Skip to main content

spec_driven_docs/gates/
spec_size_cap.rs

1//! Gate: a spec stays within 300 authored lines and carries a TOC above 100,
2//! or within the ceiling and exception its project recorded for it.
3//!
4//! The generated TOC is excluded from the count: it grows with the
5//! requirement list and would otherwise spend the author's budget on
6//! navigation. Excluding it means trusting its delimiters, so the pair is
7//! checked first — a spec carrying one marker and nothing to close it would
8//! have every line after that marker deleted from the count, which is the
9//! over-budget file the cap exists to reject. A malformed pair takes no
10//! measurement and no debt entry: it stays strict.
11//!
12//! The two dimensions are measured independently. Reporting the missing
13//! table of contents only once the line count fits would let a recorded
14//! ceiling silence the one active finding and let the second appear later,
15//! as a reward for shrinking the file.
16
17use crate::domain::debt::Measurement;
18use crate::domain::finding::Finding;
19use crate::domain::gate_id::GateId;
20use crate::domain::rule_id::RuleId;
21use crate::gates::budget;
22use crate::gates::spec_rule_id_unique::spec_files_judged;
23use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
24
25/// The rules this gate can cite.
26pub const CITES: &[RuleId] = &[RuleId::SpecStaysWithinLineCap];
27
28const RULE: RuleId = RuleId::SpecStaysWithinLineCap;
29const MARKER: &str = "<!--TOC-->";
30const CAP: usize = 300;
31const TOC_FROM: usize = 100;
32
33/// What one spec measured, or why it could not be measured.
34struct Counted {
35    path: String,
36    markers: usize,
37    authored: usize,
38}
39
40fn count(ctx: &GateCtx, file: &camino::Utf8Path) -> Result<Counted, GateError> {
41    let text = read_text(ctx, file)?;
42    let markers = text.lines().filter(|line| *line == MARKER).count();
43    let mut inside_toc = false;
44    let authored = text
45        .lines()
46        .filter(|line| {
47            if *line == MARKER {
48                inside_toc = !inside_toc;
49                return false;
50            }
51            !inside_toc
52        })
53        .count();
54    Ok(Counted {
55        path: file.to_string(),
56        markers,
57        authored,
58    })
59}
60
61fn measurements_of(counted: &Counted) -> [Measurement; 2] {
62    [
63        Measurement::count(
64            GateId::SpecSizeCap,
65            counted.path.as_str(),
66            "authored_lines",
67            counted.authored,
68            CAP,
69        ),
70        Measurement::flag(
71            GateId::SpecSizeCap,
72            counted.path.as_str(),
73            "missing_toc",
74            counted.authored > TOC_FROM && counted.markers == 0,
75        ),
76    ]
77}
78
79/// Measure every spec under the documentation root on both dimensions. A
80/// spec with a malformed marker pair is skipped, as the gate skips it.
81///
82/// # Errors
83///
84/// [`GateError::Io`] when a spec cannot be read.
85pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
86    let mut measurements = Vec::new();
87    for file in spec_files_judged(ctx).unwrap_or_default() {
88        let counted = count(ctx, &file)?;
89        if counted.markers == 0 || counted.markers == 2 {
90            measurements.extend(measurements_of(&counted));
91        }
92    }
93    Ok(measurements)
94}
95
96/// Judge every spec under the documentation root.
97///
98/// # Errors
99///
100/// [`GateError::Io`] when a spec cannot be read, and [`GateError::Debt`]
101/// when the debt file cannot be trusted.
102pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
103    let Some(files) = spec_files_judged(ctx) else {
104        return Ok(vec![Violation::Layout(
105            "no specs matched; the layout moved".to_string(),
106        )]);
107    };
108    let debt = budget::read_debt(ctx)?;
109    let mut violations = Vec::new();
110    let mut measurements = Vec::new();
111    for file in files {
112        let counted = count(ctx, &file)?;
113        if counted.markers != 0 && counted.markers != 2 {
114            violations.push(Violation::Finding(Finding::on_file(
115                RULE,
116                &file,
117                format!("{} TOC markers, expected 0 or 2", counted.markers),
118            )));
119            continue;
120        }
121        measurements.extend(measurements_of(&counted));
122    }
123    violations.extend(budget::judge(
124        &debt,
125        GateId::SpecSizeCap,
126        RULE,
127        &measurements,
128        |m| {
129            let detail = match m.value {
130                crate::domain::debt::Measured::Count { value, .. } => {
131                    format!("{value} authored lines, cap is {CAP}")
132                }
133                crate::domain::debt::Measured::Flag(_) => {
134                    format!("over {TOC_FROM} lines with no TOC")
135                }
136            };
137            Violation::Finding(Finding::on_file(RULE, m.path.as_str(), detail))
138        },
139    ));
140    Ok(violations)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn fixture(text: &str) -> tempfile::TempDir {
148        let dir = tempfile::tempdir().unwrap();
149        let specs = dir.path().join("_docs/specs");
150        std::fs::create_dir_all(&specs).unwrap();
151        std::fs::write(specs.join("SPEC-sample.md"), text).unwrap();
152        dir
153    }
154
155    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
156        let ctx = GateCtx::new(dir.path().to_str().unwrap());
157        run(&ctx, &[])
158            .unwrap()
159            .iter()
160            .map(ToString::to_string)
161            .collect()
162    }
163
164    fn run_on(text: &str) -> Vec<String> {
165        run_in(&fixture(text))
166    }
167
168    /// A spec of `lines` authored lines carrying an empty, well-formed TOC.
169    fn with_toc(lines: usize) -> String {
170        format!("<!--TOC-->\n<!--TOC-->\n{}", "line\n".repeat(lines))
171    }
172
173    #[test]
174    fn accepts_a_short_spec_with_no_toc() {
175        assert!(run_on(&"line\n".repeat(100)).is_empty());
176    }
177
178    #[test]
179    fn rejects_a_spec_over_the_authored_cap() {
180        let out = run_on(&with_toc(301));
181        assert_eq!(out.len(), 1);
182        assert!(out[0].contains("docs-specs:spec-stays-within-300-lines"));
183        assert!(out[0].ends_with(": 301 authored lines, cap is 300"));
184    }
185
186    #[test]
187    fn the_toc_region_does_not_spend_the_budget() {
188        let text = format!(
189            "<!--TOC-->\n{}<!--TOC-->\n{}",
190            "toc\n".repeat(250),
191            "line\n".repeat(90)
192        );
193        assert!(run_on(&text).is_empty());
194    }
195
196    #[test]
197    fn a_long_spec_without_a_toc_is_rejected() {
198        let out = run_on(&"line\n".repeat(101));
199        assert_eq!(out.len(), 1);
200        assert!(out[0].ends_with(": over 100 lines with no TOC"));
201    }
202
203    #[test]
204    fn both_dimensions_are_reported_independently() {
205        let out = run_on(&"line\n".repeat(301));
206        assert_eq!(out.len(), 2, "{out:?}");
207        assert!(out[0].ends_with(": 301 authored lines, cap is 300"));
208        assert!(out[1].ends_with(": over 100 lines with no TOC"));
209    }
210
211    #[test]
212    fn a_lone_marker_is_rejected_before_counting() {
213        let text = format!("<!--TOC-->\n{}", "line\n".repeat(400));
214        let out = run_on(&text);
215        assert_eq!(out.len(), 1);
216        assert!(out[0].ends_with(": 1 TOC markers, expected 0 or 2"));
217    }
218
219    #[test]
220    fn a_malformed_marker_stays_strict_under_debt() {
221        let dir = fixture(&format!("<!--TOC-->\n{}", "line\n".repeat(400)));
222        std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
223        std::fs::write(
224            dir.path().join(".spec-driven-docs/debt.yaml"),
225            "schema_version: 1\nspec-size-cap:\n  _docs/specs/SPEC-sample.md:\n    authored_lines:\n      ceiling: 400\n    missing_toc: true\n",
226        )
227        .unwrap();
228        let out = run_in(&dir);
229        assert!(
230            out.iter().any(|line| line.contains("1 TOC markers")),
231            "{out:?}"
232        );
233    }
234
235    #[test]
236    fn a_recorded_ceiling_and_exception_carry_an_oversize_spec_without_a_toc() {
237        let dir = fixture(&"line\n".repeat(417));
238        std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
239        std::fs::write(
240            dir.path().join(".spec-driven-docs/debt.yaml"),
241            "schema_version: 1\nspec-size-cap:\n  _docs/specs/SPEC-sample.md:\n    authored_lines:\n      ceiling: 417\n    missing_toc: true\n",
242        )
243        .unwrap();
244        assert!(run_in(&dir).is_empty());
245    }
246
247    #[test]
248    fn measure_reports_both_dimensions_of_an_oversize_spec_without_a_toc() {
249        let dir = fixture(&"line\n".repeat(417));
250        let ctx = GateCtx::new(dir.path().to_str().unwrap());
251        let measured = measure(&ctx).unwrap();
252        assert_eq!(measured.len(), 2);
253        assert!(measured.iter().all(Measurement::violates));
254    }
255}