cordance-advise 0.1.1

Cordance advisory engine. Deterministic doctrine checks against project state.
Documentation
//! R-schema-1 — schema files should use versioned naming (*.vN.schema.json).

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;

/// Returns true when a schema filename contains a version marker of the form
/// `.vN` where N is one or more digits, immediately before `.schema.json`.
///
/// Examples:
/// - `foo.v1.schema.json`  → versioned
/// - `foo.v12.schema.json` → versioned
/// - `foo.schema.json`     → not versioned
fn is_versioned(filename: &str) -> bool {
    // Strip the `.schema.json` suffix and look for `.vN` at the end.
    if let Some(stem) = filename.strip_suffix(".schema.json") {
        // Find the last `.` in the stem and check whether the part after it
        // matches `vN+`.
        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"));
    }
}