Skip to main content

fallow_cli/regression/
outcome.rs

1use super::Tolerance;
2
3/// Result of a regression check.
4#[derive(Debug)]
5pub enum RegressionOutcome {
6    /// No regression — current issues are within tolerance.
7    Pass {
8        baseline_total: usize,
9        current_total: usize,
10    },
11    /// Regression exceeded tolerance.
12    Exceeded {
13        baseline_total: usize,
14        current_total: usize,
15        tolerance: Tolerance,
16        /// Per-type deltas for human output.
17        type_deltas: Vec<(&'static str, isize)>,
18    },
19    /// Regression check was skipped (e.g., --changed-since active).
20    Skipped { reason: &'static str },
21}
22
23impl RegressionOutcome {
24    /// Whether this outcome should cause a non-zero exit code.
25    #[must_use]
26    pub const fn is_failure(&self) -> bool {
27        matches!(self, Self::Exceeded { .. })
28    }
29
30    /// Build a JSON value for the regression outcome (added to JSON output envelope).
31    #[must_use]
32    #[cfg(test)]
33    pub fn to_json(&self) -> serde_json::Value {
34        match self {
35            Self::Pass {
36                baseline_total,
37                current_total,
38            } => serde_json::json!({
39                "status": "pass",
40                "baseline_total": baseline_total,
41                "current_total": current_total,
42                "delta": *current_total as isize - *baseline_total as isize,
43                "exceeded": false,
44            }),
45            Self::Exceeded {
46                baseline_total,
47                current_total,
48                tolerance,
49                ..
50            } => {
51                let (tolerance_value, tolerance_kind) = match tolerance {
52                    Tolerance::Percentage(pct) => (*pct, "percentage"),
53                    Tolerance::Absolute(abs) => (*abs as f64, "absolute"),
54                };
55                serde_json::json!({
56                    "status": "exceeded",
57                    "baseline_total": baseline_total,
58                    "current_total": current_total,
59                    "delta": *current_total as isize - *baseline_total as isize,
60                    "tolerance": tolerance_value,
61                    "tolerance_kind": tolerance_kind,
62                    "exceeded": true,
63                })
64            }
65            Self::Skipped { reason } => serde_json::json!({
66                "status": "skipped",
67                "reason": reason,
68                "exceeded": false,
69            }),
70        }
71    }
72}
73
74/// Print regression outcome to stderr (human-readable summary).
75pub fn print_regression_outcome(outcome: &RegressionOutcome) {
76    match outcome {
77        RegressionOutcome::Pass {
78            baseline_total,
79            current_total,
80        } => {
81            let delta = *current_total as isize - *baseline_total as isize;
82            let sign = if delta >= 0 { "+" } else { "" };
83            eprintln!(
84                "Regression check passed: {current_total} issues (baseline: {baseline_total}, \
85                 delta: {sign}{delta})"
86            );
87        }
88        RegressionOutcome::Exceeded {
89            baseline_total,
90            current_total,
91            tolerance,
92            type_deltas,
93        } => {
94            let delta = *current_total as isize - *baseline_total as isize;
95            let tol_str = match tolerance {
96                Tolerance::Percentage(pct) => format!("{pct}%"),
97                Tolerance::Absolute(abs) => format!("{abs}"),
98            };
99            eprintln!(
100                "Regression detected: {current_total} issues (baseline: {baseline_total}, \
101                 delta: +{delta}, tolerance: {tol_str})"
102            );
103            for (name, d) in type_deltas {
104                let sign = if *d > 0 { "+" } else { "" };
105                eprintln!("  {name}: {sign}{d}");
106            }
107        }
108        RegressionOutcome::Skipped { .. } => {}
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn pass_outcome_json() {
118        let outcome = RegressionOutcome::Pass {
119            baseline_total: 10,
120            current_total: 10,
121        };
122        let json = outcome.to_json();
123        assert_eq!(json["status"], "pass");
124        assert_eq!(json["exceeded"], false);
125        assert_eq!(json["delta"], 0);
126    }
127
128    #[test]
129    fn exceeded_outcome_json() {
130        let outcome = RegressionOutcome::Exceeded {
131            baseline_total: 10,
132            current_total: 15,
133            tolerance: Tolerance::Percentage(2.0),
134            type_deltas: vec![("unused_files", 5)],
135        };
136        let json = outcome.to_json();
137        assert_eq!(json["status"], "exceeded");
138        assert_eq!(json["exceeded"], true);
139        assert_eq!(json["delta"], 5);
140        assert_eq!(json["tolerance_kind"], "percentage");
141    }
142
143    #[test]
144    fn skipped_outcome_json() {
145        let outcome = RegressionOutcome::Skipped {
146            reason: "test reason",
147        };
148        let json = outcome.to_json();
149        assert_eq!(json["status"], "skipped");
150        assert_eq!(json["exceeded"], false);
151    }
152
153    #[test]
154    fn regression_outcome_is_failure() {
155        let pass = RegressionOutcome::Pass {
156            baseline_total: 10,
157            current_total: 10,
158        };
159        assert!(!pass.is_failure());
160
161        let exceeded = RegressionOutcome::Exceeded {
162            baseline_total: 10,
163            current_total: 15,
164            tolerance: Tolerance::Absolute(2),
165            type_deltas: vec![],
166        };
167        assert!(exceeded.is_failure());
168
169        let skipped = RegressionOutcome::Skipped { reason: "test" };
170        assert!(!skipped.is_failure());
171    }
172
173    #[test]
174    fn exceeded_outcome_json_absolute() {
175        let outcome = RegressionOutcome::Exceeded {
176            baseline_total: 10,
177            current_total: 15,
178            tolerance: Tolerance::Absolute(2),
179            type_deltas: vec![("unused_files", 5)],
180        };
181        let json = outcome.to_json();
182        assert_eq!(json["status"], "exceeded");
183        assert_eq!(json["tolerance_kind"], "absolute");
184        assert_eq!(json["tolerance"], 2.0);
185        assert_eq!(json["delta"], 5);
186    }
187
188    #[test]
189    fn pass_outcome_json_with_improvement() {
190        let outcome = RegressionOutcome::Pass {
191            baseline_total: 10,
192            current_total: 5,
193        };
194        let json = outcome.to_json();
195        assert_eq!(json["status"], "pass");
196        assert_eq!(json["delta"], -5);
197        assert_eq!(json["exceeded"], false);
198    }
199
200    #[test]
201    fn print_pass_outcome_does_not_panic() {
202        let outcome = RegressionOutcome::Pass {
203            baseline_total: 10,
204            current_total: 8,
205        };
206        print_regression_outcome(&outcome);
207    }
208
209    #[test]
210    fn print_exceeded_outcome_does_not_panic() {
211        let outcome = RegressionOutcome::Exceeded {
212            baseline_total: 10,
213            current_total: 15,
214            tolerance: Tolerance::Percentage(2.0),
215            type_deltas: vec![("unused_files", 5), ("unused_exports", -2)],
216        };
217        print_regression_outcome(&outcome);
218    }
219
220    #[test]
221    fn print_exceeded_outcome_absolute_does_not_panic() {
222        let outcome = RegressionOutcome::Exceeded {
223            baseline_total: 10,
224            current_total: 15,
225            tolerance: Tolerance::Absolute(2),
226            type_deltas: vec![("unused_files", 3), ("unresolved_imports", 2)],
227        };
228        print_regression_outcome(&outcome);
229    }
230
231    #[test]
232    fn print_skipped_outcome_does_not_panic() {
233        let outcome = RegressionOutcome::Skipped {
234            reason: "test reason",
235        };
236        print_regression_outcome(&outcome);
237    }
238
239    #[test]
240    fn print_exceeded_with_empty_deltas_does_not_panic() {
241        let outcome = RegressionOutcome::Exceeded {
242            baseline_total: 10,
243            current_total: 15,
244            tolerance: Tolerance::Absolute(0),
245            type_deltas: vec![],
246        };
247        print_regression_outcome(&outcome);
248    }
249}