use chrono::{naive::NaiveDate, Duration}; use rust_decimal::Decimal; use std::str::FromStr; use crate::transaction::{Category, Transaction}; //TODO tests #[derive(Clone)] pub enum DateIsh { Absolute(NaiveDate), Relative(Duration), } impl DateIsh { pub fn parse(s: &str) -> Self { NaiveDate::parse_from_str(s, "%Y-%m-%d") .map(DateIsh::Absolute) .unwrap_or_else(|_| DateIsh::Relative(Self::parse_relative(s).unwrap())) } pub fn parse_relative(s: &str) -> Option { //TODO Month and year. Would depend on current date so maybe parse in one place. let num = s[..s.len()-1].parse().ok()?; Some(match s.chars().last()? { 'd' => Duration::days(num), 'w' => Duration::weeks(num), _ => unimplemented!(), }) } pub fn get(self) -> NaiveDate { match self { DateIsh::Absolute(date) => date, DateIsh::Relative(offset) => chrono::offset::Local::today().naive_utc() + offset } } } #[derive(Clone)] pub enum Comparison { Equal, Greater, GreaterOrEqual, Less, LessOrEqual, } fn parse_comparison(s: &str) -> Option<(Decimal, Comparison)> { if s.starts_with(">=") { Some((Decimal::from_str(&s[2..]).ok()?, Comparison::GreaterOrEqual)) } else if s.starts_with("<=") { Some((Decimal::from_str(&s[2..]).ok()?, Comparison::LessOrEqual)) } else if s.starts_with("=") { Some((Decimal::from_str(&s[1..]).ok()?, Comparison::Equal)) } else if s.starts_with(">") { Some((Decimal::from_str(&s[1..]).ok()?, Comparison::Greater)) } else if s.starts_with("<") { Some((Decimal::from_str(&s[1..]).ok()?, Comparison::Less)) } else { None } } #[derive(Clone)] pub enum Constraint { Category(Category), Before(DateIsh), After(DateIsh), On(DateIsh), AmountCompare(Decimal, Comparison), Filters(Vec), } impl Constraint { fn satisfies(&self, transaction: &Transaction) -> bool { match self { Constraint::Category(category) => &transaction.category == category, Constraint::Before(date) => transaction.date < date.clone().get(), Constraint::After(date) => transaction.date >= date.clone().get(), Constraint::On(date) => transaction.date == date.clone().get(), Constraint::AmountCompare(amount, comparison) => match comparison { Comparison::Equal => &transaction.amount == amount, Comparison::Greater => &transaction.amount > amount, Comparison::GreaterOrEqual => &transaction.amount >= amount, Comparison::Less => &transaction.amount < amount, Comparison::LessOrEqual => &transaction.amount <= amount, } Constraint::Filters(filters) => { filters .iter() .fold(true, |include, filter| match filter { Filter::Union(constraint) => include || constraint.satisfies(transaction), Filter::Intersect(constraint) => include && constraint.satisfies(transaction), Filter::Subtract(constraint) => include && !constraint.satisfies(transaction), } ) } } } } #[derive(Clone)] pub enum Filter { Union(Constraint), Intersect(Constraint), Subtract(Constraint), } #[derive(Clone)] pub struct Search<'t> { filtered: Vec, transactions: &'t [Transaction], } impl<'t> Search<'t> { pub fn new(transactions: &'t [Transaction]) -> Self { Self { filtered: std::iter::successors(Some(0_usize), |n| Some(n.checked_add(1).unwrap())) .take(transactions.len()) .collect(), transactions, } } pub fn get(&self) -> Vec<&'t Transaction> { self .filtered .iter() .map(|idx| &self.transactions[*idx]) .collect() } pub fn apply(mut self, filter: Filter) -> Self { match filter { Filter::Union(constraint) => { //TODO binary search and insert sorted for idx in self .transactions .iter() .enumerate() .filter(|(_, t)| constraint.satisfies(t)) .map(|(idx, _)| idx) { self.filtered.push(idx); } self.filtered.sort(); self.filtered.dedup(); } Filter::Intersect(constraint) => { self.filtered = self .filtered .iter() .filter(|t| constraint.satisfies(&self.transactions[**t])) .copied() .collect(); } Filter::Subtract(constraint) => { self.filtered = self .filtered .iter() .filter(|t| !constraint.satisfies(&self.transactions[**t])) .copied() .collect(); } } self } pub fn parse(mut self, rules: String) -> Self { for rule in rules.split(' ') { let (filter, rule): (fn(Constraint) -> Filter, &str) = match rule.chars().nth(0).unwrap() { '-' => (Filter::Subtract, &rule[1..]), '+' => (Filter::Union, &rule[1..]), _ => (Filter::Intersect, &rule[..]), }; //TODO lexing? can do a function for "spaces inside" instead //TODO category:"foo bar" let constraint = match rule.split_once(':').unwrap() { ("category", category) => Constraint::Category(category.to_string()), ("before", date_ish) => Constraint::Before(DateIsh::parse(date_ish)), ("after", date_ish) => Constraint::After(DateIsh::parse(date_ish)), ("on", date_ish) => Constraint::On(DateIsh::parse(date_ish)), ("amount", comparison) => { let (amount, comparison) = parse_comparison(comparison).unwrap(); Constraint::AmountCompare(amount, comparison) } _ => panic!(), }; self = self.apply(filter(constraint)); } self } } #[cfg(test)] mod test { use chrono::NaiveDate; use crate::search::{Comparison, Constraint, DateIsh, Filter, Search}; use crate::transaction::{Transaction, TransactionKind}; fn transaction(desc_id: u32) -> Transaction { Transaction { description: format!("{}", desc_id), date: NaiveDate::from_ymd(2021, 01, 01), category: "category".to_string(), amount: 100.into(), kind: TransactionKind::Expense, from: "from".to_string(), to: "to".to_string(), } } fn transaction_id(transaction: &Transaction) -> u32 { transaction.description.parse().unwrap() } #[test] fn category() { let transactions = vec![ Transaction { category: "C1".to_string(), ..transaction(0) }, Transaction { category: "C1".to_string(), ..transaction(1) }, Transaction { category: "C2".to_string(), ..transaction(2) }, ]; let mut search = Search::new(&transactions); assert_eq!(search.get().len(), 3); let category_filter = Filter::Intersect(Constraint::Category("C1".to_string())); search = search.apply(category_filter); assert_eq!(search.get().len(), 2); assert_eq!( search.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0, 1], ); } #[test] fn date() { let transactions = vec![ Transaction { date: NaiveDate::from_ymd(2021, 01, 04), ..transaction(0) }, Transaction { date: NaiveDate::from_ymd(2021, 01, 05), ..transaction(1) }, Transaction { date: NaiveDate::from_ymd(2021, 01, 06), ..transaction(2) }, Transaction { date: NaiveDate::from_ymd(2021, 01, 07), ..transaction(3) }, ]; let search = Search::new(&transactions); assert_eq!(search.get().len(), 4); let date = DateIsh::Absolute(NaiveDate::from_ymd(2021, 01, 05)); let mut search_before = search.clone(); let before_filter = Filter::Intersect(Constraint::Before(date.clone())); search_before = search_before.apply(before_filter); assert_eq!(search_before.get().len(), 1); assert_eq!( search_before.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0], ); let mut search_after = search.clone(); let after_filter = Filter::Intersect(Constraint::After(date.clone())); search_after = search_after.apply(after_filter); assert_eq!(search_after.get().len(), 3); assert_eq!( search_after.get().iter().map(|t| transaction_id(t)).collect::>(), vec![1, 2, 3], ); let mut search_on = search.clone(); let on_filter = Filter::Intersect(Constraint::On(date.clone())); search_on = search_on.apply(on_filter); assert_eq!(search_on.get().len(), 1); assert_eq!( search_on.get().iter().map(|t| transaction_id(t)).collect::>(), vec![1], ); } #[test] fn amount() { let transactions = vec![ Transaction { amount: 150.into(), ..transaction(0) }, Transaction { amount: 160.into(), ..transaction(1) }, Transaction { amount: 170.into(), ..transaction(2) }, Transaction { amount: 180.into(), ..transaction(3) }, ]; let search = Search::new(&transactions); assert_eq!(search.get().len(), 4); let mut search_less = search.clone(); let less_filter = Filter::Intersect(Constraint::AmountCompare(160.into(), Comparison::Less)); search_less = search_less.apply(less_filter); assert_eq!(search_less.get().len(), 1); assert_eq!( search_less.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0], ); let mut search_less_eq = search.clone(); let less_eq_filter = Filter::Intersect(Constraint::AmountCompare(160.into(), Comparison::LessOrEqual)); search_less_eq = search_less_eq.apply(less_eq_filter); assert_eq!(search_less_eq.get().len(), 2); assert_eq!( search_less_eq.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0, 1], ); let mut search_eq = search.clone(); let eq_filter = Filter::Intersect(Constraint::AmountCompare(160.into(), Comparison::Equal)); search_eq = search_eq.apply(eq_filter); assert_eq!(search_eq.get().len(), 1); assert_eq!( search_eq.get().iter().map(|t| transaction_id(t)).collect::>(), vec![1], ); let mut search_greater = search.clone(); let greater_filter = Filter::Intersect(Constraint::AmountCompare(160.into(), Comparison::Greater)); search_greater = search_greater.apply(greater_filter); assert_eq!(search_greater.get().len(), 2); assert_eq!( search_greater.get().iter().map(|t| transaction_id(t)).collect::>(), vec![2, 3], ); let mut search_greater_eq = search.clone(); let greater_eq_filter = Filter::Intersect(Constraint::AmountCompare(160.into(), Comparison::GreaterOrEqual)); search_greater_eq = search_greater_eq.apply(greater_eq_filter); assert_eq!(search_greater_eq.get().len(), 3); assert_eq!( search_greater_eq.get().iter().map(|t| transaction_id(t)).collect::>(), vec![1, 2, 3], ); } #[test] fn filters_constraint() { let transactions = vec![ Transaction { category: "C1".to_string(), amount: 150.into(), ..transaction(0) }, Transaction { category: "C2".to_string(), amount: 150.into(), ..transaction(1) }, Transaction { category: "C1".to_string(), amount: 160.into(), ..transaction(2) }, Transaction { category: "C2".to_string(), amount: 160.into(), ..transaction(3) }, ]; let search = Search::new(&transactions); let c1 = Constraint::Category("C1".to_string()); let amount_150 = Constraint::AmountCompare(150.into(), Comparison::Equal); let mut search_filters_1 = search.clone(); search_filters_1 = search_filters_1.apply(Filter::Intersect(c1.clone())); search_filters_1 = search_filters_1.apply(Filter::Union(amount_150.clone())); assert_eq!(search_filters_1.get().len(), 3); assert_eq!( search_filters_1.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0, 1, 2], ); // Wrap an intersect in a union. let mut search_filters_2 = search.clone(); let inner_filters = Constraint::Filters(vec![Filter::Intersect(amount_150)]); search_filters_2 = search_filters_2.apply(Filter::Intersect(c1)); search_filters_2 = search_filters_2.apply(Filter::Union(inner_filters)); assert_eq!(search_filters_2.get().len(), 3); assert_eq!( search_filters_2.get().iter().map(|t| transaction_id(t)).collect::>(), vec![0, 1, 2], ); } }