use chrono::{naive::NaiveDate, Duration}; use nom::branch::alt; use nom::bytes::complete::{is_not, tag, take}; use nom::character::complete::{alphanumeric1, anychar, char, digit1, space1}; use nom::combinator::{map, map_res, recognize}; use nom::multi::separated_list0; use nom::sequence::{delimited, pair, preceded}; use rust_decimal::Decimal; use std::convert::TryFrom; use std::str::FromStr; use crate::transaction::{Category, Transaction}; //TODO tests #[derive(Clone)] #[derive(Debug)] pub enum DateIsh { Absolute(NaiveDate), Relative(Duration), } impl DateIsh { fn parse(i: &str) -> nom::IResult<&str, Self> { alt(( map( map_res( take(10usize), |s| NaiveDate::parse_from_str(s, "%Y-%m-%d"), ), DateIsh::Absolute ), map( Self::parse_relative, DateIsh::Relative ) ))(i) } fn parse_relative(i: &str) -> nom::IResult<&str, Duration> { map( pair( digit1, anychar ), |(amount, unit): (&str, char)| match unit { 'd' => Duration::days(amount.parse().unwrap()), 'w' => Duration::days(amount.parse().unwrap()), _ => unimplemented!(), } )(i) } pub fn get(self) -> NaiveDate { match self { DateIsh::Absolute(date) => date, DateIsh::Relative(offset) => chrono::offset::Local::today().naive_utc() + offset } } } #[derive(Clone)] #[derive(Debug)] pub enum Comparison { Equal, Greater, GreaterOrEqual, Less, LessOrEqual, } impl TryFrom<&str> for Comparison { type Error = (); fn try_from(s: &str) -> Result { match s { "=" | "==" => Ok(Comparison::Equal), ">" => Ok(Comparison::Greater), ">=" => Ok(Comparison::GreaterOrEqual), "<" => Ok(Comparison::Less), "<=" => Ok(Comparison::LessOrEqual), _ => Err(()), } } } fn parse_comparison(i: &str) -> nom::IResult<&str, (Decimal, Comparison)> { let (i, comparison) = map( alt(( tag("="), tag("=="), tag(">"), tag(">="), tag("<"), tag("<="), )), |comparison| Comparison::try_from(comparison).unwrap() )(i)?; let (i, amount) = map_res( digit1, Decimal::from_str )(i)?; Ok((i, (amount, comparison))) } #[derive(Clone)] #[derive(Debug)] 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), } ) } } } fn parse(i: &str) -> nom::IResult<&str, Self> { alt(( map( preceded( tag("category:"), string, ), |c| Constraint::Category(c.to_string()) ), map( preceded( tag("before:"), DateIsh::parse ), Constraint::Before ), map( preceded( tag("after:"), DateIsh::parse ), Constraint::After ), map( preceded( tag("on:"), DateIsh::parse ), Constraint::On ), map( preceded( tag("amount:"), parse_comparison, ), |(amount, comparison)| Constraint::AmountCompare(amount, comparison) ) ))(i) } } fn string(i: &str) -> nom::IResult<&str, &str> { alt(( delimited(char('"'), is_not("\""), char('"')), recognize(alphanumeric1), ))(i) } #[derive(Clone)] #[derive(Debug)] pub enum Filter { Union(Constraint), Intersect(Constraint), Subtract(Constraint), } impl Filter { pub fn parse(i: &str) -> nom::IResult<&str, Self> { alt(( map( preceded( char('+'), Constraint::parse, ), Filter::Union ), map( preceded( char('-'), Constraint::parse, ), Filter::Subtract ), map(Constraint::parse, Filter::Intersect), ))(i) } } #[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_filters(i: &str) -> Filter { map( separated_list0( space1, Filter::parse ), |filters| Filter::Intersect(Constraint::Filters(filters)) )(i).unwrap().1 } #[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], ); } }