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