Skip to main content

truecalc_core/eval/functions/math/
criterion.rs

1use crate::types::Value;
2
3/// A parsed criterion used by COUNTIF, SUMIF, AVERAGEIF.
4#[derive(Debug)]
5pub enum Criterion {
6    NumEq(f64),
7    NumNe(f64),
8    NumLt(f64),
9    NumGt(f64),
10    NumLe(f64),
11    NumGe(f64),
12    /// Case-insensitive exact text match.
13    TextEq(String),
14    /// Case-insensitive not-equal text match.
15    TextNe(String),
16    /// Case-insensitive wildcard pattern (`*` = any chars, `?` = any single char).
17    WildcardEq(Vec<char>),
18    BoolEq(bool),
19}
20
21/// Flatten a `Value` into a flat list of references, fully recursing through
22/// nested arrays (e.g. a vertical range materializes as nested one-element
23/// row arrays — see `resolve_range` in the workbook crate). Scalars become a
24/// single-element slice.
25pub fn flatten_to_vec(v: &Value) -> Vec<&Value> {
26    match v {
27        Value::Array(arr) => arr.iter().flat_map(flatten_to_vec).collect(),
28        other => vec![other],
29    }
30}
31
32/// Parse a `Value` criterion argument into a [`Criterion`].
33pub fn parse_criterion(v: &Value) -> Criterion {
34    match v {
35        Value::Number(n) => Criterion::NumEq(*n),
36        Value::Bool(b) => Criterion::BoolEq(*b),
37        Value::Text(s) => parse_criterion_str(s),
38        _ => Criterion::TextEq(String::new()), // fallback — matches nothing useful
39    }
40}
41
42/// Parse a string like `">2"`, `"apple"`, `"a*"`, `"<>3"` into a [`Criterion`].
43fn parse_criterion_str(s: &str) -> Criterion {
44    // Strip operator prefix — longest match first.
45    let (op, rest) = if let Some(r) = s.strip_prefix("<>") {
46        ("<>", r)
47    } else if let Some(r) = s.strip_prefix(">=") {
48        (">=", r)
49    } else if let Some(r) = s.strip_prefix("<=") {
50        ("<=", r)
51    } else if let Some(r) = s.strip_prefix('>') {
52        (">", r)
53    } else if let Some(r) = s.strip_prefix('<') {
54        ("<", r)
55    } else if let Some(r) = s.strip_prefix('=') {
56        ("=", r)
57    } else {
58        ("", s)
59    };
60
61    // If there is an operator, or the bare string parses as a number, try numeric.
62    if !op.is_empty() || rest.parse::<f64>().is_ok() {
63        if let Ok(n) = rest.parse::<f64>() {
64            return match op {
65                "<>" => Criterion::NumNe(n),
66                ">=" => Criterion::NumGe(n),
67                "<=" => Criterion::NumLe(n),
68                ">"  => Criterion::NumGt(n),
69                "<"  => Criterion::NumLt(n),
70                _    => Criterion::NumEq(n), // "=" or bare number string
71            };
72        }
73        // Non-numeric after operator.
74        if op == "<>" {
75            return Criterion::TextNe(rest.to_lowercase());
76        }
77        // Other operators with non-numeric text: degrade to TextEq of original.
78        return Criterion::TextEq(s.to_lowercase());
79    }
80
81    // No operator prefix: check for wildcards or tilde escapes.
82    // A tilde-escaped pattern also needs wildcard_match so that ~ is decoded
83    // (e.g. "a~?b" should match the literal text "a?b").
84    let has_wildcard_or_tilde = {
85        let chars: Vec<char> = rest.chars().collect();
86        let mut found = false;
87        let mut i = 0;
88        while i < chars.len() {
89            if chars[i] == '~' {
90                found = true; // tilde escape -> route through wildcard_match
91                break;
92            } else if chars[i] == '*' || chars[i] == '?' {
93                found = true;
94                break;
95            } else {
96                i += 1;
97            }
98        }
99        found
100    };
101    if has_wildcard_or_tilde {
102        return Criterion::WildcardEq(rest.to_lowercase().chars().collect());
103    }
104
105    Criterion::TextEq(rest.to_lowercase())
106}
107
108/// Test whether a `Value` satisfies a `Criterion`.
109pub fn matches_criterion(value: &Value, crit: &Criterion) -> bool {
110    match crit {
111        Criterion::NumEq(n) => match value {
112            Value::Number(v) => (v - n).abs() < 1e-10,
113            // Google Sheets coercion: a numeric criterion matches a text cell
114            // whose content parses as the same number (e.g. criteria=1 matches
115            // cell text "1").  This is the standard DB-function behaviour for
116            // ID columns stored as strings.
117            Value::Text(s) => s.trim().parse::<f64>().is_ok_and(|v| (v - n).abs() < 1e-10),
118            _ => false,
119        },
120        Criterion::NumNe(n) => match value {
121            Value::Number(v) => (v - n).abs() >= 1e-10,
122            Value::Text(s) => !s.trim().parse::<f64>().is_ok_and(|v| (v - n).abs() < 1e-10),
123            _ => true, // non-numbers are "not equal" to a number
124        },
125        Criterion::NumLt(n) => matches!(value, Value::Number(v) if v < n),
126        Criterion::NumGt(n) => matches!(value, Value::Number(v) if v > n),
127        Criterion::NumLe(n) => matches!(value, Value::Number(v) if v <= n),
128        Criterion::NumGe(n) => matches!(value, Value::Number(v) if v >= n),
129        Criterion::TextEq(pat) => match value {
130            Value::Text(s) => s.to_lowercase() == *pat,
131            Value::Bool(b) => {
132                let s = if *b { "true" } else { "false" };
133                s == pat.as_str()
134            }
135            _ => false,
136        },
137        Criterion::TextNe(pat) => match value {
138            Value::Text(s) => s.to_lowercase() != *pat,
139            _ => true,
140        },
141        Criterion::WildcardEq(pattern) => match value {
142            Value::Text(s) => {
143                let text: Vec<char> = s.to_lowercase().chars().collect();
144                wildcard_match(pattern, &text)
145            }
146            _ => false,
147        },
148        Criterion::BoolEq(b) => matches!(value, Value::Bool(v) if v == b),
149    }
150}
151
152/// Full wildcard match: `pattern` must match the entire `text`.
153/// `*` matches any sequence of characters (including empty); `?` matches any single character.
154fn wildcard_match(pattern: &[char], text: &[char]) -> bool {
155    match (pattern.first(), text.first()) {
156        (None, None) => true,
157        (None, _) => false,
158        (Some('*'), _) => {
159            // Try consuming 0, 1, 2, … characters from text.
160            for i in 0..=text.len() {
161                if wildcard_match(&pattern[1..], &text[i..]) {
162                    return true;
163                }
164            }
165            false
166        }
167        (Some(_), None) => false,
168        (Some('~'), _) => {
169            // Tilde escape: next char is literal (must match exactly)
170            if pattern.len() < 2 {
171                return false;
172            }
173            match text.first() {
174                Some(t) if *t == pattern[1] => wildcard_match(&pattern[2..], &text[1..]),
175                _ => false,
176            }
177        }
178        (Some(p), Some(t)) => {
179            if *p == '?' || *p == *t {
180                wildcard_match(&pattern[1..], &text[1..])
181            } else {
182                false
183            }
184        }
185    }
186}
187
188// ── Tests ─────────────────────────────────────────────────────────────────
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::types::Value;
194
195    fn num(n: f64) -> Value { Value::Number(n) }
196    fn text(s: &str) -> Value { Value::Text(s.to_string()) }
197
198    #[test]
199    fn numeric_eq() {
200        let c = parse_criterion(&num(3.0));
201        assert!(matches_criterion(&num(3.0), &c));
202        assert!(!matches_criterion(&num(4.0), &c));
203    }
204
205    #[test]
206    fn text_criterion_gt() {
207        let c = parse_criterion(&text(">2"));
208        assert!(matches_criterion(&num(3.0), &c));
209        assert!(!matches_criterion(&num(1.0), &c));
210    }
211
212    #[test]
213    fn text_criterion_ne_num() {
214        let c = parse_criterion(&text("<>2"));
215        assert!(matches_criterion(&num(3.0), &c));
216        assert!(!matches_criterion(&num(2.0), &c));
217    }
218
219    #[test]
220    fn text_criterion_exact() {
221        let c = parse_criterion(&text("apple"));
222        assert!(matches_criterion(&text("Apple"), &c)); // case-insensitive
223        assert!(!matches_criterion(&text("banana"), &c));
224    }
225
226    #[test]
227    fn text_criterion_wildcard_star() {
228        let c = parse_criterion(&text("a*"));
229        assert!(matches_criterion(&text("apple"), &c));
230        assert!(matches_criterion(&text("a"), &c));
231        assert!(!matches_criterion(&text("banana"), &c));
232    }
233
234    #[test]
235    fn text_criterion_wildcard_question() {
236        let c = parse_criterion(&text("ap?"));
237        assert!(matches_criterion(&text("apt"), &c));
238        assert!(matches_criterion(&text("ape"), &c));
239        assert!(!matches_criterion(&text("apple"), &c));
240    }
241
242    #[test]
243    fn bool_criterion() {
244        let c = parse_criterion(&Value::Bool(true));
245        assert!(matches_criterion(&Value::Bool(true), &c));
246        assert!(!matches_criterion(&Value::Bool(false), &c));
247    }
248
249    #[test]
250    fn flatten_array() {
251        let arr = Value::Array(vec![num(1.0), num(2.0), num(3.0)]);
252        let flat = flatten_to_vec(&arr);
253        assert_eq!(flat.len(), 3);
254    }
255
256    #[test]
257    fn flatten_scalar() {
258        let v = num(5.0);
259        let flat = flatten_to_vec(&v);
260        assert_eq!(flat.len(), 1);
261    }
262}