Skip to main content

spec_driven_docs/gates/
gate_message_cites_a_rule.rs

1//! Gate: every rule ID a gate can print resolves to a requirement in the
2//! local specs.
3//!
4//! The id in a failure message is an address: the reader follows it to the
5//! sentence that binds, and a message naming an id no spec defines sends
6//! them nowhere. The citable set comes from the compiled gate registry —
7//! every rule a delivered gate declares — and the defined set from the
8//! instance's own specs, so a spec rewrite that renames a rule fails here
9//! before a gate ever prints the stale address.
10
11use std::collections::BTreeSet;
12
13use crate::domain::finding::Finding;
14use crate::domain::rule_id::RuleId;
15use crate::gates::paths::docs_root;
16use crate::gates::{GateCtx, GateResult, Violation, read_text};
17
18/// The rules this gate can cite.
19pub const CITES: &[RuleId] = &[RuleId::GateMessageCitesTheRule];
20
21/// Judge the registry's citable set against the local specs.
22///
23/// # Errors
24///
25/// [`crate::gates::GateError::Io`] when a local spec cannot be read.
26pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
27    let specs = docs_root(ctx).join("specs");
28    let Ok(entries) = ctx.path(&specs).read_dir_utf8() else {
29        return Ok(vec![Violation::Layout(format!(
30            "no specs directory at {specs}; the layout moved"
31        ))]);
32    };
33    let mut defined: BTreeSet<String> = BTreeSet::new();
34    for entry in entries.filter_map(Result::ok) {
35        let name = entry.file_name();
36        #[allow(
37            clippy::case_sensitive_file_extension_comparisons,
38            reason = "the corpus convention is lowercase, and `.MD` is not a spec"
39        )]
40        if !name.ends_with(".md") {
41            continue;
42        }
43        defined.extend(crate::embedded::rule_ids_in(&read_text(
44            ctx,
45            specs.join(name),
46        )?));
47    }
48
49    let cited: BTreeSet<RuleId> = crate::gates::GATES
50        .iter()
51        .flat_map(|gate| gate.cites.iter().copied())
52        .collect();
53    Ok(cited
54        .into_iter()
55        .filter(|rule| !defined.contains(rule.as_str()))
56        .map(|rule| {
57            Violation::Finding(Finding::global(
58                RuleId::GateMessageCitesTheRule,
59                format!("{rule} resolves to no requirement"),
60            ))
61        })
62        .collect())
63}
64
65#[cfg(test)]
66mod tests {
67    use std::fmt::Write as _;
68
69    use super::*;
70
71    fn write_spec(dir: &tempfile::TempDir, name: &str, text: &str) {
72        let specs = dir.path().join("_docs/specs");
73        std::fs::create_dir_all(&specs).unwrap();
74        std::fs::write(specs.join(name), text).unwrap();
75    }
76
77    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
78        let ctx = GateCtx::new(dir.path().to_str().unwrap());
79        run(&ctx, &[])
80            .unwrap()
81            .iter()
82            .map(ToString::to_string)
83            .collect()
84    }
85
86    #[test]
87    fn accepts_specs_defining_every_citable_rule() {
88        let dir = tempfile::tempdir().unwrap();
89        let cited: BTreeSet<RuleId> = crate::gates::GATES
90            .iter()
91            .flat_map(|gate| gate.cites.iter().copied())
92            .collect();
93        let mut text = String::new();
94        for rule in &cited {
95            let _ = write!(text, "### `{rule}` — Rule\n\n");
96        }
97        write_spec(&dir, "SPEC-all.md", &text);
98        assert!(run_in(&dir).is_empty());
99    }
100
101    #[test]
102    fn rejects_a_citable_rule_the_local_specs_lost() {
103        let dir = tempfile::tempdir().unwrap();
104        write_spec(&dir, "SPEC-thin.md", "### `sample:works` — Works\n");
105        let out = run_in(&dir);
106        assert!(!out.is_empty());
107        assert!(out.iter().all(|line| {
108            line.starts_with("FAIL spec-to-code:a-gate-message-cites-the-rule: ")
109                && line.ends_with(" resolves to no requirement")
110        }));
111    }
112
113    #[test]
114    fn a_missing_specs_directory_is_a_layout_failure() {
115        let dir = tempfile::tempdir().unwrap();
116        let out = run_in(&dir);
117        assert_eq!(out.len(), 1);
118        assert!(out[0].starts_with("FAIL no specs directory at "));
119        assert!(out[0].ends_with("; the layout moved"));
120    }
121}