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    let mut violations = Vec::new();
48    for name in records {
49        let path = decisions.join(name);
50        let words = read_text(ctx, &path)?.split_whitespace().count();
51        if words > CAP {
52            violations.push(Violation::Finding(Finding::on_file(
53                RuleId::BodyStaysWithinWordCap,
54                path,
55                words.to_string(),
56            )));
57        }
58    }
59    Ok(violations)
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    fn fixture(words: usize) -> tempfile::TempDir {
67        let dir = tempfile::tempdir().unwrap();
68        let decisions = dir.path().join("_docs/decisions");
69        std::fs::create_dir_all(&decisions).unwrap();
70        std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
71        dir
72    }
73
74    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
75        let ctx = GateCtx::new(dir.path().to_str().unwrap());
76        run(&ctx, &[])
77            .unwrap()
78            .iter()
79            .map(ToString::to_string)
80            .collect()
81    }
82
83    #[test]
84    fn accepts_a_record_at_the_cap() {
85        assert!(run_in(&fixture(350)).is_empty());
86    }
87
88    #[test]
89    fn rejects_a_record_over_the_cap() {
90        let out = run_in(&fixture(351));
91        assert_eq!(out.len(), 1);
92        assert!(out[0].contains("decision-records:body-stays-within-350-words"));
93        assert!(out[0].ends_with(": 351"));
94    }
95
96    #[test]
97    fn an_empty_record_set_is_a_layout_failure() {
98        let dir = tempfile::tempdir().unwrap();
99        let out = run_in(&dir);
100        assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
101    }
102}