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//!
9//! The status section is not the body. It carries the record's standing and
10//! its successor link, both of which are written after the argument is
11//! frozen, and counting them would mean a record near the cap could never be
12//! superseded.
13
14use camino::Utf8PathBuf;
15
16use crate::domain::debt::Measurement;
17use crate::domain::finding::Finding;
18use crate::domain::gate_id::GateId;
19use crate::domain::rule_id::RuleId;
20use crate::gates::budget;
21use crate::gates::paths::docs_root;
22use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
23
24/// The rules this gate can cite.
25pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
26
27const RULE: RuleId = RuleId::BodyStaysWithinWordCap;
28const CAP: usize = 350;
29
30/// Every decision record under the documentation root, unfiltered, or none
31/// where the layout holds no record.
32fn records(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
33    let decisions = docs_root(ctx).join("decisions");
34    let mut names: Vec<String> = ctx
35        .path(&decisions)
36        .read_dir_utf8()
37        .map(|entries| {
38            entries
39                .filter_map(Result::ok)
40                .map(|entry| entry.file_name().to_string())
41                .filter(|name| {
42                    name.strip_prefix("ADR-")
43                        .and_then(|rest| rest.strip_suffix(".md"))
44                        .is_some_and(|slug| !slug.is_empty())
45                })
46                .collect()
47        })
48        .unwrap_or_default();
49    names.sort();
50    names.into_iter().map(|name| decisions.join(name)).collect()
51}
52
53/// The argument a record makes, without the status that outlives it.
54fn body_of(text: &str) -> &str {
55    text.find("\n## Status").map_or(text, |at| &text[..at])
56}
57
58/// Measure every judged record: one `words` count per record.
59///
60/// # Errors
61///
62/// [`GateError::Io`] when a matched record cannot be read.
63pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
64    let mut measurements = Vec::new();
65    // The records are this gate's subjects, so a reserved one leaves the
66    // list before it is read.
67    for path in ctx.retained(records(ctx)) {
68        let words = body_of(&read_text(ctx, &path)?).split_whitespace().count();
69        measurements.push(Measurement::count(
70            GateId::AdrWordCap,
71            path.as_str(),
72            "words",
73            words,
74            CAP,
75        ));
76    }
77    Ok(measurements)
78}
79
80/// Judge every decision record under the documentation root.
81///
82/// # Errors
83///
84/// [`GateError::Io`] when a matched record cannot be read, and
85/// [`GateError::Debt`] when the debt file cannot be trusted.
86pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
87    // A project that has written no decision record yet is not a broken
88    // layout. Every landing starts in that state, and a gate that failed
89    // there would make the landing it delivered uncommittable.
90    if records(ctx).is_empty() {
91        return Ok(Vec::new());
92    }
93    let debt = budget::read_debt(ctx)?;
94    let measurements = measure(ctx)?;
95    Ok(budget::judge(
96        &debt,
97        GateId::AdrWordCap,
98        RULE,
99        &measurements,
100        |m| {
101            let words = match m.value {
102                crate::domain::debt::Measured::Count { value, .. } => value,
103                crate::domain::debt::Measured::Flag(_) => 0,
104            };
105            Violation::Finding(Finding::on_file(RULE, m.path.as_str(), words.to_string()))
106        },
107    ))
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn fixture(words: usize) -> tempfile::TempDir {
115        let dir = tempfile::tempdir().unwrap();
116        let decisions = dir.path().join("_docs/decisions");
117        std::fs::create_dir_all(&decisions).unwrap();
118        std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
119        dir
120    }
121
122    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
123        let ctx = GateCtx::new(dir.path().to_str().unwrap());
124        run(&ctx, &[])
125            .unwrap()
126            .iter()
127            .map(ToString::to_string)
128            .collect()
129    }
130
131    #[test]
132    fn the_status_section_is_not_the_body() {
133        let text = "# A\n\nOne two three.\n\n## Status\n\nSuperseded by [B](./B.md)\n";
134        assert_eq!(body_of(text).split_whitespace().count(), 5);
135    }
136
137    #[test]
138    fn accepts_a_record_at_the_cap() {
139        assert!(run_in(&fixture(350)).is_empty());
140    }
141
142    #[test]
143    fn rejects_a_record_over_the_cap() {
144        let out = run_in(&fixture(351));
145        assert_eq!(out.len(), 1);
146        assert!(out[0].contains("decision-records:body-stays-within-350-words"));
147        assert!(out[0].ends_with(": 351"));
148    }
149
150    #[test]
151    fn an_empty_record_set_has_nothing_to_judge() {
152        let dir = tempfile::tempdir().unwrap();
153        let out = run_in(&dir);
154        assert!(out.is_empty(), "{out:?}");
155    }
156
157    #[test]
158    fn a_recorded_ceiling_carries_an_oversize_record() {
159        let dir = fixture(612);
160        std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
161        std::fs::write(
162            dir.path().join(".spec-driven-docs/debt.yaml"),
163            "schema_version: 1\nadr-word-cap:\n  _docs/decisions/ADR-choice.md:\n    words:\n      ceiling: 612\n",
164        )
165        .unwrap();
166        assert!(run_in(&dir).is_empty());
167        std::fs::write(
168            dir.path().join("_docs/decisions/ADR-choice.md"),
169            "word ".repeat(613),
170        )
171        .unwrap();
172        let out = run_in(&dir);
173        assert_eq!(out.len(), 1);
174        assert!(
175            out[0].ends_with(": 613 words, recorded ceiling is 612"),
176            "{}",
177            out[0]
178        );
179    }
180}