spec_driven_docs/gates/
ki_record.rs1use crate::domain::finding::Finding;
10use crate::domain::rule_id::RuleId;
11use crate::gates::paths::ki_records_judged;
12use crate::gates::{GateCtx, GateResult, Violation, front_matter_values, read_text};
13
14pub const STATES: [&str; 4] = ["investigating", "mitigated", "masked", "monitoring"];
16
17pub const FILINGS: [&str; 4] = ["gathering", "ready", "filed", "deferred"];
19
20#[must_use]
23pub fn axis(text: &str, key: &str, allowed: &[&str]) -> Option<String> {
24 match front_matter_values(text, key).as_slice() {
25 [value] if allowed.contains(&value.as_str()) => Some(value.clone()),
26 _ => None,
27 }
28}
29
30pub fn judge(
36 ctx: &GateCtx,
37 args: &[String],
38 key: &str,
39 allowed: &[&str],
40 rule: RuleId,
41) -> GateResult {
42 let mut violations = Vec::new();
43 for record in ki_records_judged(ctx, args)? {
44 let text = read_text(ctx, &record)?;
45 let values = front_matter_values(&text, key);
46 let detail = match values.as_slice() {
47 [value] if allowed.contains(&value.as_str()) => continue,
48 [] => format!("no {key}: line, expected one of {}", allowed.join(", ")),
49 [value] => format!("{key}: {value} is not one of {}", allowed.join(", ")),
50 many => format!("{} {key}: lines, expected one", many.len()),
51 };
52 violations.push(Violation::Finding(Finding::on_file(rule, &record, detail)));
53 }
54 Ok(violations)
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 fn run_on(state: &str) -> Vec<String> {
61 run_on_record(&format!("---\nstate: {state}\n---\n# V\n"))
62 }
63
64 fn run_on_record(text: &str) -> Vec<String> {
65 let dir = tempfile::tempdir().unwrap();
66 let records = dir.path().join("_docs/reference/known-issues");
67 std::fs::create_dir_all(&records).unwrap();
68 std::fs::write(records.join("KI-vendor.md"), text).unwrap();
69 let ctx = GateCtx::new(dir.path().to_str().unwrap());
70 judge(&ctx, &[], "state", &STATES, RuleId::RecordCarriesOneState)
71 .unwrap()
72 .iter()
73 .map(ToString::to_string)
74 .collect()
75 }
76
77 #[test]
78 fn accepts_a_value_from_the_vocabulary() {
79 assert!(run_on("monitoring").is_empty());
80 }
81
82 #[test]
83 fn rejects_a_word_outside_the_vocabulary() {
84 let out = run_on("closed");
85 assert_eq!(out.len(), 1);
86 assert!(out[0].contains("known-issues:a-record-carries-one-state"));
87 assert!(out[0].ends_with(
88 ": state: closed is not one of investigating, mitigated, masked, monitoring"
89 ));
90 }
91
92 #[test]
93 fn rejects_a_record_that_states_no_axis() {
94 let out = run_on_record("---\nupstream: https://example.invalid/issues\n---\n# V\n");
95 assert_eq!(out.len(), 1);
96 assert!(out[0].ends_with(
97 ": no state: line, expected one of investigating, mitigated, masked, monitoring"
98 ));
99 }
100
101 #[test]
102 fn rejects_a_record_that_states_the_axis_twice() {
103 let out = run_on_record("---\nstate: masked\nstate: monitoring\n---\n# V\n");
104 assert_eq!(out.len(), 1);
105 assert!(out[0].ends_with(": 2 state: lines, expected one"));
106 }
107
108 #[test]
109 fn axis_reads_the_one_value_and_nothing_else() {
110 let text = "---\nstate: masked\n---\nstate: monitoring\n";
111 assert_eq!(axis(text, "state", &STATES), Some("masked".to_string()));
112 assert_eq!(axis("state: masked\n", "state", &STATES), None);
113 assert_eq!(axis("---\nstate: closed\n---\n", "state", &STATES), None);
114 }
115}