1use std::cmp::Ordering;
20use std::collections::HashSet;
21
22use crate::config::ResolvedColumnFormat;
23use crate::data::{CellValue, ColumnKind};
24use crate::format::format_cell;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum TextOp {
35 Contains,
37 DoesNotContain,
39 BeginsWith,
41 EndsWith,
43 Is,
45 IsNot,
47 Matches,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum NumberOp {
59 Eq,
61 Ne,
63 Gt,
65 Ge,
67 Lt,
69 Le,
71 Between,
73 NotBetween,
75}
76
77#[derive(Clone, Debug, Default, PartialEq)]
79pub enum FilterPredicate {
80 #[default]
82 None,
83 Text {
85 op: TextOp,
87 operand: String,
89 },
90 Number {
93 op: NumberOp,
95 a: f64,
97 b: f64,
99 },
100}
101
102#[derive(Clone, Debug, Default, PartialEq)]
105pub struct ColumnFilter {
106 pub predicate: FilterPredicate,
108 pub values: Option<HashSet<String>>,
111}
112
113impl ColumnFilter {
114 #[must_use]
117 pub fn is_active(&self) -> bool {
118 !matches!(self.predicate, FilterPredicate::None) || self.values.is_some()
119 }
120}
121
122#[must_use]
125pub fn uses_number_ops(kind: ColumnKind) -> bool {
126 matches!(
127 kind,
128 ColumnKind::Integer | ColumnKind::Decimal | ColumnKind::Date
129 )
130}
131
132#[must_use]
134pub fn cell_passes_filter(
135 value: &CellValue,
136 fmt: &ResolvedColumnFormat,
137 filter: &ColumnFilter,
138) -> bool {
139 if !predicate_matches(value, fmt, &filter.predicate) {
140 return false;
141 }
142 if let Some(allowed) = &filter.values {
143 let (formatted, _) = format_cell(value, fmt);
144 if !allowed.contains(&formatted) {
145 return false;
146 }
147 }
148 true
149}
150
151fn predicate_matches(
152 value: &CellValue,
153 fmt: &ResolvedColumnFormat,
154 predicate: &FilterPredicate,
155) -> bool {
156 match predicate {
157 FilterPredicate::None => true,
158 FilterPredicate::Text { op, operand } => text_matches(value, fmt, *op, operand),
159 FilterPredicate::Number { op, a, b } => number_matches(value, *op, *a, *b),
160 }
161}
162
163fn text_matches(value: &CellValue, fmt: &ResolvedColumnFormat, op: TextOp, operand: &str) -> bool {
164 let (formatted, _) = format_cell(value, fmt);
165 if op == TextOp::Matches {
166 return regex_matches(&formatted, operand);
167 }
168 let hay = formatted.to_lowercase();
169 let needle = operand.to_lowercase();
170 match op {
171 TextOp::Contains => hay.contains(&needle),
172 TextOp::DoesNotContain => !hay.contains(&needle),
173 TextOp::BeginsWith => hay.starts_with(&needle),
174 TextOp::EndsWith => hay.ends_with(&needle),
175 TextOp::Is => hay == needle,
176 TextOp::IsNot => hay != needle,
177 TextOp::Matches => unreachable!("handled above"),
178 }
179}
180
181fn regex_matches(hay: &str, pattern: &str) -> bool {
182 if pattern.is_empty() {
183 return true;
184 }
185 match regex::RegexBuilder::new(pattern)
186 .case_insensitive(true)
187 .build()
188 {
189 Ok(re) => re.is_match(hay),
190 Err(_) => false,
192 }
193}
194
195fn cell_number(value: &CellValue) -> Option<f64> {
199 match value {
200 CellValue::Integer(i) => Some(*i as f64),
201 CellValue::Decimal(d) => Some(*d),
202 CellValue::Date(t) => Some(*t as f64),
203 CellValue::Text(_) | CellValue::Boolean(_) | CellValue::None => None,
204 }
205}
206
207fn number_matches(value: &CellValue, op: NumberOp, a: f64, b: f64) -> bool {
208 let Some(v) = cell_number(value) else {
209 return false;
210 };
211 let ord = v.total_cmp(&a);
214 match op {
215 NumberOp::Eq => ord == Ordering::Equal,
216 NumberOp::Ne => ord != Ordering::Equal,
217 NumberOp::Gt => ord == Ordering::Greater,
218 NumberOp::Ge => ord != Ordering::Less,
219 NumberOp::Lt => ord == Ordering::Less,
220 NumberOp::Le => ord != Ordering::Greater,
221 NumberOp::Between => in_range(v, a, b),
222 NumberOp::NotBetween => !in_range(v, a, b),
223 }
224}
225
226fn in_range(v: f64, a: f64, b: f64) -> bool {
227 let (lo, hi) = if a.total_cmp(&b) == Ordering::Greater {
228 (b, a)
229 } else {
230 (a, b)
231 };
232 v.total_cmp(&lo) != Ordering::Less && v.total_cmp(&hi) != Ordering::Greater
233}
234
235#[must_use]
239pub fn parse_ymd_to_unix(s: &str) -> Option<i64> {
240 let t = s.trim();
241 let mut parts = t.split('-');
242 let y: i64 = parts.next()?.parse().ok()?;
243 let m: i64 = parts.next()?.parse().ok()?;
244 let d: i64 = parts.next()?.parse().ok()?;
245 if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
246 return None;
247 }
248 Some(days_from_civil(y, m, d) * 86_400)
249}
250
251fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
254 let y = if m <= 2 { y - 1 } else { y };
255 let era = if y >= 0 { y } else { y - 399 } / 400;
256 let yoe = y - era * 400;
257 let mp = if m > 2 { m - 3 } else { m + 9 };
258 let doy = (153 * mp + 2) / 5 + d - 1;
259 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
260 era * 146_097 + doe - 719_468
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used, clippy::expect_used)]
265mod tests {
266 use super::*;
267 use crate::config::{BooleanFormat, DateFormat, NumberFormat, ReplacementTiming, StringFormat};
268
269 fn resolved(kind: ColumnKind) -> ResolvedColumnFormat {
270 ResolvedColumnFormat {
271 kind,
272 number: NumberFormat::default(),
273 date: DateFormat::default(),
274 boolean: BooleanFormat::default(),
275 string: StringFormat::default(),
276 null: crate::config::NullFormat::default(),
277 replacements: vec![],
278 replacement_timing: ReplacementTiming::AfterFormat,
279 }
280 }
281
282 fn text_filter(op: TextOp, operand: &str) -> ColumnFilter {
283 ColumnFilter {
284 predicate: FilterPredicate::Text {
285 op,
286 operand: operand.to_owned(),
287 },
288 values: None,
289 }
290 }
291
292 fn number_filter(op: NumberOp, a: f64, b: f64) -> ColumnFilter {
293 ColumnFilter {
294 predicate: FilterPredicate::Number { op, a, b },
295 values: None,
296 }
297 }
298
299 #[test]
300 fn empty_filter_passes_everything() {
301 let f = ColumnFilter::default();
302 assert!(!f.is_active());
303 assert!(cell_passes_filter(
304 &CellValue::Text("anything".into()),
305 &resolved(ColumnKind::Text),
306 &f
307 ));
308 }
309
310 #[test]
311 fn text_ops_are_case_insensitive() {
312 let fmt = resolved(ColumnKind::Text);
313 let v = CellValue::Text("Hello World".into());
314 assert!(cell_passes_filter(
315 &v,
316 &fmt,
317 &text_filter(TextOp::Contains, "LO W")
318 ));
319 assert!(cell_passes_filter(
320 &v,
321 &fmt,
322 &text_filter(TextOp::BeginsWith, "hell")
323 ));
324 assert!(cell_passes_filter(
325 &v,
326 &fmt,
327 &text_filter(TextOp::EndsWith, "RLD")
328 ));
329 assert!(cell_passes_filter(
330 &v,
331 &fmt,
332 &text_filter(TextOp::Is, "hello world")
333 ));
334 assert!(!cell_passes_filter(
335 &v,
336 &fmt,
337 &text_filter(TextOp::IsNot, "hello world")
338 ));
339 assert!(cell_passes_filter(
340 &v,
341 &fmt,
342 &text_filter(TextOp::DoesNotContain, "zzz")
343 ));
344 }
345
346 #[test]
347 fn text_matches_regex_and_bad_regex_matches_nothing() {
348 let fmt = resolved(ColumnKind::Text);
349 let v = CellValue::Text("abc123".into());
350 assert!(cell_passes_filter(
351 &v,
352 &fmt,
353 &text_filter(TextOp::Matches, r"^abc\d+$")
354 ));
355 assert!(!cell_passes_filter(
356 &v,
357 &fmt,
358 &text_filter(TextOp::Matches, r"^\d+$")
359 ));
360 assert!(!cell_passes_filter(
362 &v,
363 &fmt,
364 &text_filter(TextOp::Matches, "(")
365 ));
366 }
367
368 #[test]
369 fn number_ops_cover_comparisons_and_ranges() {
370 let fmt = resolved(ColumnKind::Integer);
371 let v = CellValue::Integer(50);
372 assert!(cell_passes_filter(
373 &v,
374 &fmt,
375 &number_filter(NumberOp::Eq, 50.0, 0.0)
376 ));
377 assert!(cell_passes_filter(
378 &v,
379 &fmt,
380 &number_filter(NumberOp::Ne, 51.0, 0.0)
381 ));
382 assert!(cell_passes_filter(
383 &v,
384 &fmt,
385 &number_filter(NumberOp::Gt, 49.0, 0.0)
386 ));
387 assert!(cell_passes_filter(
388 &v,
389 &fmt,
390 &number_filter(NumberOp::Ge, 50.0, 0.0)
391 ));
392 assert!(cell_passes_filter(
393 &v,
394 &fmt,
395 &number_filter(NumberOp::Lt, 51.0, 0.0)
396 ));
397 assert!(cell_passes_filter(
398 &v,
399 &fmt,
400 &number_filter(NumberOp::Le, 50.0, 0.0)
401 ));
402 assert!(cell_passes_filter(
404 &v,
405 &fmt,
406 &number_filter(NumberOp::Between, 100.0, 10.0)
407 ));
408 assert!(!cell_passes_filter(
409 &v,
410 &fmt,
411 &number_filter(NumberOp::NotBetween, 10.0, 100.0)
412 ));
413 }
414
415 #[test]
416 fn number_predicate_rejects_non_numeric_cells() {
417 let fmt = resolved(ColumnKind::Integer);
418 assert!(!cell_passes_filter(
419 &CellValue::None,
420 &fmt,
421 &number_filter(NumberOp::Ge, 0.0, 0.0)
422 ));
423 }
424
425 #[test]
426 fn value_set_allow_list_filters() {
427 let fmt = resolved(ColumnKind::Text);
428 let mut allowed = HashSet::new();
429 allowed.insert("keep".to_owned());
430 let f = ColumnFilter {
431 predicate: FilterPredicate::None,
432 values: Some(allowed),
433 };
434 assert!(f.is_active());
435 assert!(cell_passes_filter(
436 &CellValue::Text("keep".into()),
437 &fmt,
438 &f
439 ));
440 assert!(!cell_passes_filter(
441 &CellValue::Text("drop".into()),
442 &fmt,
443 &f
444 ));
445 }
446
447 #[test]
448 fn predicate_and_value_set_compose_with_and() {
449 let fmt = resolved(ColumnKind::Text);
450 let mut allowed = HashSet::new();
451 allowed.insert("alpha".to_owned());
452 allowed.insert("apex".to_owned());
453 let f = ColumnFilter {
454 predicate: FilterPredicate::Text {
455 op: TextOp::BeginsWith,
456 operand: "al".into(),
457 },
458 values: Some(allowed),
459 };
460 assert!(cell_passes_filter(
462 &CellValue::Text("alpha".into()),
463 &fmt,
464 &f
465 ));
466 assert!(!cell_passes_filter(
468 &CellValue::Text("apex".into()),
469 &fmt,
470 &f
471 ));
472 }
473
474 #[test]
475 fn date_range_via_parsed_operands() {
476 let fmt = resolved(ColumnKind::Date);
477 let jan1 = parse_ymd_to_unix("2024-01-01").expect("valid date");
479 assert_eq!(jan1, 1_704_067_200);
480 let feb1 = parse_ymd_to_unix("2024-02-01").expect("valid date");
481 let v = CellValue::Date(1_706_000_000); let f = number_filter(NumberOp::Between, jan1 as f64, feb1 as f64);
483 assert!(cell_passes_filter(&v, &fmt, &f));
484 }
485
486 #[test]
487 fn parse_ymd_rejects_garbage() {
488 assert!(parse_ymd_to_unix("not-a-date").is_none());
489 assert!(parse_ymd_to_unix("2024-13-01").is_none());
490 assert!(parse_ymd_to_unix("2024-01-32").is_none());
491 assert!(parse_ymd_to_unix("2024-01-01-01").is_none());
492 }
493
494 #[test]
495 fn uses_number_ops_matches_numeric_kinds() {
496 assert!(uses_number_ops(ColumnKind::Integer));
497 assert!(uses_number_ops(ColumnKind::Decimal));
498 assert!(uses_number_ops(ColumnKind::Date));
499 assert!(!uses_number_ops(ColumnKind::Text));
500 assert!(!uses_number_ops(ColumnKind::Boolean));
501 assert!(!uses_number_ops(ColumnKind::None));
502 }
503}