commit 6cdcdcbb8dc2de9defaa25948fc21d7e4a669047 Author: Tait Hoyem Date: Sun Dec 11 13:49:06 2022 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6546b76 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,153 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "arrayvec" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "beancount-parser" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e30aaa9270127e9201b1db9644001e13b5218178afced4c6abc7e1969ac938" +dependencies = [ + "nom", + "rust_decimal", + "rustc_version", + "thiserror", +] + +[[package]] +name = "beancount-rs" +version = "0.1.0" +dependencies = [ + "beancount-parser", + "rust_decimal", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rust_decimal" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9164faf726e4f3ece4978b25ca877ddc6802fa77f38cdccb32c7f805ecd70c" +dependencies = [ + "arrayvec", + "num-traits", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4" + +[[package]] +name = "serde" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" + +[[package]] +name = "syn" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fcd952facd492f9be3ef0d0b7032a6e442ee9b361d4acc2b1d0c4aaa5f613a1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5e405a6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "beancount-rs" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +beancount-parser = { version = "1.8.5", features = ["rust_decimal"] } +#beancount-parser = { version = "1.0.0", features = ["rust_decimal"] } +rust_decimal = "1.26" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4675b73 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# `beancount-rs` + +This tool is extremely similar to the `beancount` utilities you're used to. +The only difference, is that they're written in Rust. +I plan to use this daily, so I should find any issues, but feel free to put issues in the Git for additional information. + +All code is licensed under the GPLv3, with the exception of the web portion, which is provided under the AGPLv3. + +## Limitations + +* The largest value the parser can understand is around: 79228162514264340000000000000 (That's 79 × 10^27). + * Pretty sure nobody has this much money, even theoretically. But who knows with inflation these days. + +## Restrictions + +* All transactions must either: + * Balance to zero, OR + * Have a blank posting in the account to fill in. + * You may only have zero or one blank postings per transaction. +* All transactions are processed in order of date, regardless of the order that the file contents. + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d57aea1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,125 @@ +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(()) +}