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";
21
22fn cap_for(file: &str) -> usize {
23    if file.ends_with("-gates.md")
24        || file.ends_with("-checklist.md")
25        || file.ends_with("glossary.md")
26        || file.ends_with("README.md")
27    {
28        300
29    } else {
30        200
31    }
32}
33
34// The shell glob this matches was case-sensitive; `.MD` is not a chapter.
35#[allow(clippy::case_sensitive_file_extension_comparisons)]
36fn is_chapter(name: &str) -> bool {
37    if name == "glossary.md" || name == "README.md" {
38        return true;
39    }
40    let bytes = name.as_bytes();
41    name.ends_with(".md")
42        && bytes.len() > 3
43        && bytes[0].is_ascii_digit()
44        && bytes[1].is_ascii_digit()
45        && bytes[2] == b'-'
46}
47
48/// Judge every chapter and catalog, honoring the debt list.
49///
50/// # Errors
51///
52/// [`crate::gates::GateError::Io`] when a matched file cannot be read.
53pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
54    let mut violations = Vec::new();
55    let mut debt_entries: Vec<String> = Vec::new();
56
57    if ctx.path(DEBT).is_file() {
58        for entry in read_text(ctx, DEBT)?.lines() {
59            if entry.is_empty() || entry.starts_with('#') {
60                continue;
61            }
62            let file = if entry.starts_with("./") {
63                entry.to_string()
64            } else {
65                format!("./{entry}")
66            };
67            if !ctx.path(&file).is_file() {
68                violations.push(Violation::Finding(Finding::on_file(
69                    RULE,
70                    format!("delist {file}"),
71                    "deleted",
72                )));
73                continue;
74            }
75            if line_count(&read_text(ctx, Utf8Path::new(&file))?) <= cap_for(&file) {
76                violations.push(Violation::Finding(Finding::on_file(
77                    RULE,
78                    format!("delist {file}"),
79                    "now fits",
80                )));
81            }
82            debt_entries.push(file);
83        }
84    }
85
86    for file in walk_files(ctx) {
87        let Some(name) = file.file_name() else {
88            continue;
89        };
90        if !is_chapter(name) {
91            continue;
92        }
93        let as_listed = file.as_str();
94        let bare = as_listed.trim_start_matches("./");
95        if debt_entries
96            .iter()
97            .any(|entry| entry == as_listed || entry.trim_start_matches("./") == bare)
98        {
99            continue;
100        }
101        if line_count(&read_text(ctx, &file)?) > cap_for(as_listed) {
102            violations.push(Violation::Finding(Finding::on_file(RULE, file, "")));
103        }
104    }
105    Ok(violations)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn write(dir: &tempfile::TempDir, path: &str, content: &str) {
113        let path = dir.path().join(path);
114        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
115        std::fs::write(path, content).unwrap();
116    }
117
118    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
119        let ctx = GateCtx::new(dir.path().to_str().unwrap());
120        run(&ctx, &[])
121            .unwrap()
122            .iter()
123            .map(ToString::to_string)
124            .collect()
125    }
126
127    #[test]
128    fn accepts_chapters_within_cap_and_ignores_vendored_trees() {
129        let dir = tempfile::tempdir().unwrap();
130        write(&dir, "00-chapter.md", "# Chapter\n");
131        write(&dir, "node_modules/pkg/README.md", &"line\n".repeat(400));
132        assert!(run_in(&dir).is_empty());
133    }
134
135    #[test]
136    fn rejects_a_chapter_over_cap() {
137        let dir = tempfile::tempdir().unwrap();
138        write(&dir, "00-chapter.md", &"line\n".repeat(201));
139        assert_eq!(
140            run_in(&dir),
141            vec!["FAIL docs-format:chapter-stays-within-200-lines ./00-chapter.md".to_string()]
142        );
143    }
144
145    #[test]
146    fn catalogs_get_the_larger_cap() {
147        let dir = tempfile::tempdir().unwrap();
148        write(&dir, "README.md", &"line\n".repeat(300));
149        write(&dir, "08-gates.md", &"line\n".repeat(300));
150        assert!(run_in(&dir).is_empty());
151    }
152
153    #[test]
154    fn debt_exempts_an_oversize_chapter() {
155        let dir = tempfile::tempdir().unwrap();
156        write(&dir, "00-chapter.md", &"line\n".repeat(201));
157        write(
158            &dir,
159            ".spec-driven-docs/chapter-size-debt.txt",
160            "00-chapter.md\n",
161        );
162        assert!(run_in(&dir).is_empty());
163    }
164
165    #[test]
166    fn debt_expires_when_the_chapter_fits_even_unterminated() {
167        let dir = tempfile::tempdir().unwrap();
168        write(&dir, "00-chapter.md", "# fits\n");
169        write(
170            &dir,
171            ".spec-driven-docs/chapter-size-debt.txt",
172            "00-chapter.md\n",
173        );
174        assert!(run_in(&dir)[0].contains("delist ./00-chapter.md: now fits"));
175
176        write(
177            &dir,
178            ".spec-driven-docs/chapter-size-debt.txt",
179            "00-chapter.md",
180        );
181        assert!(run_in(&dir)[0].contains("now fits"));
182    }
183
184    #[test]
185    fn debt_expires_when_the_chapter_is_deleted_even_unterminated() {
186        let dir = tempfile::tempdir().unwrap();
187        write(
188            &dir,
189            ".spec-driven-docs/chapter-size-debt.txt",
190            "missing.md\n",
191        );
192        assert!(run_in(&dir)[0].contains("delist ./missing.md: deleted"));
193
194        write(
195            &dir,
196            ".spec-driven-docs/chapter-size-debt.txt",
197            "missing.md",
198        );
199        assert!(run_in(&dir)[0].contains("deleted"));
200    }
201}