Skip to main content

spec_driven_docs/gates/
adr_word_cap.rs

1//! Gate: a decision record's body stays within 350 words.
2//!
3//! Records are permanent, so their cost is paid on every read; the cap keeps
4//! each one a decision rather than a chapter. The record set is read from
5//! the documentation root — filename shape and heading structure belong to
6//! other gates.
7
8use crate::domain::finding::Finding;
9use crate::domain::rule_id::RuleId;
10use crate::gates::paths::docs_root;
11use crate::gates::{GateCtx, GateResult, Violation, read_text};
12
13/// The rules this gate can cite.
14pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
15
16const CAP: usize = 350;
17
18/// Judge every decision record under the documentation root.
19///
20/// # Errors
21///
22/// [`GateError::Io`] when a matched record cannot be read.
23pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
24    let decisions = docs_root(ctx).join("decisions");
25    let mut records: Vec<String> = ctx
26        .path(&decisions)
27        .read_dir_utf8()
28        .map(|entries| {
29            entries
30                .filter_map(Result::ok)
31                .map(|entry| entry.file_name().to_string())
32                .filter(|name| {
33                    name.strip_prefix("ADR-")
34                        .and_then(|rest| rest.strip_suffix(".md"))
35                        .is_some_and(|slug| !slug.is_empty())
36                })
37                .collect()
38        })
39        .unwrap_or_default();
40    records.sort();
41    if records.is_empty() {
42        return Ok(vec![Violation::Layout(
43            "no decision records matched".to_string(),
44        )]);
45    }
46
47    // The records are this gate's subjects, so a reserved one leaves the
48    // list before it is read. The layout check above reads the unfiltered
49    // set: a project that reserves every record still has a layout.
50    let records: Vec<String> = ctx
51        .retained(records.iter().map(|name| decisions.join(name)))
52        .iter()
53        .filter_map(|path| path.file_name().map(ToString::to_string))
54        .collect();
55
56    let mut violations = Vec::new();
57    for name in records {
58        let path = decisions.join(name);
59        let words = read_text(ctx, &path)?.split_whitespace().count();
60        if words > CAP {
61            violations.push(Violation::Finding(Finding::on_file(
62                RuleId::BodyStaysWithinWordCap,
63                path,
64                words.to_string(),
65            )));
66        }
67    }
68    Ok(violations)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    fn fixture(words: usize) -> tempfile::TempDir {
76        let dir = tempfile::tempdir().unwrap();
77        let decisions = dir.path().join("_docs/decisions");
78        std::fs::create_dir_all(&decisions).unwrap();
79        std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
80        dir
81    }
82
83    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
84        let ctx = GateCtx::new(dir.path().to_str().unwrap());
85        run(&ctx, &[])
86            .unwrap()
87            .iter()
88            .map(ToString::to_string)
89            .collect()
90    }
91
92    #[test]
93    fn accepts_a_record_at_the_cap() {
94        assert!(run_in(&fixture(350)).is_empty());
95    }
96
97    #[test]
98    fn rejects_a_record_over_the_cap() {
99        let out = run_in(&fixture(351));
100        assert_eq!(out.len(), 1);
101        assert!(out[0].contains("decision-records:body-stays-within-350-words"));
102        assert!(out[0].ends_with(": 351"));
103    }
104
105    #[test]
106    fn an_empty_record_set_is_a_layout_failure() {
107        let dir = tempfile::tempdir().unwrap();
108        let out = run_in(&dir);
109        assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
110    }
111}