use std::{ io::Read, fs::File, env::args, process::exit, }; use rust_decimal::Decimal; use beancount_parser::{ Parser, Directive, Transaction, Account, Open, Close, Date, }; fn last_posting_is_none(transaction: &Transaction<'_>) -> bool { transaction.postings() .last() .expect("There are no transactions in this posting") .amount() .is_none() } fn balance(transaction: &Transaction<'_>) -> Decimal { transaction.postings() .iter() .filter_map(|p| Some::(p.amount()? .expression() .evaluate() .into()) ) .sum() } fn account_exists(acct: &Account, opens: &Vec>) -> bool { opens.iter() .filter(|a| a.account() == acct) .count() == 1 } fn accounts_exist(transaction: &Transaction, opens: &Vec>) -> bool { transaction.postings() .iter() .all(|post| account_exists(post.account(), opens)) } fn account_open(account: &Account, date: Date, opens: &Vec>) -> bool { opens.iter() .any(|o| o.date() <= date && account == o.account()) } fn accounts_open(transaction: &Transaction, opens: &Vec>) -> bool { transaction.postings() .iter() .all(|post| account_open(post.account(), transaction.date(), opens)) } fn account_closed(account: &Account, date: Date, closes: &Vec>) -> bool { closes.iter() .any(|c| c.date() < date && account == c.account()) } fn accounts_closed(transaction: &Transaction, closes: &Vec>) -> bool { transaction.postings() .iter() .all(|post| account_closed(post.account(), transaction.date(), closes)) } #[non_exhaustive] #[derive(Debug, Clone, Copy, Hash)] enum TransactionValidationError { AccountDoesNotExist, AccountNotOpenYet, AccountPreviouslyClosed, DoesNotBalanceToZero, } fn is_valid_transaction(transaction: &Transaction<'_>, opens: &Vec>, closes: &Vec>) -> Result { if balance(transaction) != Decimal::ZERO && !last_posting_is_none(transaction) { return Err(TransactionValidationError::DoesNotBalanceToZero); } if !accounts_exist(transaction, opens) { return Err(TransactionValidationError::AccountDoesNotExist); } if !accounts_open(transaction, opens) { return Err(TransactionValidationError::AccountNotOpenYet); } if accounts_closed(transaction, closes) { return Err(TransactionValidationError::AccountPreviouslyClosed); } Ok(true) } fn main() -> Result<(), beancount_parser::Error> { let bean_filename = args().nth(1).expect("A file must be specified"); let mut bean_file = File::open(bean_filename).expect("Can not open file"); let mut bean_contents = String::new(); bean_file.read_to_string(&mut bean_contents).expect("Can not read file"); let directives: Vec> = Parser::new(&bean_contents) .collect::>() .expect("Can not load directives; invalid syntax."); let transactions: Vec = directives.iter() .filter_map(|d| match d { Directive::Transaction(t) => Some(t.clone()), _ => None, }) .collect(); let opens: Vec> = directives.iter() .filter_map(|d| match d { Directive::Open(o) => Some(o.clone()), _ => None, }) .collect(); let closes: Vec> = directives.iter() .filter_map(|d| match d { Directive::Close(c) => Some(c.clone()), _ => None, }) .collect(); for transaction in &transactions { if let Err(e) = is_valid_transaction(&transaction, &opens, &closes) { println!("Invalid transaction: {:#?}", e); exit(1); } } println!("Accounts: {}", opens.len()); println!("Transactions: {}", transactions.len()); Ok(()) }