Skip to main content

spec_driven_docs/gates/
adr_word_cap.rs

1//! Gate: a decision record's body stays within 350 words, or within the
2//! ceiling its project recorded for it.
3//!
4//! Records are permanent, so their cost is paid on every read; the cap keeps
5//! each one a decision rather than a chapter. The record set is read from
6//! the documentation root — filename shape and heading structure belong to
7//! other gates.
8
9use camino::Utf8PathBuf;
10
11use crate::domain::debt::Measurement;
12use crate::domain::finding::Finding;
13use crate::domain::gate_id::GateId;
14use crate::domain::rule_id::RuleId;
15use crate::gates::budget;
16use crate::gates::paths::docs_root;
17use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
18
19/// The rules this gate can cite.
20pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
21
22const RULE: RuleId = RuleId::BodyStaysWithinWordCap;
23const CAP: usize = 350;
24
25/// Every decision record under the documentation root, unfiltered, or none
26/// where the layout holds no record.
27fn records(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
28    let decisions = docs_root(ctx).join("decisions");
29    let mut names: Vec<String> = ctx
30        .path(&decisions)
31        .read_dir_utf8()
32        .map(|entries| {
33            entries
34                .filter_map(Result::ok)
35                .map(|entry| entry.file_name().to_string())
36                .filter(|name| {
37                    name.strip_prefix("ADR-")
38                        .and_then(|rest| rest.strip_suffix(".md"))
39                        .is_some_and(|slug| !slug.is_empty())
40                })
41                .collect()
42        })
43        .unwrap_or_default();
44    names.sort();
45    names.into_iter().map(|name| decisions.join(name)).collect()
46}
47
48/// Measure every judged record: one `words` count per record.
49///
50/// # Errors
51///
52/// [`GateError::Io`] when a matched record cannot be read.
53pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
54    let mut measurements = Vec::new();
55    // The records are this gate's subjects, so a reserved one leaves the
56    // list before it is read.
57    for path in ctx.retained(records(ctx)) {
58        let words = read_text(ctx, &path)?.split_whitespace().count();
59        measurements.push(Measurement::count(
60            GateId::AdrWordCap,
61            path.as_str(),
62            "words",
63            words,
64            CAP,
65        ));
66    }
67    Ok(measurements)
68}
69
70/// Judge every decision record under the documentation root.
71///
72/// # Errors
73///
74/// [`GateError::Io`] when a matched record cannot be read, and
75/// [`GateError::Debt`] when the debt file cannot be trusted.
76pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
77    // The layout check reads the unfiltered set: a project that reserves
78    // every record still has a layout.
79    if records(ctx).is_empty() {
80        return Ok(vec![Violation::Layout(
81            "no decision records matched".to_string(),
82        )]);
83    }
84    let debt = budget::read_debt(ctx)?;
85    let measurements = measure(ctx)?;
86    Ok(budget::judge(
87        &debt,
88        GateId::AdrWordCap,
89        RULE,
90        &measurements,
91        |m| {
92            let words = match m.value {
93                crate::domain::debt::Measured::Count { value, .. } => value,
94                crate::domain::debt::Measured::Flag(_) => 0,
95            };
96            Violation::Finding(Finding::on_file(RULE, m.path.as_str(), words.to_string()))
97        },
98    ))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn fixture(words: usize) -> tempfile::TempDir {
106        let dir = tempfile::tempdir().unwrap();
107        let decisions = dir.path().join("_docs/decisions");
108        std::fs::create_dir_all(&decisions).unwrap();
109        std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
110        dir
111    }
112
113    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
114        let ctx = GateCtx::new(dir.path().to_str().unwrap());
115        run(&ctx, &[])
116            .unwrap()
117            .iter()
118            .map(ToString::to_string)
119            .collect()
120    }
121
122    #[test]
123    fn accepts_a_record_at_the_cap() {
124        assert!(run_in(&fixture(350)).is_empty());
125    }
126
127    #[test]
128    fn rejects_a_record_over_the_cap() {
129        let out = run_in(&fixture(351));
130        assert_eq!(out.len(), 1);
131        assert!(out[0].contains("decision-records:body-stays-within-350-words"));
132        assert!(out[0].ends_with(": 351"));
133    }
134
135    #[test]
136    fn an_empty_record_set_is_a_layout_failure() {
137        let dir = tempfile::tempdir().unwrap();
138        let out = run_in(&dir);
139        assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
140    }
141
142    #[test]
143    fn a_recorded_ceiling_carries_an_oversize_record() {
144        let dir = fixture(612);
145        std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
146        std::fs::write(
147            dir.path().join(".spec-driven-docs/debt.yaml"),
148            "schema_version: 1\nadr-word-cap:\n  _docs/decisions/ADR-choice.md:\n    words:\n      ceiling: 612\n",
149        )
150        .unwrap();
151        assert!(run_in(&dir).is_empty());
152        std::fs::write(
153            dir.path().join("_docs/decisions/ADR-choice.md"),
154            "word ".repeat(613),
155        )
156        .unwrap();
157        let out = run_in(&dir);
158        assert_eq!(out.len(), 1);
159        assert!(
160            out[0].ends_with(": 613 words, recorded ceiling is 612"),
161            "{}",
162            out[0]
163        );
164    }
165}