spec_driven_docs/gates/
adr_word_cap.rs1use camino::Utf8PathBuf;
10
11use crate::domain::debt::Measurement;
12use crate::domain::finding::Finding;
13use crate::domain::gate_id::GateId;
14use crate::domain::rule_id::RuleId;
15use crate::gates::budget;
16use crate::gates::paths::docs_root;
17use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
18
19pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
21
22const RULE: RuleId = RuleId::BodyStaysWithinWordCap;
23const CAP: usize = 350;
24
25fn records(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
28 let decisions = docs_root(ctx).join("decisions");
29 let mut names: Vec<String> = ctx
30 .path(&decisions)
31 .read_dir_utf8()
32 .map(|entries| {
33 entries
34 .filter_map(Result::ok)
35 .map(|entry| entry.file_name().to_string())
36 .filter(|name| {
37 name.strip_prefix("ADR-")
38 .and_then(|rest| rest.strip_suffix(".md"))
39 .is_some_and(|slug| !slug.is_empty())
40 })
41 .collect()
42 })
43 .unwrap_or_default();
44 names.sort();
45 names.into_iter().map(|name| decisions.join(name)).collect()
46}
47
48pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
54 let mut measurements = Vec::new();
55 for path in ctx.retained(records(ctx)) {
58 let words = read_text(ctx, &path)?.split_whitespace().count();
59 measurements.push(Measurement::count(
60 GateId::AdrWordCap,
61 path.as_str(),
62 "words",
63 words,
64 CAP,
65 ));
66 }
67 Ok(measurements)
68}
69
70pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
77 if records(ctx).is_empty() {
80 return Ok(vec![Violation::Layout(
81 "no decision records matched".to_string(),
82 )]);
83 }
84 let debt = budget::read_debt(ctx)?;
85 let measurements = measure(ctx)?;
86 Ok(budget::judge(
87 &debt,
88 GateId::AdrWordCap,
89 RULE,
90 &measurements,
91 |m| {
92 let words = match m.value {
93 crate::domain::debt::Measured::Count { value, .. } => value,
94 crate::domain::debt::Measured::Flag(_) => 0,
95 };
96 Violation::Finding(Finding::on_file(RULE, m.path.as_str(), words.to_string()))
97 },
98 ))
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 fn fixture(words: usize) -> tempfile::TempDir {
106 let dir = tempfile::tempdir().unwrap();
107 let decisions = dir.path().join("_docs/decisions");
108 std::fs::create_dir_all(&decisions).unwrap();
109 std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
110 dir
111 }
112
113 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
114 let ctx = GateCtx::new(dir.path().to_str().unwrap());
115 run(&ctx, &[])
116 .unwrap()
117 .iter()
118 .map(ToString::to_string)
119 .collect()
120 }
121
122 #[test]
123 fn accepts_a_record_at_the_cap() {
124 assert!(run_in(&fixture(350)).is_empty());
125 }
126
127 #[test]
128 fn rejects_a_record_over_the_cap() {
129 let out = run_in(&fixture(351));
130 assert_eq!(out.len(), 1);
131 assert!(out[0].contains("decision-records:body-stays-within-350-words"));
132 assert!(out[0].ends_with(": 351"));
133 }
134
135 #[test]
136 fn an_empty_record_set_is_a_layout_failure() {
137 let dir = tempfile::tempdir().unwrap();
138 let out = run_in(&dir);
139 assert_eq!(out, vec!["FAIL no decision records matched".to_string()]);
140 }
141
142 #[test]
143 fn a_recorded_ceiling_carries_an_oversize_record() {
144 let dir = fixture(612);
145 std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
146 std::fs::write(
147 dir.path().join(".spec-driven-docs/debt.yaml"),
148 "schema_version: 1\nadr-word-cap:\n _docs/decisions/ADR-choice.md:\n words:\n ceiling: 612\n",
149 )
150 .unwrap();
151 assert!(run_in(&dir).is_empty());
152 std::fs::write(
153 dir.path().join("_docs/decisions/ADR-choice.md"),
154 "word ".repeat(613),
155 )
156 .unwrap();
157 let out = run_in(&dir);
158 assert_eq!(out.len(), 1);
159 assert!(
160 out[0].ends_with(": 613 words, recorded ceiling is 612"),
161 "{}",
162 out[0]
163 );
164 }
165}