spec_driven_docs/gates/
adr_word_cap.rs1use camino::Utf8PathBuf;
15
16use crate::domain::debt::Measurement;
17use crate::domain::finding::Finding;
18use crate::domain::gate_id::GateId;
19use crate::domain::rule_id::RuleId;
20use crate::gates::budget;
21use crate::gates::paths::docs_root;
22use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
23
24pub const CITES: &[RuleId] = &[RuleId::BodyStaysWithinWordCap];
26
27const RULE: RuleId = RuleId::BodyStaysWithinWordCap;
28const CAP: usize = 350;
29
30fn records(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
33 let decisions = docs_root(ctx).join("decisions");
34 let mut names: Vec<String> = ctx
35 .path(&decisions)
36 .read_dir_utf8()
37 .map(|entries| {
38 entries
39 .filter_map(Result::ok)
40 .map(|entry| entry.file_name().to_string())
41 .filter(|name| {
42 name.strip_prefix("ADR-")
43 .and_then(|rest| rest.strip_suffix(".md"))
44 .is_some_and(|slug| !slug.is_empty())
45 })
46 .collect()
47 })
48 .unwrap_or_default();
49 names.sort();
50 names.into_iter().map(|name| decisions.join(name)).collect()
51}
52
53fn body_of(text: &str) -> &str {
55 text.find("\n## Status").map_or(text, |at| &text[..at])
56}
57
58pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
64 let mut measurements = Vec::new();
65 for path in ctx.retained(records(ctx)) {
68 let words = body_of(&read_text(ctx, &path)?).split_whitespace().count();
69 measurements.push(Measurement::count(
70 GateId::AdrWordCap,
71 path.as_str(),
72 "words",
73 words,
74 CAP,
75 ));
76 }
77 Ok(measurements)
78}
79
80pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
87 if records(ctx).is_empty() {
91 return Ok(Vec::new());
92 }
93 let debt = budget::read_debt(ctx)?;
94 let measurements = measure(ctx)?;
95 Ok(budget::judge(
96 &debt,
97 GateId::AdrWordCap,
98 RULE,
99 &measurements,
100 |m| {
101 let words = match m.value {
102 crate::domain::debt::Measured::Count { value, .. } => value,
103 crate::domain::debt::Measured::Flag(_) => 0,
104 };
105 Violation::Finding(Finding::on_file(RULE, m.path.as_str(), words.to_string()))
106 },
107 ))
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn fixture(words: usize) -> tempfile::TempDir {
115 let dir = tempfile::tempdir().unwrap();
116 let decisions = dir.path().join("_docs/decisions");
117 std::fs::create_dir_all(&decisions).unwrap();
118 std::fs::write(decisions.join("ADR-choice.md"), "word ".repeat(words)).unwrap();
119 dir
120 }
121
122 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
123 let ctx = GateCtx::new(dir.path().to_str().unwrap());
124 run(&ctx, &[])
125 .unwrap()
126 .iter()
127 .map(ToString::to_string)
128 .collect()
129 }
130
131 #[test]
132 fn the_status_section_is_not_the_body() {
133 let text = "# A\n\nOne two three.\n\n## Status\n\nSuperseded by [B](./B.md)\n";
134 assert_eq!(body_of(text).split_whitespace().count(), 5);
135 }
136
137 #[test]
138 fn accepts_a_record_at_the_cap() {
139 assert!(run_in(&fixture(350)).is_empty());
140 }
141
142 #[test]
143 fn rejects_a_record_over_the_cap() {
144 let out = run_in(&fixture(351));
145 assert_eq!(out.len(), 1);
146 assert!(out[0].contains("decision-records:body-stays-within-350-words"));
147 assert!(out[0].ends_with(": 351"));
148 }
149
150 #[test]
151 fn an_empty_record_set_has_nothing_to_judge() {
152 let dir = tempfile::tempdir().unwrap();
153 let out = run_in(&dir);
154 assert!(out.is_empty(), "{out:?}");
155 }
156
157 #[test]
158 fn a_recorded_ceiling_carries_an_oversize_record() {
159 let dir = fixture(612);
160 std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
161 std::fs::write(
162 dir.path().join(".spec-driven-docs/debt.yaml"),
163 "schema_version: 1\nadr-word-cap:\n _docs/decisions/ADR-choice.md:\n words:\n ceiling: 612\n",
164 )
165 .unwrap();
166 assert!(run_in(&dir).is_empty());
167 std::fs::write(
168 dir.path().join("_docs/decisions/ADR-choice.md"),
169 "word ".repeat(613),
170 )
171 .unwrap();
172 let out = run_in(&dir);
173 assert_eq!(out.len(), 1);
174 assert!(
175 out[0].ends_with(": 613 words, recorded ceiling is 612"),
176 "{}",
177 out[0]
178 );
179 }
180}