You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

126 lines
3.7 KiB

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::<Decimal>(p.amount()?
.expression()
.evaluate()
.into())
)
.sum()
}
fn account_exists(acct: &Account, opens: &Vec<Open<'_>>) -> bool {
opens.iter()
.filter(|a| a.account() == acct)
.count() == 1
}
fn accounts_exist(transaction: &Transaction, opens: &Vec<Open<'_>>) -> bool {
transaction.postings()
.iter()
.all(|post| account_exists(post.account(), opens))
}
fn account_open(account: &Account, date: Date, opens: &Vec<Open<'_>>) -> bool {
opens.iter()
.any(|o| o.date() <= date && account == o.account())
}
fn accounts_open(transaction: &Transaction, opens: &Vec<Open<'_>>) -> bool {
transaction.postings()
.iter()
.all(|post| account_open(post.account(), transaction.date(), opens))
}
fn account_closed(account: &Account, date: Date, closes: &Vec<Close<'_>>) -> bool {
closes.iter()
.any(|c| c.date() < date && account == c.account())
}
fn accounts_closed(transaction: &Transaction, closes: &Vec<Close<'_>>) -> 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<Open<'_>>, closes: &Vec<Close<'_>>) -> Result<bool, TransactionValidationError> {
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<Directive<'_>> = Parser::new(&bean_contents)
.collect::<Result<_, _>>()
.expect("Can not load directives; invalid syntax.");
let transactions: Vec<Transaction> = directives.iter()
.filter_map(|d| match d {
Directive::Transaction(t) => Some(t.clone()),
_ => None,
})
.collect();
let opens: Vec<Open<'_>> = directives.iter()
.filter_map(|d| match d {
Directive::Open(o) => Some(o.clone()),
_ => None,
})
.collect();
let closes: Vec<Close<'_>> = 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(())
}