use chrono::Datelike; 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) => today() + 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("<="), 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), ), map(tag("week"), |_| Constraint::Filters(this_week())), map(tag("month"), |_| Constraint::Filters(this_month())), map( delimited(char('('), separated_list0(space1, Filter::parse), char(')')), Constraint::Filters, ), ))(i) } } fn today() -> NaiveDate { chrono::Local::today().naive_utc() } fn filter_between(start: NaiveDate, end: NaiveDate) -> Vec { vec![ Filter::Intersect(Constraint::After(DateIsh::Absolute(start))), Filter::Intersect(Constraint::Before(DateIsh::Absolute(end))), ] } fn this_week() -> Vec { let today = today(); let to_last_monday = Duration::days(today.weekday().num_days_from_monday() as i64); let last_monday = today - to_last_monday; let next_monday = last_monday + Duration::weeks(1); filter_between(last_monday, next_monday) } fn this_month() -> Vec { let start_of_month = today().with_day(1).unwrap(); let start_of_next_month = match start_of_month.month() { 12 => start_of_month .with_year(start_of_month.year() + 1) .unwrap() .with_month(1) .unwrap(), _ => start_of_month .with_month(start_of_month.month() + 1) .unwrap(), }; filter_between(start_of_month, start_of_next_month) } 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: (0..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() } fn ids(search: &Search) -> Vec { search.get().iter().map(|t| transaction_id(t)).collect() } #[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!(ids(&search), 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!(ids(&search_before), 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!(ids(&search_after), 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!(ids(&search_on), 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!(ids(&search_less), 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!(ids(&search_less_eq), 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!(ids(&search_eq), 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!(ids(&search_greater), 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!(ids(&search_greater_eq), 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!(ids(&search_filters_1), 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!(ids(&search_filters_2), vec![0, 1, 2]); } }