use camino::Utf8PathBuf;
use cordance_core::advise::{AdviseFinding, Severity};
use cordance_core::pack::CordancePack;
use cordance_core::source::SourceClass;
use super::AdviseRule;
pub struct RSchemaVersion1;
fn is_versioned(filename: &str) -> bool {
if let Some(stem) = filename.strip_suffix(".schema.json") {
if let Some(dot_pos) = stem.rfind('.') {
let tag = &stem[dot_pos + 1..];
if let Some(rest) = tag.strip_prefix('v') {
return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
}
}
}
false
}
impl AdviseRule for RSchemaVersion1 {
fn id(&self) -> &'static str {
"R-schema-1"
}
fn doctrine_anchor(&self) -> &'static str {
"doctrine/principles/event-contracts.md"
}
fn check(&self, pack: &CordancePack) -> Vec<AdviseFinding> {
let mut findings = Vec::new();
let schemas = pack.sources.iter().filter(|r| {
r.class == SourceClass::ProjectSchema && r.path.as_str().ends_with(".schema.json")
});
for schema in schemas {
let path_str = schema.path.as_str();
let filename = schema.path.file_name().unwrap_or(path_str);
if !is_versioned(filename) {
findings.push(AdviseFinding {
id: self.id().into(),
severity: Severity::Info,
summary: format!(
"Schema file {path_str} does not use versioned naming \
(e.g., foo.v1.schema.json)."
),
doctrine_anchor: Utf8PathBuf::from(self.doctrine_anchor()),
project_paths: vec![schema.path.clone()],
remediation: format!(
"Rename to include version: mv {path_str} \
contracts/foo.v1.schema.json. Never mutate a versioned schema \
— add a new version."
),
});
}
}
findings
}
}
#[cfg(test)]
mod tests {
use super::is_versioned;
#[test]
fn versioned_names_pass() {
assert!(is_versioned("foo.v1.schema.json"));
assert!(is_versioned("foo.v12.schema.json"));
assert!(is_versioned("bar-baz.v2.schema.json"));
}
#[test]
fn unversioned_names_fail() {
assert!(!is_versioned("foo.schema.json"));
assert!(!is_versioned("foo.va.schema.json"));
assert!(!is_versioned("foo.v.schema.json"));
}
}