spec_driven_docs/gates/
chapter_size_cap.rs1use 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
16pub 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#[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
48pub 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}