1use corpora_core::{Diagnostic, DocPath, Graph, OracleStatus, RevisionOracle};
7
8use crate::Rule;
9
10pub struct GateB {
11 oracle: Box<dyn RevisionOracle>,
12}
13
14impl GateB {
15 pub fn new(oracle: Box<dyn RevisionOracle>) -> Self {
16 GateB { oracle }
17 }
18}
19
20impl Rule for GateB {
21 fn code(&self) -> &'static str {
22 "GATE_B"
23 }
24
25 fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
26 match self.oracle.status() {
27 OracleStatus::Disabled => return,
29 OracleStatus::Unavailable => {
32 out.push(Diagnostic::warn(
33 "GATE_B",
34 &DocPath("(repository)".into()),
35 "git is unavailable; code_revision pins are unverified".to_string(),
36 ));
37 return;
38 }
39 OracleStatus::NoRepo => {
42 let pins = g.records().filter(|r| r.facet.code_revision().is_some()).count();
43 if pins > 0 {
44 out.push(Diagnostic::warn(
45 "GATE_B",
46 &DocPath("(repository)".into()),
47 format!("no git repository found; {pins} code_revision pin(s) unverified"),
48 ));
49 }
50 return;
51 }
52 OracleStatus::Ready => {}
53 }
54
55 for r in g.records() {
56 if let Some(rev) = r.facet.code_revision() {
57 if self.oracle.resolve(rev).is_none() {
58 out.push(Diagnostic::error(
59 "GATE_B",
60 &r.path,
61 format!("code_revision {} does not resolve in the repository", rev.0),
62 ));
63 }
64 }
65 }
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use corpora_core::*;
73 use std::sync::Arc;
74
75 struct FakeOracle {
77 status: OracleStatus,
78 resolves: bool,
79 }
80 impl RevisionOracle for FakeOracle {
81 fn status(&self) -> OracleStatus {
82 self.status
83 }
84 fn resolve(&self, _r: &Rev) -> Option<Rev> {
85 self.resolves.then(|| Rev("resolved".into()))
86 }
87 }
88
89 fn current_record(rev: &str) -> Record {
90 Record::minimal(
91 Some(Id("C1".into())),
92 DocPath("c.md".into()),
93 Kind::Current,
94 Lifecycle::Current,
95 Authority::Descriptive,
96 Facet::Current {
97 implementation: Impl::Implemented,
98 code_revision: Rev(rev.into()),
99 source_revision: None,
100 },
101 )
102 }
103
104 fn run(oracle: Box<dyn RevisionOracle>, records: Vec<Record>) -> Vec<Diagnostic> {
105 let g = Graph::build(records.into_iter().map(Arc::new).collect()).0;
106 let mut out = Vec::new();
107 GateB::new(oracle).check(&g, &mut out);
108 out
109 }
110
111 #[test]
112 fn unresolvable_revision_flagged() {
113 let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: false });
114 let out = run(oracle, vec![current_record("deadbeef")]);
115 assert_eq!(out.len(), 1);
116 assert_eq!(out[0].code, "GATE_B");
117 assert_eq!(out[0].severity, Severity::Error);
118 }
119
120 #[test]
121 fn resolvable_revision_passes() {
122 let oracle = Box::new(FakeOracle { status: OracleStatus::Ready, resolves: true });
123 let out = run(oracle, vec![current_record("cafef00d")]);
124 assert!(out.is_empty(), "{out:?}");
125 }
126
127 #[test]
128 fn disabled_skips_silently_even_with_pins() {
129 assert!(run(Box::new(NullOracle), vec![current_record("deadbeef")]).is_empty());
130 }
131
132 #[test]
133 fn no_repo_warns_when_there_are_pins() {
134 let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
135 let out = run(oracle, vec![current_record("deadbeef")]);
136 assert_eq!(out.len(), 1);
137 assert_eq!(out[0].severity, Severity::Warning);
138 }
139
140 #[test]
141 fn no_repo_silent_without_pins() {
142 let narrative = Record::minimal(
143 Some(Id("D1".into())),
144 DocPath("d1.md".into()),
145 Kind::Decision,
146 Lifecycle::Current,
147 Authority::Normative,
148 Facet::Narrative,
149 );
150 let oracle = Box::new(FakeOracle { status: OracleStatus::NoRepo, resolves: false });
151 assert!(run(oracle, vec![narrative]).is_empty());
152 }
153
154 #[test]
155 fn unavailable_git_warns() {
156 let oracle = Box::new(FakeOracle { status: OracleStatus::Unavailable, resolves: false });
157 let out = run(oracle, vec![current_record("deadbeef")]);
158 assert_eq!(out.len(), 1);
159 assert_eq!(out[0].severity, Severity::Warning);
160 }
161}