spec_driven_docs/gates/
budget.rs1use crate::domain::debt::{Debt, DebtError, Measured, Measurement, Recorded, dimensions};
14use crate::domain::finding::Finding;
15use crate::domain::gate_id::GateId;
16use crate::domain::rule_id::RuleId;
17use crate::gates::{GateCtx, GateError, Violation};
18
19const TIGHTEN: &str = "run 'sdd debt tighten --apply'";
20
21pub(crate) fn read_debt(ctx: &GateCtx) -> Result<Debt, GateError> {
29 Debt::read(&ctx.repo_root).map_err(GateError::Debt)
30}
31
32fn label(gate: GateId, dimension: &str) -> &'static str {
33 dimensions(gate)
34 .iter()
35 .find(|spec| spec.name == dimension)
36 .map_or("", |spec| spec.label)
37}
38
39pub(crate) fn judge(
46 debt: &Debt,
47 gate: GateId,
48 rule: RuleId,
49 measurements: &[Measurement],
50 fresh: impl Fn(&Measurement) -> Violation,
51) -> Vec<Violation> {
52 let mut violations = Vec::new();
53 for measurement in measurements {
54 let recorded = debt.recorded(gate, &measurement.path, measurement.dimension);
55 let noun = label(gate, measurement.dimension);
56 let stale = |reason: String| {
57 Violation::Finding(Finding::on_file(
58 rule,
59 measurement.path.as_str(),
60 format!("{reason}; {TIGHTEN}"),
61 ))
62 };
63 match (recorded, measurement.value) {
64 (None, _) => {
65 if measurement.violates() {
66 violations.push(fresh(measurement));
67 }
68 }
69 (Some(Recorded::Ceiling(ceiling)), Measured::Count { value, budget }) => {
70 if value <= budget {
71 violations.push(stale(format!(
72 "{value} {noun} is within the budget of {budget} and a ceiling of {ceiling} is recorded"
73 )));
74 } else if value > ceiling {
75 violations.push(Violation::Finding(Finding::on_file(
76 rule,
77 measurement.path.as_str(),
78 format!("{value} {noun}, recorded ceiling is {ceiling}"),
79 )));
80 } else if value < ceiling {
81 violations.push(stale(format!(
82 "{value} {noun} is below the recorded ceiling of {ceiling}"
83 )));
84 }
85 }
86 (Some(Recorded::Exception), Measured::Flag(holds)) => {
87 if !holds {
88 violations.push(stale(format!(
89 "the recorded exception for {noun} is corrected"
90 )));
91 }
92 }
93 (Some(Recorded::Ceiling(_)), Measured::Flag(_))
94 | (Some(Recorded::Exception), Measured::Count { .. }) => {
95 violations.push(stale(format!(
96 "the recorded entry for {noun} is not of the kind this gate measures"
97 )));
98 }
99 }
100 }
101 for (path, dimension, _) in debt.recorded_for(gate) {
104 let measured = measurements
105 .iter()
106 .any(|m| crate::domain::debt::normalize(&m.path) == path && m.dimension == dimension);
107 if !measured {
108 violations.push(Violation::Finding(Finding::on_file(
109 rule,
110 path.as_str(),
111 format!(
112 "a {} entry is recorded and this gate measures no such path; {TIGHTEN}",
113 label(gate, dimension)
114 ),
115 )));
116 }
117 }
118 violations
119}
120
121impl From<DebtError> for GateError {
122 fn from(error: DebtError) -> Self {
123 Self::Debt(error)
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 fn fresh(m: &Measurement) -> Violation {
132 Violation::Finding(Finding::on_file(
133 RuleId::ChapterStaysWithinLineCap,
134 m.path.as_str(),
135 "fresh",
136 ))
137 }
138
139 fn debt(ceiling: usize) -> Debt {
140 let mut debt = Debt::default();
141 debt.record(
142 GateId::ChapterSizeCap,
143 "method/a.md",
144 "lines",
145 Recorded::Ceiling(ceiling),
146 );
147 debt
148 }
149
150 fn lines(value: usize) -> Vec<Measurement> {
151 vec![Measurement::count(
152 GateId::ChapterSizeCap,
153 "./method/a.md",
154 "lines",
155 value,
156 200,
157 )]
158 }
159
160 fn judged(debt: &Debt, measurements: &[Measurement]) -> Vec<String> {
161 judge(
162 debt,
163 GateId::ChapterSizeCap,
164 RuleId::ChapterStaysWithinLineCap,
165 measurements,
166 fresh,
167 )
168 .iter()
169 .map(ToString::to_string)
170 .collect()
171 }
172
173 #[test]
174 fn a_measurement_at_its_ceiling_passes() {
175 assert!(judged(&debt(300), &lines(300)).is_empty());
176 }
177
178 #[test]
179 fn a_measurement_above_its_ceiling_fails_citing_the_budget_rule() {
180 let out = judged(&debt(300), &lines(301));
181 assert_eq!(
182 out,
183 vec![
184 "FAIL docs-format:chapter-stays-within-200-lines ./method/a.md: 301 lines, recorded ceiling is 300"
185 .to_string()
186 ]
187 );
188 }
189
190 #[test]
191 fn a_measurement_below_its_ceiling_fails_naming_tighten() {
192 let out = judged(&debt(300), &lines(250));
193 assert_eq!(out.len(), 1);
194 assert!(out[0].contains("below the recorded ceiling of 300"));
195 assert!(out[0].contains("sdd debt tighten --apply"));
196 }
197
198 #[test]
199 fn a_measurement_within_the_budget_with_an_entry_fails_naming_tighten() {
200 let out = judged(&debt(300), &lines(150));
201 assert_eq!(out.len(), 1);
202 assert!(out[0].contains("within the budget"));
203 assert!(out[0].contains("sdd debt tighten --apply"));
204 }
205
206 #[test]
207 fn a_fresh_violation_prints_the_gates_own_message() {
208 assert_eq!(
209 judged(&Debt::default(), &lines(201)),
210 vec![
211 "FAIL docs-format:chapter-stays-within-200-lines ./method/a.md: fresh".to_string()
212 ]
213 );
214 assert!(judged(&Debt::default(), &lines(200)).is_empty());
215 }
216
217 #[test]
218 fn an_entry_the_gate_did_not_measure_fails_naming_tighten() {
219 let out = judged(&debt(300), &[]);
220 assert_eq!(out.len(), 1);
221 assert!(out[0].contains("method/a.md"));
222 assert!(out[0].contains("measures no such path"));
223 }
224}