corpora-rules 0.1.0

Validation rule pack (E3, held-decision, schema) for the corpora docs validator.
Documentation
//! Gate B — every pinned `code_revision` must resolve in the repository. The git lookup is
//! injected via a [`RevisionOracle`], so the rule stays pure-testable. It distinguishes an
//! intentional no-op and a repo-less corpus (skip) from a real misconfiguration such as a
//! missing `git` binary, which is surfaced as a warning instead of failing open silently.

use corpora_core::{Diagnostic, DocPath, Graph, OracleStatus, RevisionOracle};

use crate::Rule;

pub struct GateB {
    oracle: Box<dyn RevisionOracle>,
}

impl GateB {
    pub fn new(oracle: Box<dyn RevisionOracle>) -> Self {
        GateB { oracle }
    }
}

impl Rule for GateB {
    fn code(&self) -> &'static str {
        "GATE_B"
    }

    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
        match self.oracle.status() {
            // Only the explicitly-disabled NullOracle fails open silently.
            OracleStatus::Disabled => return,
            // Backend wanted but unusable (e.g. git missing): make the gap visible rather
            // than passing every pin unchecked.
            OracleStatus::Unavailable => {
                out.push(Diagnostic::warn(
                    "GATE_B",
                    &DocPath("(repository)".into()),
                    "git is unavailable; code_revision pins are unverified".to_string(),
                ));
                return;
            }
            // No repository here (e.g. a mistyped --repo). Silence would hide unverified
            // pins, so warn when there are any; stay quiet only if nothing needs verifying.
            OracleStatus::NoRepo => {
                let pins = g.records().filter(|r| r.facet.code_revision().is_some()).count();
                if pins > 0 {
                    out.push(Diagnostic::warn(
                        "GATE_B",
                        &DocPath("(repository)".into()),
                        format!("no git repository found; {pins} code_revision pin(s) unverified"),
                    ));
                }
                return;
            }
            OracleStatus::Ready => {}
        }

        for r in g.records() {
            if let Some(rev) = r.facet.code_revision() {
                if self.oracle.resolve(rev).is_none() {
                    out.push(Diagnostic::error(
                        "GATE_B",
                        &r.path,
                        format!("code_revision {} does not resolve in the repository", rev.0),
                    ));
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use corpora_core::*;
    use std::sync::Arc;

    /// Configurable stand-in: pick a status and whether `resolve` succeeds.
    struct FakeOracle {
        status: OracleStatus,
        resolves: bool,
    }
    impl RevisionOracle for FakeOracle {
        fn status(&self) -> OracleStatus {
            self.status
        }
        fn resolve(&self, _r: &Rev) -> Option<Rev> {
            self.resolves.then(|| Rev("resolved".into()))
        }
    }

    fn current_record(rev: &str) -> Record {
        Record::minimal(
            Some(Id("C1".into())),
            DocPath("c.md".into()),
            Kind::Current,
            Lifecycle::Current,
            Authority::Descriptive,
            Facet::Current {
                implementation: Impl::Implemented,
                code_revision: Rev(rev.into()),
                source_revision: None,
            },
        )
    }

    fn run(oracle: Box<dyn RevisionOracle>, records: Vec<Record>) -> Vec<Diagnostic> {
        let g = Graph::build(records.into_iter().map(Arc::new).collect()).0;
        let mut out = Vec::new();
        GateB::new(oracle).check(&g, &mut out);
        out
    }

    #[test]
    fn unresolvable_revision_flagged() {
        let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: false });
        let out = run(oracle, vec![current_record("deadbeef")]);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].code, "GATE_B");
        assert_eq!(out[0].severity, Severity::Error);
    }

    #[test]
    fn resolvable_revision_passes() {
        let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: true });
        let out = run(oracle, vec![current_record("cafef00d")]);
        assert!(out.is_empty(), "{out:?}");
    }

    #[test]
    fn disabled_skips_silently_even_with_pins() {
        assert!(run(Box::new(NullOracle), vec![current_record("deadbeef")]).is_empty());
    }

    #[test]
    fn no_repo_warns_when_there_are_pins() {
        let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
        let out = run(oracle, vec![current_record("deadbeef")]);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].severity, Severity::Warning);
    }

    #[test]
    fn no_repo_silent_without_pins() {
        let narrative = Record::minimal(
            Some(Id("D1".into())),
            DocPath("d1.md".into()),
            Kind::Decision,
            Lifecycle::Current,
            Authority::Normative,
            Facet::Narrative,
        );
        let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
        assert!(run(oracle, vec![narrative]).is_empty());
    }

    #[test]
    fn unavailable_git_warns() {
        let oracle = Box::new(FakeOracle { status: OracleStatus::Unavailable, resolves: false });
        let out = run(oracle, vec![current_record("deadbeef")]);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].severity, Severity::Warning);
    }
}