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 records: Vec<String> = ctx
51 .retained(records.iter().map(|name| decisions.join(name)))
52 .iter()
53 .filter_map(|path| path.file_name().map(ToString::to_string))
54 .collect();
55
56 let mut violations = Vec::new();
57 for name in records {
58 let path = decisions.join(name);
59 let words = read_text(ctx, &path)?.split_whitespace().count();
60 if words > CAP {
61 violations.push(Violation::Finding(Finding::on_file(
62 RuleId::BodyStaysWithinWordCap,
63 path,
64 words.to_string(),
65 )));
66 }
67 }
68 Ok(violations)
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 fn fixture(words: usize) -> tempfile::TempDir {
76 let dir = tempfile::tempdir().unwrap();
77 let decisions = dir.path().join("_docs/decisions");
78 std::fs::create_dir_all(&decisions).unwrap();
79 std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
80 dir
81 }
82
83 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
84 let ctx = GateCtx::new(dir.path().to_str().unwrap());
85 run(&ctx, &[])
86 .unwrap()
87 .iter()
88 .map(ToString::to_string)
89 .collect()
90 }
91
92 #[test]
93 fn accepts_a_record_at_the_cap() {
94 assert!(run_in(&fixture(350)).is_empty());
95 }
96
97 #[test]
98 fn rejects_a_record_over_the_cap() {
99 let out = run_in(&fixture(351));
100 assert_eq!(out.len(), 1);
101 assert!(out[0].contains("decision-records:body-stays-within-350-words"));
102 assert!(out[0].ends_with(": 351"));
103 }
104
105 #[test]
106 fn an_empty_record_set_is_a_layout_failure() {
107 let dir = tempfile::tempdir().unwrap();
108 let out = run_in(&dir);
109 assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
110 }
111}