Skip to main content

ssg/core/
theme_manifest.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Theme manifest compatibility check.
5//!
6//! A theme declares the oldest generator it works with:
7//!
8//! ```toml
9//! # themes/atlas/theme.toml
10//! min_version = "0.0.50"
11//! ```
12//!
13//! Nothing used to read it. That mattered because the ways a too-old
14//! generator breaks a theme are all *silent*: before v0.0.50 the `layout`
15//! named in front matter was ignored and every page rendered through
16//! `page.html`, a bundled `content.schema.toml` aborted the compile with an
17//! unrelated message, and extracted CSS 404'd under a sub-path. A user on
18//! an older release got a build that succeeded and a site that was wrong.
19//!
20//! This turns that into one clear error at the start of the build.
21
22use crate::error::SsgError;
23use std::path::Path;
24
25/// A semantic version reduced to the three numeric components ssg uses.
26///
27/// Pre-release and build metadata are ignored: `0.0.50-rc.1` compares equal
28/// to `0.0.50`. Themes pin a floor, not an exact build, so treating a
29/// release candidate as satisfying its own floor is the useful behaviour.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
31struct Version(u64, u64, u64);
32
33impl Version {
34    fn parse(raw: &str) -> Option<Self> {
35        let core = raw
36            .trim()
37            .trim_start_matches('v')
38            .split(['-', '+'])
39            .next()?;
40        let mut parts = core.split('.');
41        let major = parts.next()?.parse().ok()?;
42        // A theme may pin `0.1` or even `1`; absent components are zero.
43        let minor = parts.next().map_or(Some(0), |p| p.parse().ok())?;
44        let patch = parts.next().map_or(Some(0), |p| p.parse().ok())?;
45        Some(Self(major, minor, patch))
46    }
47}
48
49/// Reads `min_version` from a theme manifest beside `template_dir`.
50///
51/// Layouts conventionally live in `<theme>/_layouts`, so the manifest is
52/// looked for in the template directory itself and then in its parent.
53/// `theme.toml` wins over `theme.json`; a theme with neither, or with no
54/// `min_version`, imposes no floor.
55fn declared_min_version(template_dir: &Path) -> Option<(String, String)> {
56    let candidates = [
57        template_dir.join("theme.toml"),
58        template_dir.parent()?.join("theme.toml"),
59        template_dir.join("theme.json"),
60        template_dir.parent()?.join("theme.json"),
61    ];
62
63    for path in candidates {
64        let Ok(text) = std::fs::read_to_string(&path) else {
65            continue;
66        };
67        let key = if path.extension().is_some_and(|e| e == "json") {
68            "min_ssg_version"
69        } else {
70            "min_version"
71        };
72        if let Some(v) = scan_for_key(&text, key) {
73            return Some((v, path.display().to_string()));
74        }
75    }
76    None
77}
78
79/// Pulls `key = "value"` / `"key": "value"` out of a manifest.
80///
81/// Deliberately not a TOML/JSON parse: this runs before anything else in
82/// the build, and a malformed manifest should not be able to abort a build
83/// that would otherwise succeed. A key it cannot find imposes no floor.
84fn scan_for_key(text: &str, key: &str) -> Option<String> {
85    text.lines().find_map(|line| {
86        let line = line.trim();
87        if line.starts_with('#') || !line.contains(key) {
88            return None;
89        }
90        let (lhs, rhs) = line.split_once(['=', ':'])?;
91        if lhs.trim().trim_matches(['"', '\''].as_ref()) != key {
92            return None;
93        }
94        let value = rhs
95            .trim()
96            .trim_end_matches(',')
97            .trim()
98            .trim_matches(['"', '\''].as_ref());
99        (!value.is_empty()).then(|| value.to_string())
100    })
101}
102
103/// Fails the build when the theme requires a newer generator than this one.
104///
105/// # Errors
106///
107/// Returns [`SsgError::Validation`] naming both versions and the manifest
108/// that declared the floor.
109pub fn check_theme_compatibility(template_dir: &Path) -> Result<(), SsgError> {
110    let Some((declared, manifest)) = declared_min_version(template_dir) else {
111        return Ok(());
112    };
113    let (Some(required), Some(current)) = (
114        Version::parse(&declared),
115        Version::parse(env!("CARGO_PKG_VERSION")),
116    ) else {
117        // An unparseable version is the theme author's typo, not a reason to
118        // refuse to build. Warn and continue.
119        log::warn!(
120            "[theme] could not parse min_version {declared:?} in {manifest}; skipping compatibility check"
121        );
122        return Ok(());
123    };
124
125    if current < required {
126        return Err(SsgError::Validation {
127            field: "theme min_version".to_string(),
128            message: format!(
129            "this theme requires ssg {declared} or later, but this is {current_v}.\n\
130             \n\
131             Declared by {manifest}.\n\
132             \n\
133             Older releases fail silently rather than loudly: the layout named in\n\
134             front matter may be ignored so every page renders through page.html,\n\
135             a bundled content.schema.toml may abort the compile, and extracted\n\
136             CSS may 404 under a sub-path. Upgrade with `cargo install ssg`.",
137            current_v = env!("CARGO_PKG_VERSION"),
138            ),
139        });
140    }
141
142    log::debug!(
143        "[theme] {manifest} requires ssg {declared}; running {current:?}"
144    );
145    Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::fs;
152    use tempfile::tempdir;
153
154    #[test]
155    fn version_parses_partial_and_prefixed_forms() {
156        assert_eq!(Version::parse("0.0.50"), Some(Version(0, 0, 50)));
157        assert_eq!(Version::parse("v1.2.3"), Some(Version(1, 2, 3)));
158        assert_eq!(Version::parse("0.1"), Some(Version(0, 1, 0)));
159        assert_eq!(Version::parse("2"), Some(Version(2, 0, 0)));
160        // Pre-release satisfies its own floor.
161        assert_eq!(Version::parse("0.0.50-rc.1"), Some(Version(0, 0, 50)));
162        assert_eq!(Version::parse("nonsense"), None);
163    }
164
165    #[test]
166    fn version_ordering_is_numeric_not_lexical() {
167        // The bug a string compare would introduce: "0.0.9" > "0.0.50".
168        assert!(
169            Version::parse("0.0.9").unwrap()
170                < Version::parse("0.0.50").unwrap()
171        );
172    }
173
174    #[test]
175    fn no_manifest_imposes_no_floor() {
176        let dir = tempdir().unwrap();
177        assert!(check_theme_compatibility(dir.path()).is_ok());
178    }
179
180    #[test]
181    fn manifest_without_min_version_imposes_no_floor() {
182        let dir = tempdir().unwrap();
183        fs::write(dir.path().join("theme.toml"), "name = \"x\"\n").unwrap();
184        assert!(check_theme_compatibility(dir.path()).is_ok());
185    }
186
187    #[test]
188    fn a_future_min_version_fails_with_both_versions_named() {
189        let dir = tempdir().unwrap();
190        let layouts = dir.path().join("_layouts");
191        fs::create_dir_all(&layouts).unwrap();
192        // Manifest sits beside _layouts, as themes ship it.
193        fs::write(dir.path().join("theme.toml"), "min_version = \"999.0.0\"\n")
194            .unwrap();
195
196        let err = check_theme_compatibility(&layouts).unwrap_err();
197        let msg = format!("{err}");
198        assert!(msg.contains("999.0.0"), "{msg}");
199        assert!(msg.contains(env!("CARGO_PKG_VERSION")), "{msg}");
200        assert!(msg.contains("theme.toml"), "{msg}");
201    }
202
203    #[test]
204    fn the_current_version_satisfies_its_own_floor() {
205        let dir = tempdir().unwrap();
206        fs::write(
207            dir.path().join("theme.toml"),
208            format!("min_version = \"{}\"\n", env!("CARGO_PKG_VERSION")),
209        )
210        .unwrap();
211        assert!(check_theme_compatibility(dir.path()).is_ok());
212    }
213
214    #[test]
215    fn theme_json_min_ssg_version_is_honoured() {
216        let dir = tempdir().unwrap();
217        fs::write(
218            dir.path().join("theme.json"),
219            "{\n  \"min_ssg_version\": \"999.0.0\"\n}\n",
220        )
221        .unwrap();
222        assert!(check_theme_compatibility(dir.path()).is_err());
223    }
224
225    #[test]
226    fn a_malformed_version_warns_rather_than_failing_the_build() {
227        let dir = tempdir().unwrap();
228        fs::write(dir.path().join("theme.toml"), "min_version = \"latest\"\n")
229            .unwrap();
230        assert!(check_theme_compatibility(dir.path()).is_ok());
231    }
232
233    #[test]
234    fn a_commented_out_key_is_not_read() {
235        let dir = tempdir().unwrap();
236        fs::write(
237            dir.path().join("theme.toml"),
238            "# min_version = \"999.0.0\"\nname = \"x\"\n",
239        )
240        .unwrap();
241        assert!(check_theme_compatibility(dir.path()).is_ok());
242    }
243}