fallow_cli/regression/
tolerance.rs1#[derive(Debug, Clone, Copy)]
3pub enum Tolerance {
4 Percentage(f64),
6 Absolute(usize),
8}
9
10impl Tolerance {
11 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 #[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;
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 #[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 #[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)); assert!(!t.exceeded(10, 13)); assert!(t.exceeded(10, 14)); }
126
127 #[test]
128 fn percentage_tolerance_allows_within_range() {
129 let t = Tolerance::Percentage(10.0);
130 assert!(!t.exceeded(100, 109)); assert!(!t.exceeded(100, 110)); assert!(t.exceeded(100, 111)); }
134
135 #[test]
136 fn percentage_tolerance_from_zero_baseline() {
137 let t = Tolerance::Percentage(10.0);
138 assert!(t.exceeded(0, 1)); assert!(!t.exceeded(0, 0)); }
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 #[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 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 #[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 let t = Tolerance::Percentage(10.0);
208 assert!(t.exceeded(3, 4)); assert!(!t.exceeded(3, 3)); }
211
212 #[test]
213 fn percentage_tolerance_large_percentage() {
214 let t = Tolerance::Percentage(100.0);
215 assert!(!t.exceeded(10, 20)); assert!(t.exceeded(10, 21)); }
219
220 #[test]
221 fn absolute_tolerance_at_exact_boundary() {
222 let t = Tolerance::Absolute(5);
223 assert!(!t.exceeded(10, 15)); assert!(t.exceeded(10, 16)); }
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}