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                    return delta > 0;
54                }
55                let allowed = (baseline_total as f64 * pct / 100.0).floor() as usize;
56                delta > allowed
57            }
58            Self::Absolute(abs) => delta > abs,
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn parse_percentage_tolerance() {
69        let t = Tolerance::parse("2%").unwrap();
70        assert!(matches!(t, Tolerance::Percentage(p) if (p - 2.0).abs() < f64::EPSILON));
71    }
72
73    #[test]
74    fn parse_absolute_tolerance() {
75        let t = Tolerance::parse("5").unwrap();
76        assert!(matches!(t, Tolerance::Absolute(5)));
77    }
78
79    #[test]
80    fn parse_zero_tolerance() {
81        let t = Tolerance::parse("0").unwrap();
82        assert!(matches!(t, Tolerance::Absolute(0)));
83    }
84
85    #[test]
86    fn parse_empty_defaults_to_zero() {
87        let t = Tolerance::parse("").unwrap();
88        assert!(matches!(t, Tolerance::Absolute(0)));
89    }
90
91    #[test]
92    fn parse_invalid_percentage() {
93        assert!(Tolerance::parse("abc%").is_err());
94    }
95
96    #[test]
97    fn parse_negative_percentage() {
98        assert!(Tolerance::parse("-1%").is_err());
99    }
100
101    #[test]
102    fn parse_invalid_absolute() {
103        assert!(Tolerance::parse("abc").is_err());
104    }
105
106    #[test]
107    fn zero_tolerance_detects_any_increase() {
108        let t = Tolerance::Absolute(0);
109        assert!(t.exceeded(10, 11));
110        assert!(!t.exceeded(10, 10));
111        assert!(!t.exceeded(10, 9));
112    }
113
114    #[test]
115    fn absolute_tolerance_allows_within_range() {
116        let t = Tolerance::Absolute(3);
117        assert!(!t.exceeded(10, 12));
118        assert!(!t.exceeded(10, 13));
119        assert!(t.exceeded(10, 14));
120    }
121
122    #[test]
123    fn percentage_tolerance_allows_within_range() {
124        let t = Tolerance::Percentage(10.0);
125        assert!(!t.exceeded(100, 109));
126        assert!(!t.exceeded(100, 110));
127        assert!(t.exceeded(100, 111));
128    }
129
130    #[test]
131    fn percentage_tolerance_from_zero_baseline() {
132        let t = Tolerance::Percentage(10.0);
133        assert!(t.exceeded(0, 1));
134        assert!(!t.exceeded(0, 0));
135    }
136
137    #[test]
138    fn decrease_never_exceeds() {
139        let t = Tolerance::Absolute(0);
140        assert!(!t.exceeded(10, 5));
141        let t = Tolerance::Percentage(0.0);
142        assert!(!t.exceeded(10, 5));
143    }
144
145    #[test]
146    fn parse_whitespace_padded_tolerance() {
147        let t = Tolerance::parse("  5  ").unwrap();
148        assert!(matches!(t, Tolerance::Absolute(5)));
149    }
150
151    #[test]
152    fn parse_whitespace_only_defaults_to_zero() {
153        let t = Tolerance::parse("   ").unwrap();
154        assert!(matches!(t, Tolerance::Absolute(0)));
155    }
156
157    #[test]
158    fn parse_zero_percent_tolerance() {
159        let t = Tolerance::parse("0%").unwrap();
160        assert!(matches!(t, Tolerance::Percentage(p) if p == 0.0));
161    }
162
163    #[test]
164    fn parse_decimal_percentage_tolerance() {
165        let t = Tolerance::parse("1.5%").unwrap();
166        assert!(matches!(t, Tolerance::Percentage(p) if (p - 1.5).abs() < f64::EPSILON));
167    }
168
169    #[test]
170    fn parse_large_absolute_tolerance() {
171        let t = Tolerance::parse("1000").unwrap();
172        assert!(matches!(t, Tolerance::Absolute(1000)));
173    }
174
175    #[test]
176    fn parse_negative_absolute_is_err() {
177        assert!(Tolerance::parse("-1").is_err());
178    }
179
180    #[test]
181    fn parse_whitespace_padded_percentage() {
182        let t = Tolerance::parse("  3.5%  ").unwrap();
183        assert!(matches!(t, Tolerance::Percentage(p) if (p - 3.5).abs() < f64::EPSILON));
184    }
185
186    #[test]
187    fn zero_pct_tolerance_detects_any_increase() {
188        let t = Tolerance::Percentage(0.0);
189        assert!(t.exceeded(100, 101));
190        assert!(!t.exceeded(100, 100));
191        assert!(!t.exceeded(100, 99));
192    }
193
194    #[test]
195    fn percentage_tolerance_with_small_baseline() {
196        let t = Tolerance::Percentage(10.0);
197        assert!(t.exceeded(3, 4));
198        assert!(!t.exceeded(3, 3));
199    }
200
201    #[test]
202    fn percentage_tolerance_large_percentage() {
203        let t = Tolerance::Percentage(100.0);
204        assert!(!t.exceeded(10, 20));
205        assert!(t.exceeded(10, 21));
206    }
207
208    #[test]
209    fn absolute_tolerance_at_exact_boundary() {
210        let t = Tolerance::Absolute(5);
211        assert!(!t.exceeded(10, 15));
212        assert!(t.exceeded(10, 16));
213    }
214
215    #[test]
216    fn decrease_never_exceeds_for_all_variants() {
217        let t = Tolerance::Absolute(0);
218        assert!(!t.exceeded(10, 0));
219        let t = Tolerance::Percentage(0.0);
220        assert!(!t.exceeded(10, 0));
221    }
222
223    #[test]
224    fn equal_values_never_exceed() {
225        assert!(!Tolerance::Absolute(0).exceeded(0, 0));
226        assert!(!Tolerance::Percentage(0.0).exceeded(0, 0));
227        assert!(!Tolerance::Absolute(0).exceeded(100, 100));
228        assert!(!Tolerance::Percentage(0.0).exceeded(100, 100));
229    }
230}