kharcha_core/
categorize.rs1#[derive(Debug, Clone, uniffi::Record)]
9pub struct Rule {
10 pub pattern: String,
11 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 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
43pub 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
67fn 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()); assert!(categorize("parents gift", &r).is_none()); }
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}