Skip to main content

spec_driven_docs/gates/
budget.rs

1//! What every budget gate does once it has measured: read the debt, judge
2//! each measurement against its ceiling or its budget, and report the
3//! entries the measurements no longer support.
4//!
5//! The four budget gates measure different things and print different
6//! findings for a fresh violation, so each keeps its own message for that
7//! case and hands it in as a closure. The debt comparison is one
8//! implementation here, so no gate can honour a ceiling differently from
9//! another. A gate never writes: a stale entry is a failure naming
10//! `sdd debt tighten --apply`, because a stale ceiling that only warned would
11//! permit regrowth up to it.
12
13use 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
21/// The debt the instance at this context's root carries.
22///
23/// # Errors
24///
25/// [`GateError::Debt`] when the file cannot be trusted or both formats are
26/// present. A gate that cannot read its debt cannot run: a silent empty
27/// debt would turn a red commit green.
28pub(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
39/// Judge every measurement, and every recorded entry the measurements do
40/// not support.
41///
42/// `fresh` renders the finding for a measurement that violates its budget
43/// with no entry recorded, which is the message the gate printed before
44/// debt existed and the one its tests hold.
45pub(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    // An entry the gate did not measure is a path it no longer judges:
102    // deleted, moved, or excluded. It is dead weight the ratchet removes.
103    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}