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;
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}