Skip to main content

spec_driven_docs/gates/
ki_record.rs

1//! What every known-issue gate reads: a record's one-word axes.
2//!
3//! `state:` and `filing:` are judged the same way against different
4//! vocabularies, so the judgement lives here once and each gate supplies
5//! its key, its values, and the rule it cites. The gates that only consult
6//! an axis, rather than judge it, read [`axis`] and get `None` back where
7//! the axis gate already has something to say.
8
9use 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
14/// The values `state:` accepts.
15pub const STATES: [&str; 4] = ["investigating", "mitigated", "masked", "monitoring"];
16
17/// The values `filing:` accepts.
18pub const FILINGS: [&str; 4] = ["gathering", "ready", "filed", "deferred"];
19
20/// The one value a record's axis carries, or `None` where it carries no
21/// value, more than one, or a word outside the vocabulary.
22#[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
30/// Judge one axis across every record under the resolved roots.
31///
32/// # Errors
33///
34/// [`crate::gates::GateError::Io`] when a record cannot be read.
35pub 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}