Skip to main content

kharcha_core/
categorize.rs

1/// Merchant normalization + rule-based categorization. Rule-based only — no AI.
2/// Port of `lib/core/categorizer.dart`.
3///
4/// Match is a word-boundary substring on normalized strings, which doubles as
5/// the fuzzy match: "Zomato", "ZOMATO-UB", "zomato order" all hit "zomato",
6/// but never inside "buzzomatic".
7/// Minimal rule row (callers map their storage rows onto this).
8#[derive(Debug, Clone, uniffi::Record)]
9pub struct Rule {
10    pub pattern: String,
11    /// "learned" beats "builtin".
12    pub rule_type: String,
13    pub category_id: Option<i64>,
14}
15
16impl Rule {
17    pub fn new(pattern: &str, rule_type: &str, category_id: i64) -> Self {
18        Self { pattern: pattern.to_string(), rule_type: rule_type.to_string(), category_id: Some(category_id) }
19    }
20}
21
22pub fn normalize_merchant(raw: &str) -> String {
23    // Mirrors `toLowerCase().replaceAll([^a-z0-9]+, ' ').trim()`:
24    // Unicode lowercase (same mapping as Dart), ASCII alnum kept, runs of
25    // anything else collapse to one space, ends trimmed. Single pass.
26    let mut out = String::with_capacity(raw.len());
27    let mut prev_space = true;
28    for c in raw.chars().flat_map(|c| c.to_lowercase()) {
29        if c.is_ascii_alphanumeric() {
30            out.push(c);
31            prev_space = false;
32        } else if !prev_space {
33            out.push(' ');
34            prev_space = true;
35        }
36    }
37    if prev_space {
38        out.pop();
39    }
40    out
41}
42
43/// Returns the matching rule for a merchant, or None.
44/// Priority: learned beats builtin; within one type, longest pattern first.
45/// Stable sort — ties keep caller order (a strengthening; Dart documents
46/// no stability guarantee, but all real rule sets order identically).
47/// ponytail: length is bytes, Dart `length` is UTF-16 units — identical for
48/// the ASCII patterns this engine actually stores; revisit if non-ASCII rule
49/// patterns ever exist.
50pub fn categorize<'a>(merchant: &str, rules: &'a [Rule]) -> Option<&'a Rule> {
51    let normalized = normalize_merchant(merchant);
52    if normalized.is_empty() {
53        return None;
54    }
55    let mut ordered: Vec<&'a Rule> = rules.iter().collect();
56    ordered.sort_by(|a, b| {
57        let ta = i32::from(a.rule_type != "learned");
58        let tb = i32::from(b.rule_type != "learned");
59        ta.cmp(&tb).then(b.pattern.len().cmp(&a.pattern.len()))
60    });
61    ordered.into_iter().find(|r| {
62        let pattern = normalize_merchant(&r.pattern);
63        !pattern.is_empty() && word_match(&normalized, &pattern)
64    })
65}
66
67/// ASCII `\bneedle\b` on normalized text. Both sides are `[a-z0-9 ]` after
68/// normalization, so a boundary is exactly start/end-or-space — no regex
69/// engine needed (ponytail rung 3: stdlib over dep).
70fn word_match(haystack: &str, needle: &str) -> bool {
71    let h = haystack.as_bytes();
72    haystack.match_indices(needle).any(|(i, _)| {
73        (i == 0 || h[i - 1] == b' ') && (i + needle.len() == h.len() || h[i + needle.len()] == b' ')
74    })
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn normalizes() {
83        assert_eq!(normalize_merchant("ZOMATO-UB"), "zomato ub");
84        assert_eq!(normalize_merchant("  Swiggy   Instamart!! "), "swiggy instamart");
85        assert_eq!(normalize_merchant("₹UPI-Pay"), "upi pay");
86    }
87
88    fn rules() -> Vec<Rule> {
89        vec![
90            Rule::new("zomato", "builtin", 1),
91            Rule::new("swiggy", "builtin", 1),
92            Rule::new("uber", "builtin", 2),
93            Rule::new("vi", "builtin", 3),
94            Rule::new("rent", "builtin", 4),
95        ]
96    }
97
98    #[test]
99    fn variants_hit_no_false_positives() {
100        let r = rules();
101        for m in ["Zomato", "ZOMATO-UB", "zomato order", " Swiggy "] {
102            assert!(categorize(m, &r).is_some(), "{m}");
103        }
104        assert!(categorize("Ravi Kirana", &r).is_none());
105        assert!(categorize("zzz", &r).is_none());
106        assert!(categorize("service station", &r).is_none()); // 'vi' not inside
107        assert!(categorize("parents gift", &r).is_none()); // 'rent' not inside
108    }
109
110    #[test]
111    fn learned_overrides_longest_wins() {
112        let r = rules();
113        let mut learned = vec![Rule::new("zomato", "learned", 9)];
114        learned.extend(r.clone());
115        assert_eq!(categorize("zomato", &learned).unwrap().category_id, Some(9));
116        let mut with_long = vec![Rule::new("zomato ub", "builtin", 7)];
117        with_long.extend(r);
118        assert_eq!(categorize("zomato ub", &with_long).unwrap().category_id, Some(7));
119    }
120}