Skip to main content

spec_driven_docs/gates/
chapter_size_cap.rs

1//! Gate: a chapter stays within its line cap, and the debt list shrinks by
2//! itself.
3//!
4//! Chapters get 200 lines; catalogs — gates, checklists, glossaries, READMEs
5//! — get 300. A debt entry exempts one oversize file, and expires the moment
6//! the file fits or disappears, so the list can only shrink. Vendored trees
7//! are pruned. Which caps exist is the format spec's business; this gate
8//! only counts.
9
10use camino::Utf8Path;
11
12use crate::domain::finding::Finding;
13use crate::domain::rule_id::RuleId;
14use crate::gates::{GateCtx, GateResult, Violation, line_count, read_text, walk_files};
15
16/// The rules this gate can cite.
17pub const CITES: &[RuleId] = &[RuleId::ChapterStaysWithinLineCap];
18
19const RULE: RuleId = RuleId::ChapterStaysWithinLineCap;
20const DEBT: &str = ".spec-driven-docs/chapter-size-debt.txt";
21const CHAPTER_ZONES: &[&str] = &["./method", "./comparison-docs"];
22
23fn cap_for(file: &Utf8Path) -> usize {
24    if file.file_name().is_some_and(|name| {
25        name.ends_with("-gates.md")
26            || name.ends_with("-checklist.md")
27            || matches!(
28                name,
29                "gates.md" | "checklist.md" | "glossary.md" | "README.md" | "SOURCES.md"
30            )
31    }) {
32        300
33    } else {
34        200
35    }
36}
37
38fn is_chapter(file: &Utf8Path) -> bool {
39    let Some(name) = file.file_name() else {
40        return false;
41    };
42    if name == "AGENTS.md" {
43        return false;
44    }
45    if name == "glossary.md" || name == "README.md" {
46        return true;
47    }
48    file.extension() == Some("md")
49        && file
50            .parent()
51            .is_some_and(|parent| CHAPTER_ZONES.contains(&parent.as_str()))
52}
53
54/// Judge every chapter and catalog, honoring the debt list.
55///
56/// # Errors
57///
58/// [`crate::gates::GateError::Io`] when a matched file cannot be read.
59pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
60    let mut violations = Vec::new();
61    let mut debt_entries: Vec<String> = Vec::new();
62
63    if ctx.path(DEBT).is_file() {
64        for entry in read_text(ctx, DEBT)?.lines() {
65            if entry.is_empty() || entry.starts_with('#') {
66                continue;
67            }
68            let file = if entry.starts_with("./") {
69                entry.to_string()
70            } else {
71                format!("./{entry}")
72            };
73            if !ctx.path(&file).is_file() {
74                violations.push(Violation::Finding(Finding::on_file(
75                    RULE,
76                    format!("delist {file}"),
77                    "deleted",
78                )));
79                continue;
80            }
81            if line_count(&read_text(ctx, Utf8Path::new(&file))?) <= cap_for(Utf8Path::new(&file)) {
82                violations.push(Violation::Finding(Finding::on_file(
83                    RULE,
84                    format!("delist {file}"),
85                    "now fits",
86                )));
87            }
88            debt_entries.push(file);
89        }
90    }
91
92    for file in walk_files(ctx) {
93        if !is_chapter(&file) {
94            continue;
95        }
96        let as_listed = file.as_str();
97        let bare = as_listed.trim_start_matches("./");
98        if debt_entries
99            .iter()
100            .any(|entry| entry == as_listed || entry.trim_start_matches("./") == bare)
101        {
102            continue;
103        }
104        if line_count(&read_text(ctx, &file)?) > cap_for(&file) {
105            violations.push(Violation::Finding(Finding::on_file(RULE, file, "")));
106        }
107    }
108    Ok(violations)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn write(dir: &tempfile::TempDir, path: &str, content: &str) {
116        let path = dir.path().join(path);
117        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
118        std::fs::write(path, content).unwrap();
119    }
120
121    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
122        let ctx = GateCtx::new(dir.path().to_str().unwrap());
123        run(&ctx, &[])
124            .unwrap()
125            .iter()
126            .map(ToString::to_string)
127            .collect()
128    }
129
130    #[test]
131    fn accepts_chapters_within_cap_and_ignores_vendored_trees() {
132        let dir = tempfile::tempdir().unwrap();
133        write(&dir, "method/chapter.md", "# Chapter\n");
134        write(&dir, "node_modules/pkg/README.md", &"line\n".repeat(400));
135        assert!(run_in(&dir).is_empty());
136    }
137
138    #[test]
139    fn rejects_a_chapter_over_cap() {
140        let dir = tempfile::tempdir().unwrap();
141        write(&dir, "method/chapter.md", &"line\n".repeat(201));
142        assert_eq!(
143            run_in(&dir),
144            vec!["FAIL docs-format:chapter-stays-within-200-lines ./method/chapter.md".to_string()]
145        );
146    }
147
148    #[test]
149    fn catalogs_get_the_larger_cap() {
150        let dir = tempfile::tempdir().unwrap();
151        write(&dir, "README.md", &"line\n".repeat(300));
152        write(&dir, "instance/README.md", &"line\n".repeat(300));
153        write(&dir, "method/README.md", &"line\n".repeat(300));
154        write(&dir, "method/gates.md", &"line\n".repeat(300));
155        write(&dir, "method/checklist.md", &"line\n".repeat(300));
156        write(&dir, "method/glossary.md", &"line\n".repeat(300));
157        write(&dir, "method/08-gates.md", &"line\n".repeat(300));
158        write(&dir, "method/08-checklist.md", &"line\n".repeat(300));
159        write(&dir, "comparison-docs/SOURCES.md", &"line\n".repeat(300));
160        assert!(run_in(&dir).is_empty());
161
162        for path in [
163            "README.md",
164            "comparison-docs/SOURCES.md",
165            "instance/README.md",
166            "method/08-checklist.md",
167            "method/08-gates.md",
168            "method/README.md",
169            "method/checklist.md",
170            "method/gates.md",
171            "method/glossary.md",
172        ] {
173            write(&dir, path, &"line\n".repeat(301));
174        }
175        assert_eq!(run_in(&dir).len(), 9);
176    }
177
178    #[test]
179    fn debt_exempts_an_oversize_chapter() {
180        let dir = tempfile::tempdir().unwrap();
181        write(&dir, "method/debt-chapter.md", &"line\n".repeat(201));
182        write(
183            &dir,
184            ".spec-driven-docs/chapter-size-debt.txt",
185            "method/debt-chapter.md\n",
186        );
187        assert!(run_in(&dir).is_empty());
188    }
189
190    #[test]
191    fn debt_expires_when_the_chapter_fits_even_unterminated() {
192        let dir = tempfile::tempdir().unwrap();
193        write(&dir, "method/debt-chapter.md", "# fits\n");
194        write(
195            &dir,
196            ".spec-driven-docs/chapter-size-debt.txt",
197            "method/debt-chapter.md\n",
198        );
199        assert!(run_in(&dir)[0].contains("delist ./method/debt-chapter.md: now fits"));
200
201        write(
202            &dir,
203            ".spec-driven-docs/chapter-size-debt.txt",
204            "method/debt-chapter.md",
205        );
206        assert!(run_in(&dir)[0].contains("now fits"));
207    }
208
209    #[test]
210    fn debt_expires_when_the_chapter_is_deleted_even_unterminated() {
211        let dir = tempfile::tempdir().unwrap();
212        write(
213            &dir,
214            ".spec-driven-docs/chapter-size-debt.txt",
215            "method/missing-chapter.md\n",
216        );
217        assert!(run_in(&dir)[0].contains("delist ./method/missing-chapter.md: deleted"));
218
219        write(
220            &dir,
221            ".spec-driven-docs/chapter-size-debt.txt",
222            "method/missing-chapter.md",
223        );
224        assert!(run_in(&dir)[0].contains("deleted"));
225    }
226
227    #[test]
228    fn rejects_a_slug_named_chapter_in_a_zone() {
229        let dir = tempfile::tempdir().unwrap();
230        write(
231            &dir,
232            "comparison-docs/slug-chapter.md",
233            &"line\n".repeat(201),
234        );
235        assert_eq!(
236            run_in(&dir),
237            vec![
238                "FAIL docs-format:chapter-stays-within-200-lines ./comparison-docs/slug-chapter.md"
239                    .to_string()
240            ]
241        );
242    }
243
244    #[test]
245    fn ignores_a_slug_named_markdown_file_outside_every_zone() {
246        let dir = tempfile::tempdir().unwrap();
247        write(&dir, "reference/slug-document.md", &"line\n".repeat(201));
248        assert!(run_in(&dir).is_empty());
249    }
250
251    #[test]
252    fn ignores_a_markdown_file_nested_below_a_chapter_zone() {
253        let dir = tempfile::tempdir().unwrap();
254        write(&dir, "method/nested/slug-chapter.md", &"line\n".repeat(201));
255        assert!(run_in(&dir).is_empty());
256    }
257
258    #[test]
259    fn judges_a_glossary_outside_every_zone() {
260        let dir = tempfile::tempdir().unwrap();
261        write(&dir, "reference/glossary.md", &"line\n".repeat(301));
262        assert_eq!(
263            run_in(&dir),
264            vec![
265                "FAIL docs-format:chapter-stays-within-200-lines ./reference/glossary.md"
266                    .to_string()
267            ]
268        );
269    }
270
271    #[test]
272    fn ignores_agents_md_in_a_chapter_zone() {
273        let dir = tempfile::tempdir().unwrap();
274        write(&dir, "method/AGENTS.md", &"line\n".repeat(301));
275        write(&dir, "comparison-docs/AGENTS.md", &"line\n".repeat(301));
276        assert!(run_in(&dir).is_empty());
277    }
278}