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