Skip to main content

fallow_cli/regression/
tolerance.rs

1/// How much increase is allowed before a regression is flagged.
2#[derive(Debug, Clone, Copy)]
3pub enum Tolerance {
4    /// Percentage increase relative to the baseline total (e.g., 2.0 means 2%).
5    Percentage(f64),
6    /// Absolute increase in issue count.
7    Absolute(usize),
8}
9
10impl Tolerance {
11    /// Parse a tolerance string: `"2%"` for percentage, `"5"` for absolute.
12    /// Default when no value is given: `Absolute(0)` (zero tolerance).
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the string is not a valid number or percentage,
17    /// or if a percentage value is negative.
18    pub fn parse(s: &str) -> Result<Self, String> {
19        let s = s.trim();
20        if s.is_empty() {
21            return Ok(Self::Absolute(0));
22        }
23        if let Some(pct_str) = s.strip_suffix('%') {
24            let pct: f64 = pct_str
25                .trim()
26                .parse()
27                .map_err(|_| format!("invalid tolerance percentage: {s}"))?;
28            if pct < 0.0 {
29                return Err(format!("tolerance percentage must be non-negative: {s}"));
30            }
31            Ok(Self::Percentage(pct))
32        } else {
33            let abs: usize = s
34                .parse()
35                .map_err(|_| format!("invalid tolerance value: {s} (use a number or N%)"))?;
36            Ok(Self::Absolute(abs))
37        }
38    }
39
40    /// Check whether the delta exceeds this tolerance.
41    #[expect(
42        clippy::cast_possible_truncation,
43        reason = "percentage of a count is bounded by the count itself"
44    )]
45    pub fn exceeded(&self, baseline_total: usize, current_total: usize) -> bool {
46        if current_total <= baseline_total {
47            return false;
48        }
49        let delta = current_total - baseline_total;
50        match *self {
51            Self::Percentage(pct) => {
52                if baseline_total == 0 {
53                    // Any increase from zero is a regression when pct tolerance is used
54                    return delta > 0;
55                }
56                let allowed = (baseline_total as f64 * pct / 100.0).floor() as usize;
57                delta > allowed
58            }
59            Self::Absolute(abs) => delta > abs,
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    // ── Tolerance parsing ───────────────────────────────────────────
69
70    #[test]
71    fn parse_percentage_tolerance() {
72        let t = Tolerance::parse("2%").unwrap();
73        assert!(matches!(t, Tolerance::Percentage(p) if (p - 2.0).abs() < f64::EPSILON));
74    }
75
76    #[test]
77    fn parse_absolute_tolerance() {
78        let t = Tolerance::parse("5").unwrap();
79        assert!(matches!(t, Tolerance::Absolute(5)));
80    }
81
82    #[test]
83    fn parse_zero_tolerance() {
84        let t = Tolerance::parse("0").unwrap();
85        assert!(matches!(t, Tolerance::Absolute(0)));
86    }
87
88    #[test]
89    fn parse_empty_defaults_to_zero() {
90        let t = Tolerance::parse("").unwrap();
91        assert!(matches!(t, Tolerance::Absolute(0)));
92    }
93
94    #[test]
95    fn parse_invalid_percentage() {
96        assert!(Tolerance::parse("abc%").is_err());
97    }
98
99    #[test]
100    fn parse_negative_percentage() {
101        assert!(Tolerance::parse("-1%").is_err());
102    }
103
104    #[test]
105    fn parse_invalid_absolute() {
106        assert!(Tolerance::parse("abc").is_err());
107    }
108
109    // ── Tolerance::exceeded ────────────────────────────────────────
110
111    #[test]
112    fn zero_tolerance_detects_any_increase() {
113        let t = Tolerance::Absolute(0);
114        assert!(t.exceeded(10, 11));
115        assert!(!t.exceeded(10, 10));
116        assert!(!t.exceeded(10, 9));
117    }
118
119    #[test]
120    fn absolute_tolerance_allows_within_range() {
121        let t = Tolerance::Absolute(3);
122        assert!(!t.exceeded(10, 12)); // delta=2, allowed=3
123        assert!(!t.exceeded(10, 13)); // delta=3, allowed=3
124        assert!(t.exceeded(10, 14)); // delta=4, allowed=3
125    }
126
127    #[test]
128    fn percentage_tolerance_allows_within_range() {
129        let t = Tolerance::Percentage(10.0);
130        assert!(!t.exceeded(100, 109)); // delta=9, allowed=floor(10)=10
131        assert!(!t.exceeded(100, 110)); // delta=10, allowed=10
132        assert!(t.exceeded(100, 111)); // delta=11, allowed=10
133    }
134
135    #[test]
136    fn percentage_tolerance_from_zero_baseline() {
137        let t = Tolerance::Percentage(10.0);
138        assert!(t.exceeded(0, 1)); // any increase from zero
139        assert!(!t.exceeded(0, 0)); // no increase
140    }
141
142    #[test]
143    fn decrease_never_exceeds() {
144        let t = Tolerance::Absolute(0);
145        assert!(!t.exceeded(10, 5));
146        let t = Tolerance::Percentage(0.0);
147        assert!(!t.exceeded(10, 5));
148    }
149
150    // ── Additional tolerance parsing ────────────────────────────────
151
152    #[test]
153    fn parse_whitespace_padded_tolerance() {
154        let t = Tolerance::parse("  5  ").unwrap();
155        assert!(matches!(t, Tolerance::Absolute(5)));
156    }
157
158    #[test]
159    fn parse_whitespace_only_defaults_to_zero() {
160        let t = Tolerance::parse("   ").unwrap();
161        assert!(matches!(t, Tolerance::Absolute(0)));
162    }
163
164    #[test]
165    fn parse_zero_percent_tolerance() {
166        let t = Tolerance::parse("0%").unwrap();
167        assert!(matches!(t, Tolerance::Percentage(p) if p == 0.0));
168    }
169
170    #[test]
171    fn parse_decimal_percentage_tolerance() {
172        let t = Tolerance::parse("1.5%").unwrap();
173        assert!(matches!(t, Tolerance::Percentage(p) if (p - 1.5).abs() < f64::EPSILON));
174    }
175
176    #[test]
177    fn parse_large_absolute_tolerance() {
178        let t = Tolerance::parse("1000").unwrap();
179        assert!(matches!(t, Tolerance::Absolute(1000)));
180    }
181
182    #[test]
183    fn parse_negative_absolute_is_err() {
184        // usize can't be negative, so parsing "-1" as usize fails
185        assert!(Tolerance::parse("-1").is_err());
186    }
187
188    #[test]
189    fn parse_whitespace_padded_percentage() {
190        let t = Tolerance::parse("  3.5%  ").unwrap();
191        assert!(matches!(t, Tolerance::Percentage(p) if (p - 3.5).abs() < f64::EPSILON));
192    }
193
194    // ── Additional Tolerance::exceeded ──────────────────────────────
195
196    #[test]
197    fn zero_pct_tolerance_detects_any_increase() {
198        let t = Tolerance::Percentage(0.0);
199        assert!(t.exceeded(100, 101));
200        assert!(!t.exceeded(100, 100));
201        assert!(!t.exceeded(100, 99));
202    }
203
204    #[test]
205    fn percentage_tolerance_with_small_baseline() {
206        // baseline=3, 10% of 3 = 0.3, floor = 0 => delta > 0 triggers
207        let t = Tolerance::Percentage(10.0);
208        assert!(t.exceeded(3, 4)); // delta=1 > allowed=0
209        assert!(!t.exceeded(3, 3)); // no increase
210    }
211
212    #[test]
213    fn percentage_tolerance_large_percentage() {
214        let t = Tolerance::Percentage(100.0);
215        // baseline=10, 100% of 10 = 10, floor=10 => delta > 10 triggers
216        assert!(!t.exceeded(10, 20)); // delta=10, allowed=10
217        assert!(t.exceeded(10, 21)); // delta=11, allowed=10
218    }
219
220    #[test]
221    fn absolute_tolerance_at_exact_boundary() {
222        let t = Tolerance::Absolute(5);
223        assert!(!t.exceeded(10, 15)); // delta=5, allowed=5
224        assert!(t.exceeded(10, 16)); // delta=6, allowed=5
225    }
226
227    #[test]
228    fn decrease_never_exceeds_for_all_variants() {
229        let t = Tolerance::Absolute(0);
230        assert!(!t.exceeded(10, 0));
231        let t = Tolerance::Percentage(0.0);
232        assert!(!t.exceeded(10, 0));
233    }
234
235    #[test]
236    fn equal_values_never_exceed() {
237        assert!(!Tolerance::Absolute(0).exceeded(0, 0));
238        assert!(!Tolerance::Percentage(0.0).exceeded(0, 0));
239        assert!(!Tolerance::Absolute(0).exceeded(100, 100));
240        assert!(!Tolerance::Percentage(0.0).exceeded(100, 100));
241    }
242}