ssg/core/
theme_manifest.rs1use crate::error::SsgError;
23use std::path::Path;
24
25#[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 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
49fn 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
79fn 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
103pub 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 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 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 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 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}