Skip to main content

fallow_cli/regression/
outcome.rs

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