spec_driven_docs/gates/
spec_size_cap.rs1use 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
15pub const CITES: &[RuleId] = &[RuleId::SpecStaysWithinLineCap];
17
18const RULE: RuleId = RuleId::SpecStaysWithinLineCap;
19const MARKER: &str = "<!--TOC-->";
20
21pub 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}