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//!
3//! The generated TOC is excluded from the count: it grows with the
4//! requirement list and would otherwise spend the author's budget on
5//! navigation. Excluding it means trusting its delimiters, so the pair is
6//! checked first — a spec carrying one marker and nothing to close it would
7//! have every line after that marker deleted from the count, which is the
8//! over-budget file the cap exists to reject.
9
10use crate::domain::finding::Finding;
11use crate::domain::rule_id::RuleId;
12use crate::gates::spec_rule_id_unique::spec_files;
13use crate::gates::{GateCtx, GateResult, Violation, read_text};
14
15/// The rules this gate can cite.
16pub const CITES: &[RuleId] = &[RuleId::SpecStaysWithinLineCap];
17
18const RULE: RuleId = RuleId::SpecStaysWithinLineCap;
19const MARKER: &str = "<!--TOC-->";
20
21/// Judge every spec under the documentation root.
22///
23/// # Errors
24///
25/// [`crate::gates::GateError::Io`] when a spec cannot be read.
26pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
27    let Some(files) = spec_files(ctx) else {
28        return Ok(vec![Violation::Layout(
29            "no specs matched; the layout moved".to_string(),
30        )]);
31    };
32    let mut violations = Vec::new();
33    for file in files {
34        let text = read_text(ctx, &file)?;
35        let markers = text.lines().filter(|line| *line == MARKER).count();
36        if markers != 0 && markers != 2 {
37            violations.push(Violation::Finding(Finding::on_file(
38                RULE,
39                &file,
40                format!("{markers} TOC markers, expected 0 or 2"),
41            )));
42            continue;
43        }
44        let mut inside_toc = false;
45        let authored = text
46            .lines()
47            .filter(|line| {
48                if *line == MARKER {
49                    inside_toc = !inside_toc;
50                    return false;
51                }
52                !inside_toc
53            })
54            .count();
55        if authored > 300 {
56            violations.push(Violation::Finding(Finding::on_file(
57                RULE,
58                &file,
59                format!("{authored} authored lines, cap is 300"),
60            )));
61        } else if authored > 100 && markers == 0 {
62            violations.push(Violation::Finding(Finding::on_file(
63                RULE,
64                &file,
65                "over 100 lines with no TOC",
66            )));
67        }
68    }
69    Ok(violations)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    fn run_on(text: &str) -> Vec<String> {
77        let dir = tempfile::tempdir().unwrap();
78        let specs = dir.path().join("_docs/specs");
79        std::fs::create_dir_all(&specs).unwrap();
80        std::fs::write(specs.join("SPEC-sample.md"), text).unwrap();
81        let ctx = GateCtx::new(dir.path().to_str().unwrap());
82        run(&ctx, &[])
83            .unwrap()
84            .iter()
85            .map(ToString::to_string)
86            .collect()
87    }
88
89    #[test]
90    fn accepts_a_short_spec_with_no_toc() {
91        assert!(run_on(&"line\n".repeat(100)).is_empty());
92    }
93
94    #[test]
95    fn rejects_a_spec_over_the_authored_cap() {
96        let out = run_on(&"line\n".repeat(301));
97        assert_eq!(out.len(), 1);
98        assert!(out[0].contains("docs-specs:spec-stays-within-300-lines"));
99        assert!(out[0].ends_with(": 301 authored lines, cap is 300"));
100    }
101
102    #[test]
103    fn the_toc_region_does_not_spend_the_budget() {
104        let text = format!(
105            "<!--TOC-->\n{}<!--TOC-->\n{}",
106            "toc\n".repeat(250),
107            "line\n".repeat(90)
108        );
109        assert!(run_on(&text).is_empty());
110    }
111
112    #[test]
113    fn a_long_spec_without_a_toc_is_rejected() {
114        let out = run_on(&"line\n".repeat(101));
115        assert_eq!(out.len(), 1);
116        assert!(out[0].ends_with(": over 100 lines with no TOC"));
117    }
118
119    #[test]
120    fn a_lone_marker_is_rejected_before_counting() {
121        let text = format!("<!--TOC-->\n{}", "line\n".repeat(400));
122        let out = run_on(&text);
123        assert_eq!(out.len(), 1);
124        assert!(out[0].ends_with(": 1 TOC markers, expected 0 or 2"));
125    }
126}