use crate::error::SsgError;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Version(u64, u64, u64);
impl Version {
fn parse(raw: &str) -> Option<Self> {
let core = raw
.trim()
.trim_start_matches('v')
.split(['-', '+'])
.next()?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().map_or(Some(0), |p| p.parse().ok())?;
let patch = parts.next().map_or(Some(0), |p| p.parse().ok())?;
Some(Self(major, minor, patch))
}
}
fn declared_min_version(template_dir: &Path) -> Option<(String, String)> {
let candidates = [
template_dir.join("theme.toml"),
template_dir.parent()?.join("theme.toml"),
template_dir.join("theme.json"),
template_dir.parent()?.join("theme.json"),
];
for path in candidates {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let key = if path.extension().is_some_and(|e| e == "json") {
"min_ssg_version"
} else {
"min_version"
};
if let Some(v) = scan_for_key(&text, key) {
return Some((v, path.display().to_string()));
}
}
None
}
fn scan_for_key(text: &str, key: &str) -> Option<String> {
text.lines().find_map(|line| {
let line = line.trim();
if line.starts_with('#') || !line.contains(key) {
return None;
}
let (lhs, rhs) = line.split_once(['=', ':'])?;
if lhs.trim().trim_matches(['"', '\''].as_ref()) != key {
return None;
}
let value = rhs
.trim()
.trim_end_matches(',')
.trim()
.trim_matches(['"', '\''].as_ref());
(!value.is_empty()).then(|| value.to_string())
})
}
pub fn check_theme_compatibility(template_dir: &Path) -> Result<(), SsgError> {
let Some((declared, manifest)) = declared_min_version(template_dir) else {
return Ok(());
};
let (Some(required), Some(current)) = (
Version::parse(&declared),
Version::parse(env!("CARGO_PKG_VERSION")),
) else {
log::warn!(
"[theme] could not parse min_version {declared:?} in {manifest}; skipping compatibility check"
);
return Ok(());
};
if current < required {
return Err(SsgError::Validation {
field: "theme min_version".to_string(),
message: format!(
"this theme requires ssg {declared} or later, but this is {current_v}.\n\
\n\
Declared by {manifest}.\n\
\n\
Older releases fail silently rather than loudly: the layout named in\n\
front matter may be ignored so every page renders through page.html,\n\
a bundled content.schema.toml may abort the compile, and extracted\n\
CSS may 404 under a sub-path. Upgrade with `cargo install ssg`.",
current_v = env!("CARGO_PKG_VERSION"),
),
});
}
log::debug!(
"[theme] {manifest} requires ssg {declared}; running {current:?}"
);
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn version_parses_partial_and_prefixed_forms() {
assert_eq!(Version::parse("0.0.50"), Some(Version(0, 0, 50)));
assert_eq!(Version::parse("v1.2.3"), Some(Version(1, 2, 3)));
assert_eq!(Version::parse("0.1"), Some(Version(0, 1, 0)));
assert_eq!(Version::parse("2"), Some(Version(2, 0, 0)));
assert_eq!(Version::parse("0.0.50-rc.1"), Some(Version(0, 0, 50)));
assert_eq!(Version::parse("nonsense"), None);
}
#[test]
fn version_ordering_is_numeric_not_lexical() {
assert!(
Version::parse("0.0.9").unwrap()
< Version::parse("0.0.50").unwrap()
);
}
#[test]
fn no_manifest_imposes_no_floor() {
let dir = tempdir().unwrap();
assert!(check_theme_compatibility(dir.path()).is_ok());
}
#[test]
fn manifest_without_min_version_imposes_no_floor() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("theme.toml"), "name = \"x\"\n").unwrap();
assert!(check_theme_compatibility(dir.path()).is_ok());
}
#[test]
fn a_future_min_version_fails_with_both_versions_named() {
let dir = tempdir().unwrap();
let layouts = dir.path().join("_layouts");
fs::create_dir_all(&layouts).unwrap();
fs::write(dir.path().join("theme.toml"), "min_version = \"999.0.0\"\n")
.unwrap();
let err = check_theme_compatibility(&layouts).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("999.0.0"), "{msg}");
assert!(msg.contains(env!("CARGO_PKG_VERSION")), "{msg}");
assert!(msg.contains("theme.toml"), "{msg}");
}
#[test]
fn the_current_version_satisfies_its_own_floor() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("theme.toml"),
format!("min_version = \"{}\"\n", env!("CARGO_PKG_VERSION")),
)
.unwrap();
assert!(check_theme_compatibility(dir.path()).is_ok());
}
#[test]
fn theme_json_min_ssg_version_is_honoured() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("theme.json"),
"{\n \"min_ssg_version\": \"999.0.0\"\n}\n",
)
.unwrap();
assert!(check_theme_compatibility(dir.path()).is_err());
}
#[test]
fn a_malformed_version_warns_rather_than_failing_the_build() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("theme.toml"), "min_version = \"latest\"\n")
.unwrap();
assert!(check_theme_compatibility(dir.path()).is_ok());
}
#[test]
fn a_commented_out_key_is_not_read() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("theme.toml"),
"# min_version = \"999.0.0\"\nname = \"x\"\n",
)
.unwrap();
assert!(check_theme_compatibility(dir.path()).is_ok());
}
}