spec_driven_docs/gates/
adr_word_cap.rs1use crate::domain::finding::Finding;
9use crate::domain::rule_id::RuleId;
10use crate::gates::paths::docs_root;
11use crate::gates::{GateCtx, GateResult, Violation, read_text};
12
13pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
15
16const CAP: usize = 350;
17
18pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
24 let decisions = docs_root(ctx).join("decisions");
25 let mut records: Vec<String> = ctx
26 .path(&decisions)
27 .read_dir_utf8()
28 .map(|entries| {
29 entries
30 .filter_map(Result::ok)
31 .map(|entry| entry.file_name().to_string())
32 .filter(|name| {
33 name.strip_prefix("ADR-")
34 .and_then(|rest| rest.strip_suffix(".md"))
35 .is_some_and(|slug| !slug.is_empty())
36 })
37 .collect()
38 })
39 .unwrap_or_default();
40 records.sort();
41 if records.is_empty() {
42 return Ok(vec![Violation::Layout(
43 "no decision records matched".to_string(),
44 )]);
45 }
46
47 let mut violations = Vec::new();
48 for name in records {
49 let path = decisions.join(name);
50 let words = read_text(ctx, &path)?.split_whitespace().count();
51 if words > CAP {
52 violations.push(Violation::Finding(Finding::on_file(
53 RuleId::BodyStaysWithinWordCap,
54 path,
55 words.to_string(),
56 )));
57 }
58 }
59 Ok(violations)
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65
66 fn fixture(words: usize) -> tempfile::TempDir {
67 let dir = tempfile::tempdir().unwrap();
68 let decisions = dir.path().join("_docs/decisions");
69 std::fs::create_dir_all(&decisions).unwrap();
70 std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
71 dir
72 }
73
74 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
75 let ctx = GateCtx::new(dir.path().to_str().unwrap());
76 run(&ctx, &[])
77 .unwrap()
78 .iter()
79 .map(ToString::to_string)
80 .collect()
81 }
82
83 #[test]
84 fn accepts_a_record_at_the_cap() {
85 assert!(run_in(&fixture(350)).is_empty());
86 }
87
88 #[test]
89 fn rejects_a_record_over_the_cap() {
90 let out = run_in(&fixture(351));
91 assert_eq!(out.len(), 1);
92 assert!(out[0].contains("decision-records:body-stays-within-350-words"));
93 assert!(out[0].ends_with(": 351"));
94 }
95
96 #[test]
97 fn an_empty_record_set_is_a_layout_failure() {
98 let dir = tempfile::tempdir().unwrap();
99 let out = run_in(&dir);
100 assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
101 }
102}