use crate::enrichment::CategorizationMethod;
use regex::Regex;
#[derive(Debug)]
pub struct Rule {
pub name: Option<String>,
pub pattern: RulePattern,
pub account: String,
pub priority: i32,
}
#[derive(Debug)]
pub enum RulePattern {
Substring(String),
Regex(Regex),
Exact(String),
}
impl RulePattern {
fn matches(&self, text: &str) -> bool {
match self {
Self::Substring(s) => text.contains(s.as_str()),
Self::Regex(r) => r.is_match(text),
Self::Exact(s) => text.eq_ignore_ascii_case(s.as_str()),
}
}
}
#[derive(Debug, Clone)]
pub struct RuleMatch {
pub account: String,
pub rule_name: Option<String>,
pub method: CategorizationMethod,
pub confidence: f64,
}
#[derive(Debug)]
pub struct RulesEngine {
rules: Vec<Rule>,
}
impl RulesEngine {
#[must_use]
pub const fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: Rule) {
self.rules.push(rule);
self.rules.sort_by_key(|r| std::cmp::Reverse(r.priority));
}
pub fn load_from_mappings(&mut self, mappings: &[(String, String)]) {
for (pattern, account) in mappings {
self.rules.push(Rule {
name: None,
pattern: RulePattern::Substring(pattern.to_lowercase()),
account: account.clone(),
priority: 0,
});
}
self.rules.sort_by_key(|r| std::cmp::Reverse(r.priority));
}
pub fn load_from_regex_mappings(&mut self, mappings: &[(String, String)]) {
for (pattern, account) in mappings {
if let Ok(regex) = regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
self.rules.push(Rule {
name: Some(pattern.clone()),
pattern: RulePattern::Regex(regex),
account: account.clone(),
priority: 0,
});
}
}
self.rules.sort_by_key(|r| std::cmp::Reverse(r.priority));
}
pub fn load_merchant_dict(&mut self) {
for entry in crate::merchants::MERCHANT_PATTERNS {
if let Ok(regex) = regex::RegexBuilder::new(entry.pattern)
.case_insensitive(true)
.build()
{
self.rules.push(Rule {
name: Some(entry.category.to_string()),
pattern: RulePattern::Regex(regex),
account: entry.account.to_string(),
priority: -1000, });
}
}
self.rules.sort_by_key(|r| std::cmp::Reverse(r.priority));
}
pub fn categorize(&self, payee: Option<&str>, narration: &str) -> Option<RuleMatch> {
let payee_lower = payee.map(str::to_lowercase);
let narration_lower = narration.to_lowercase();
for rule in &self.rules {
if let Some(ref p) = payee_lower
&& rule.pattern.matches(p)
{
return Some(RuleMatch {
account: rule.account.clone(),
rule_name: rule.name.clone(),
method: if rule.priority <= -1000 {
CategorizationMethod::MerchantDict
} else {
CategorizationMethod::Rule
},
confidence: 1.0,
});
}
if rule.pattern.matches(&narration_lower) {
return Some(RuleMatch {
account: rule.account.clone(),
rule_name: rule.name.clone(),
method: if rule.priority <= -1000 {
CategorizationMethod::MerchantDict
} else {
CategorizationMethod::Rule
},
confidence: 1.0,
});
}
}
None
}
#[must_use]
pub const fn len(&self) -> usize {
self.rules.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
impl Default for RulesEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substring_match() {
let mut engine = RulesEngine::new();
engine.load_from_mappings(&[("amazon".to_string(), "Expenses:Shopping".to_string())]);
let result = engine.categorize(Some("AMAZON MARKETPLACE"), "Order #123");
assert!(result.is_some());
assert_eq!(result.unwrap().account, "Expenses:Shopping");
}
#[test]
fn substring_match_narration() {
let mut engine = RulesEngine::new();
engine.load_from_mappings(&[("coffee".to_string(), "Expenses:Dining:Coffee".to_string())]);
let result = engine.categorize(None, "Morning coffee at the cafe");
assert!(result.is_some());
assert_eq!(result.unwrap().account, "Expenses:Dining:Coffee");
}
#[test]
fn regex_match() {
let mut engine = RulesEngine::new();
engine.load_from_regex_mappings(&[(
r"UBER(EATS)?".to_string(),
"Expenses:Transport".to_string(),
)]);
let result = engine.categorize(Some("UBEREATS"), "food delivery");
assert!(result.is_some());
assert_eq!(result.unwrap().account, "Expenses:Transport");
let result = engine.categorize(Some("UBER TRIP"), "ride");
assert!(result.is_some());
}
#[test]
fn no_match_returns_none() {
let mut engine = RulesEngine::new();
engine.load_from_mappings(&[("amazon".to_string(), "Expenses:Shopping".to_string())]);
let result = engine.categorize(Some("STARBUCKS"), "Latte");
assert!(result.is_none());
}
#[test]
fn priority_ordering() {
let mut engine = RulesEngine::new();
engine.add_rule(Rule {
name: Some("general".to_string()),
pattern: RulePattern::Substring("food".to_string()),
account: "Expenses:Food".to_string(),
priority: -100,
});
engine.add_rule(Rule {
name: Some("specific".to_string()),
pattern: RulePattern::Substring("food".to_string()),
account: "Expenses:Groceries".to_string(),
priority: 100,
});
let result = engine.categorize(None, "whole food market");
assert!(result.is_some());
assert_eq!(result.unwrap().account, "Expenses:Groceries");
}
#[test]
fn user_rules_beat_merchant_dict() {
let mut engine = RulesEngine::new();
engine.load_from_mappings(&[("starbucks".to_string(), "Expenses:Coffee".to_string())]);
engine.load_merchant_dict();
let result = engine.categorize(Some("STARBUCKS"), "");
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.account, "Expenses:Coffee");
assert_eq!(m.method, CategorizationMethod::Rule);
}
#[test]
fn merchant_dict_as_fallback() {
let mut engine = RulesEngine::new();
engine.load_merchant_dict();
let result = engine.categorize(Some("NETFLIX.COM"), "");
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.method, CategorizationMethod::MerchantDict);
}
#[test]
fn exact_match() {
let mut engine = RulesEngine::new();
engine.add_rule(Rule {
name: None,
pattern: RulePattern::Exact("rent".to_string()),
account: "Expenses:Rent".to_string(),
priority: 0,
});
let result = engine.categorize(None, "rent");
assert!(result.is_some());
let result = engine.categorize(None, "rent payment");
assert!(result.is_none());
}
#[test]
fn payee_takes_priority_over_narration() {
let mut engine = RulesEngine::new();
engine.load_from_mappings(&[("whole foods".to_string(), "Expenses:Groceries".to_string())]);
engine.load_from_mappings(&[("whole foods".to_string(), "Expenses:Organic".to_string())]);
let result = engine.categorize(Some("Whole Foods Market"), "weekly shopping");
assert_eq!(result.unwrap().account, "Expenses:Groceries");
}
#[test]
fn empty_engine() {
let engine = RulesEngine::new();
assert!(engine.is_empty());
assert_eq!(engine.len(), 0);
assert!(engine.categorize(Some("anything"), "anything").is_none());
}
}