├── tests ├── include.ledger ├── tests-for-wasm.rs ├── lot.ledger ├── trade.ledger ├── basic.ledger ├── minimal.ledger ├── multiple_currencies.ledger ├── trade-sell.ledger ├── two_xact.ledger ├── two-xact-sub-acct.ledger ├── commodity_exchange.ledger ├── trade-buy-sell.ledger ├── trade-buy-sell-full-price.ledger ├── trade-buy-sell-lot.ledger ├── utility-tests.rs ├── accounts-tests.rs ├── functional-tests.rs ├── ext-report-tests.rs ├── poc.rs └── ext-parser-tests.rs ├── src ├── utilities.rs ├── directives.rs ├── journalreader.rs ├── main.rs ├── iterator.rs ├── value.rs ├── post.rs ├── commodity.rs ├── annotate.rs ├── reader.rs ├── journal.rs ├── report.rs ├── lib.rs ├── balance.rs ├── amount.rs ├── xact.rs ├── account.rs ├── option.rs ├── history.rs ├── pool.rs ├── scanner.rs └── parser.rs ├── check-wasm.cmd ├── wwwroot ├── index.html └── server.ts ├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── README.md └── .vscode └── launch.json /tests/include.ledger: -------------------------------------------------------------------------------- 1 | include basic.ledger 2 | -------------------------------------------------------------------------------- /src/utilities.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Handy utility functions 3 | */ 4 | 5 | -------------------------------------------------------------------------------- /tests/tests-for-wasm.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Tests for the functionality used in Wasm. 3 | */ 4 | -------------------------------------------------------------------------------- /tests/lot.ledger: -------------------------------------------------------------------------------- 1 | 2023-05-01 Trade 2 | Assets:Stocks 10 VEUR @ 12.75 EUR 3 | Assets:Cash 4 | -------------------------------------------------------------------------------- /tests/trade.ledger: -------------------------------------------------------------------------------- 1 | ; a trade 2 | 3 | 2023-04-21 Trade 4 | Assets:Stocks 10 VEUR @ 20 EUR 5 | Assets:Cash 6 | -------------------------------------------------------------------------------- /tests/basic.ledger: -------------------------------------------------------------------------------- 1 | ; basic test file 2 | 3 | 2023-04-21 Supermarket 4 | Expenses:Food 20 EUR 5 | Assets:Cash 6 | -------------------------------------------------------------------------------- /tests/minimal.ledger: -------------------------------------------------------------------------------- 1 | ; The simplest journal. No commodities, two accounts, integer value. 2 | 2023-05-05 Payee 3 | Expenses 20 4 | Assets 5 | -------------------------------------------------------------------------------- /tests/multiple_currencies.ledger: -------------------------------------------------------------------------------- 1 | ; Multiple currencies in one transaction 2 | 3 | 2023-04-21 FX 4 | Assets:Eur -20 EUR 5 | Assets:Bam 40 BAM 6 | -------------------------------------------------------------------------------- /check-wasm.cmd: -------------------------------------------------------------------------------- 1 | :: check wasm32 support 2 | :: First install the wasm32 target: 3 | :: rustup target add wasm32-unknown-unknown 4 | 5 | cargo check --target wasm32-unknown-unknown 6 | -------------------------------------------------------------------------------- /wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Hi!

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/trade-sell.ledger: -------------------------------------------------------------------------------- 1 | ; a sale trade 2 | 3 | 2023-04-21 Buy Trade 4 | Assets:Stocks 10 VEUR @ 20 EUR 5 | Assets:Cash 6 | 7 | 2023-04-21 Sell Trade 8 | Assets:Stocks -10 VEUR @ 25 EUR 9 | Assets:Cash 10 | -------------------------------------------------------------------------------- /tests/two_xact.ledger: -------------------------------------------------------------------------------- 1 | ; Two transactions with a blank line separator. 2 | 3 | 2023-04-21 Supermarket 4 | Expenses:Food 20 EUR 5 | Assets:Cash 6 | 7 | 2023-04-22 Supermarket 8 | Expenses:Food 10 EUR 9 | Assets:Cash 10 | -------------------------------------------------------------------------------- /src/directives.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Types of directives 3 | */ 4 | 5 | use crate::xact::Xact; 6 | 7 | 8 | /// Types of directives 9 | #[derive(Debug)] 10 | pub enum DirectiveType { 11 | Comment, 12 | Price, 13 | Xact(Xact) 14 | } 15 | -------------------------------------------------------------------------------- /tests/two-xact-sub-acct.ledger: -------------------------------------------------------------------------------- 1 | ; Two transactions with two accounts under the same parent. 2 | 3 | 2023-04-21 Supermarket 4 | Expenses:Food 20 EUR 5 | Assets:Cash 6 | 7 | 2023-04-22 Supermarket 8 | Expenses:Dining 10 EUR 9 | Assets:Checking 10 | -------------------------------------------------------------------------------- /tests/commodity_exchange.ledger: -------------------------------------------------------------------------------- 1 | ; based on the given price and a transaction, 2 | ; run the balance report with -X USD, 3 | ; to get the account balances in USD. 4 | 5 | P 2022-03-03 13:00:00 EUR 1.10 USD 6 | 7 | 2023-01-10 Vacation 8 | Expenses:Vacation 20 EUR 9 | Assets:Cash 10 | -------------------------------------------------------------------------------- /tests/trade-buy-sell.ledger: -------------------------------------------------------------------------------- 1 | ; a full trade, with purchase and sale of a commodity. 2 | 3 | 2023-04-21 Buy Stocks 4 | Assets:Stocks 10 VEUR @ 20 EUR 5 | Assets:Cash 6 | 7 | 2023-05-17 Sell Stocks 8 | Assets:Stocks -10 VEUR @ 25 EUR 9 | ;Income:Capital Gains 10 | Assets:Cash 11 | -------------------------------------------------------------------------------- /tests/trade-buy-sell-full-price.ledger: -------------------------------------------------------------------------------- 1 | ; a full trade, with purchase and sale of a commodity. 2 | 3 | 2023-04-21 Buy Stocks 4 | Assets:Stocks 10 VEUR @@ 20 EUR 5 | Assets:Cash 6 | 7 | 2023-05-17 Sell Stocks 8 | Assets:Stocks -10 VEUR @@ 25 EUR 9 | ;Income:Capital Gains 10 | Assets:Cash 11 | -------------------------------------------------------------------------------- /tests/trade-buy-sell-lot.ledger: -------------------------------------------------------------------------------- 1 | ; a full trade, with purchase and sale of a commodity. 2 | 3 | 2023-04-01 Buy Stocks 4 | Assets:Stocks 10 VEUR @ 20 EUR 5 | Assets:Cash 6 | 7 | 2023-05-01 Sell Stocks 8 | Assets:Stocks -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR 9 | ;Income:Capital Gains 10 | Assets:Cash 11 | -------------------------------------------------------------------------------- /wwwroot/server.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std/http/server.ts"; 2 | import { serveFile } from "https://deno.land/std/http/file_server.ts"; 3 | 4 | const server = serve({ port: 8000 }); 5 | 6 | for await (const req of server) { 7 | if (req.url === "/") { 8 | const html = await serveFile(req, "./index.html"); 9 | req.respond(html); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | 17 | # Added by cargo 18 | 19 | /target 20 | -------------------------------------------------------------------------------- /src/journalreader.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Journal Reader reads lines from the journal string and keeps track of the 3 | * line number. 4 | */ 5 | 6 | /// Reads the input text line by line and keeps track of the line number. 7 | pub(crate) struct JournalReader {} 8 | 9 | impl JournalReader { 10 | pub fn new() -> Self { 11 | JournalReader { } 12 | } 13 | } 14 | 15 | #[cfg(test)] 16 | mod tests { 17 | use super::JournalReader; 18 | 19 | #[test] 20 | fn test_instantiation() { 21 | let x = JournalReader::new(); 22 | 23 | // if no exceptions 24 | assert!(true); 25 | } 26 | } -------------------------------------------------------------------------------- /tests/utility-tests.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Tests for the library functionality useful for 3rd-party software. 3 | */ 4 | 5 | use ledger_rs_lib::{journal::Journal, amount::Quantity}; 6 | 7 | /// Verify the validity of a new transaction. 8 | #[test] 9 | fn test_xact_verification() { 10 | let src = r#"2023-05-23 Supermarket 11 | Expenses:Food 20 EUR 12 | Assets:Cash 13 | "#; 14 | let mut journal = Journal::new(); 15 | 16 | // Act 17 | ledger_rs_lib::parse_text(src, &mut journal); 18 | 19 | // Assert 20 | assert_eq!(1, journal.xacts.len()); 21 | let xact = &journal.xacts[0]; 22 | assert_eq!(Quantity::from(-20), xact.posts[1].amount.as_ref().unwrap().quantity); 23 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use ledger_rs_lib; 4 | 5 | /// Main entry point for the CLI. 6 | fn main() { 7 | println!("Hello, Ledger-rs!"); 8 | 9 | let args: Vec = env::args().collect(); 10 | println!("You requested {:?}", args); 11 | 12 | // let args = read_command_arguments(args); 13 | 14 | if !args.is_empty() { 15 | execute_command(args); 16 | } else { 17 | // repl. 18 | panic!("not implemented") 19 | } 20 | } 21 | 22 | // fn output_to_stdin(content: Vec) { 23 | // content.try_for_each(|s| writeln!(stdout, "{}", s)) 24 | // } 25 | 26 | fn execute_command(args: Vec) { 27 | let output = ledger_rs_lib::run(args); 28 | // output_to_stdin(output); 29 | println!("{:?}", output); 30 | } 31 | -------------------------------------------------------------------------------- /tests/accounts-tests.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Test operations with accounts 3 | */ 4 | 5 | use ledger_rs_lib::journal::Journal; 6 | 7 | /// Read a transaction and parse account tree 8 | #[test] 9 | fn test_creating_account_tree() { 10 | // Arrange 11 | let file_path = "tests/basic.ledger"; 12 | let mut journal = Journal::new(); 13 | 14 | // Act 15 | ledger_rs_lib::parse_file(file_path, &mut journal); 16 | 17 | // Assert 18 | let accounts = journal.master.flatten_account_tree(); 19 | assert_eq!(5, accounts.len()); 20 | let mut iterator = accounts.iter(); 21 | assert_eq!("", iterator.next().unwrap().name); 22 | assert_eq!("Assets", iterator.next().unwrap().name); 23 | assert_eq!("Cash", iterator.next().unwrap().name); 24 | assert_eq!("Expenses", iterator.next().unwrap().name); 25 | assert_eq!("Food", iterator.next().unwrap().name); 26 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ledger-rs-lib" 3 | description="A Ledger implementation in Rust" 4 | version = "0.7.0" 5 | edition = "2021" 6 | authors = ["Alen Šiljak "] 7 | license="AGPL-3.0" 8 | repository = "https://github.com/ledger-rs/ledger-rs-lib" 9 | categories = ["finance"] 10 | keywords = ["library", "finance", "wasm"] 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | # For WASM: 15 | # [target.'cfg(target_arch = "wasm32")'.lib] 16 | [lib] 17 | crate-type = ["cdylib", "rlib"] 18 | 19 | # [[bin]] 20 | # filename = "ledger-rs-cli" 21 | 22 | 23 | [dependencies] 24 | anyhow = "1.0.75" 25 | chrono = "0.4.31" 26 | env_logger = "0.10.0" 27 | log = "0.4.20" 28 | petgraph = "0.6.4" 29 | rust_decimal = "1.32.0" 30 | shell-words = "1.1.0" 31 | 32 | [target.'cfg(target_arch = "wasm32")'.dependencies] 33 | wasm-bindgen = "0.2.87" 34 | wasm-bindgen-test = "0.3.37" 35 | 36 | [dev-dependencies] 37 | test-log = "0.2.13" 38 | -------------------------------------------------------------------------------- /src/iterator.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Implemetation of the parser that returns an iterator over the results. 3 | * 4 | * An example on how to use Iterators. 5 | */ 6 | 7 | use crate::{directives::DirectiveType, xact::Xact}; 8 | 9 | 10 | #[derive(Debug)] 11 | /// A custom iterator type 12 | pub struct SimpleParserIter { 13 | // reader 14 | counter: u8, 15 | } 16 | 17 | impl SimpleParserIter { 18 | pub fn new() -> Self { 19 | SimpleParserIter { counter: 0 } 20 | } 21 | } 22 | 23 | impl Iterator for SimpleParserIter { 24 | type Item = DirectiveType; 25 | 26 | fn next(self: &mut SimpleParserIter) -> Option { 27 | // read the content and 28 | // parse the next directive 29 | 30 | self.counter += 1; 31 | if self.counter > 100 { 32 | return None; 33 | } 34 | 35 | Some(DirectiveType::Xact(Xact::default())) 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests { 41 | use super::SimpleParserIter; 42 | 43 | #[test] 44 | /// create a custom iterator of directives 45 | fn test_creating_custom_iterator() { 46 | let item = SimpleParserIter::new(); 47 | 48 | for x in item { 49 | println!("item: {:?}", x); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /tests/functional-tests.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Functional tests 3 | * 4 | */ 5 | 6 | use std::io::Cursor; 7 | 8 | use chrono::Local; 9 | use ledger_rs_lib::{journal::Journal, amount::{Amount, Quantity}}; 10 | 11 | /// TODO: complete the functionality and the test 12 | //#[test] 13 | fn test_price_reading() { 14 | // Arrange 15 | let mut j = Journal::new(); 16 | let text = r#" 17 | P 2022-03-03 13:00:00 EUR 1.12 USD 18 | "#; 19 | // Act 20 | ledger_rs_lib::parse_text(text, &mut j); 21 | 22 | // Assert 23 | let eur = j.commodity_pool.find("EUR").unwrap(); 24 | let usd = j.commodity_pool.find("USD").unwrap(); 25 | let three_eur = Amount::new(Quantity::from(3), Some(eur)); 26 | let exch_rate = Amount::new(Quantity::from(1.5), Some(usd)); 27 | 28 | let (cost_breakdown, price) = j.commodity_pool.exchange(&three_eur, &exch_rate, true, Local::now().naive_local()); 29 | assert!(price.is_some()); 30 | todo!("check that the price was parsed") 31 | } 32 | 33 | fn test_commodity_conversion_with_price() { 34 | let input = r#" 35 | P 2022-03-03 13:00:00 EUR 1.12 USD 36 | 37 | 2023-01-10 Vacation 38 | Expenses:Vacation 20 EUR 39 | Assets:Cash 40 | "#; 41 | let mut journal = Journal::new(); 42 | journal.read(Cursor::new(input)); 43 | 44 | todo!("run a report with -X USD") 45 | 46 | // assert 47 | 48 | } -------------------------------------------------------------------------------- /src/value.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * value.cc 3 | * 4 | */ 5 | 6 | use chrono::NaiveDateTime; 7 | 8 | use crate::{ 9 | commodity::{Commodity, PricePoint}, 10 | pool::CommodityPool, 11 | }; 12 | 13 | /// commodities = comma-separated list of symbols. Also can contain `=`. 14 | /// Returns value_t. 15 | pub(crate) fn exchange_commodities( 16 | commodities: &str, 17 | add_prices: bool, 18 | moment: &NaiveDateTime, 19 | pool: &mut CommodityPool, 20 | ) { 21 | if !commodities.contains(',') && !commodities.contains('=') { 22 | // only one commodity. 23 | let cdty = pool.find_or_create(commodities, None); 24 | todo!("fix") 25 | // return value(moment, cdty, &pool); 26 | } 27 | 28 | todo!("complete") 29 | } 30 | 31 | fn value(moment: &NaiveDateTime, in_terms_of: Option<&Commodity>, pool: &CommodityPool) { 32 | // &Commodity 33 | 34 | amount_value(moment, in_terms_of, pool) 35 | 36 | // TODO: handle balance 37 | } 38 | 39 | /// amount.cc 40 | /// optional 41 | /// amount_t::value(const datetime_t& moment, 42 | /// const commodity_t * in_terms_of) const 43 | fn amount_value(moment: &NaiveDateTime, in_terms_of: Option<&Commodity>, pool: &CommodityPool) { 44 | // if quantity 45 | // if has_commodity() && (in_terms_of || ! commodity().has_flags(COMMODITY_PRIMARY)) 46 | let point: Option; 47 | // let commodity 48 | 49 | // if has_annotation && annotation().price 50 | 51 | // if ! point 52 | // commodity().find_price(comm, moment) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use crate::{journal::Journal, parse_file}; 58 | 59 | //#[test] 60 | fn test_exchange() { 61 | let mut journal = Journal::new(); 62 | parse_file("tests/commodity_exchange.ledger", &mut journal); 63 | 64 | // act 65 | todo!("run bal -X USD") 66 | 67 | // assert 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/post.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Posting 3 | */ 4 | 5 | use crate::{ 6 | account::Account, 7 | amount::Amount, 8 | xact::Xact, 9 | }; 10 | 11 | #[derive(Debug, PartialEq)] 12 | pub struct Post { 13 | /// Pointer to the Account. 14 | pub account: *const Account, 15 | // pub account_index: AccountIndex, 16 | /// Pointer to the Xact. 17 | pub xact_ptr: *const Xact, 18 | // pub xact_index: XactIndex, 19 | 20 | pub amount: Option, 21 | pub cost: Option, 22 | // given_cost 23 | // assigned_amount 24 | // checkin 25 | // checkout 26 | pub note: Option, 27 | } 28 | 29 | impl Post { 30 | /// Creates a Post from post tokens. 31 | pub fn new( 32 | account: *const Account, 33 | xact_ptr: *const Xact, 34 | amount: Option, 35 | cost: Option, 36 | note: Option<&str>, 37 | ) -> Self { 38 | Self { 39 | account, 40 | xact_ptr, 41 | amount, 42 | cost, 43 | note: match note { 44 | Some(content) => Some(content.to_owned()), 45 | None => None, 46 | }, 47 | } 48 | } 49 | 50 | pub fn add_note(&mut self, note: &str) { 51 | self.note = Some(note.into()); 52 | } 53 | } 54 | 55 | impl Default for Post { 56 | fn default() -> Self { 57 | Self { 58 | account: std::ptr::null(), 59 | xact_ptr: std::ptr::null(), 60 | amount: Default::default(), 61 | cost: Default::default(), 62 | note: Default::default(), 63 | } 64 | } 65 | } 66 | 67 | #[cfg(test)] 68 | mod tests { 69 | use crate::account::Account; 70 | 71 | use super::Post; 72 | 73 | #[test] 74 | fn test_pointers() { 75 | const ACCT_NAME: &str = "Some Account"; 76 | let mut post = Post::default(); 77 | 78 | assert_eq!(std::ptr::null(), post.account); 79 | 80 | // Assign account. 81 | let acct = Account::new(ACCT_NAME); 82 | post.account = &acct as *const Account; 83 | 84 | unsafe { 85 | // println!("account is {:?}", *post.account); 86 | assert_eq!(ACCT_NAME, (*post.account).name); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/commodity.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Commodity definition 3 | * 4 | * commodity.cc 5 | */ 6 | 7 | use chrono::NaiveDateTime; 8 | 9 | use crate::{amount::Amount, pool::CommodityIndex}; 10 | 11 | #[derive(Debug, PartialEq)] 12 | pub struct Commodity { 13 | pub symbol: String, 14 | /// Index in the commodity graph. 15 | pub graph_index: Option, 16 | // precision 17 | pub name: Option, 18 | pub note: Option, 19 | // smaller: Option 20 | // larger: Option 21 | // value_expr: Option<> 22 | 23 | // commodity_pool 24 | // annotated_commodity 25 | // parent: *const CommodityPool, 26 | // qualified_symbol: Option, 27 | pub annotated: bool, 28 | } 29 | 30 | impl Commodity { 31 | pub fn new(symbol: &str) -> Self { 32 | Self { 33 | symbol: symbol.to_owned(), 34 | graph_index: None, 35 | name: None, 36 | note: None, 37 | annotated: false, 38 | } 39 | } 40 | } 41 | 42 | /// commodity.cc 43 | /// 44 | pub(crate) fn find_price(commodity: &Commodity, moment: NaiveDateTime, oldest: NaiveDateTime) { 45 | // if commodity 46 | let target = commodity; 47 | 48 | // memoized_price_entry entry(moment, oldest, commodity) 49 | 50 | // memoized_price_map map > 51 | // commodity.price_map.find(entry) 52 | 53 | todo!() 54 | } 55 | 56 | pub(crate) fn from_ptr<'a>(ptr: *const Commodity) -> &'a Commodity { 57 | unsafe { 58 | &*ptr 59 | } 60 | } 61 | 62 | #[derive(Debug, PartialEq, Eq)] 63 | pub(crate) struct PricePoint { 64 | pub when: NaiveDateTime, 65 | pub price: Amount, 66 | } 67 | 68 | impl PricePoint { 69 | pub fn new(when: NaiveDateTime, price: Amount) -> Self { 70 | Self { when, price } 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use super::Commodity; 77 | 78 | #[test] 79 | fn test_comparison() { 80 | let c1 = Commodity::new("EUR"); 81 | let c2 = Commodity::new("EUR"); 82 | 83 | assert!(c1 == c2); 84 | } 85 | 86 | #[test] 87 | fn test_comparison_ne() { 88 | let c1 = Commodity::new("EUR"); 89 | let c2 = Commodity::new("GBP"); 90 | 91 | assert!(c1 != c2); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/annotate.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Types for annotating commodities 3 | * 4 | * annotate.h 5 | * 6 | * Annotations normally represent the Lot information: date, price. 7 | */ 8 | 9 | use anyhow::Error; 10 | use chrono::NaiveDate; 11 | 12 | use crate::{amount::{Amount, Quantity}, parser::ISO_DATE_FORMAT, journal::Journal}; 13 | 14 | pub struct Annotation { 15 | /// Price per unit. The {} value in the Lot syntax. 16 | pub price: Option, 17 | /// The [] date in the Lot syntax. 18 | pub date: Option, 19 | // pub tag: Option, 20 | // pub value_expr: 21 | } 22 | 23 | impl Annotation { 24 | pub fn new(price: Option, date: Option) -> Self { 25 | // todo: add support for tags 26 | Self { 27 | price, 28 | date, 29 | // tag: None, 30 | } 31 | } 32 | 33 | pub fn parse(date: &str, quantity: &str, commodity_symbol: &str, journal: &mut Journal) -> Result { 34 | // parse amount 35 | let commodity = journal.commodity_pool.find_or_create(commodity_symbol, None); 36 | 37 | let price = if let Some(quantity) = Quantity::from_str(quantity) { 38 | Some(Amount::new(quantity, Some(commodity))) 39 | } else { 40 | None 41 | }; 42 | 43 | let result = Self { 44 | price: price, 45 | date: match date.is_empty() { 46 | true => None, 47 | false => { 48 | let d = 49 | NaiveDate::parse_from_str(date, ISO_DATE_FORMAT).expect("successful parse"); 50 | Some(d) 51 | } 52 | }, 53 | }; 54 | 55 | Ok(result) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use crate::journal::Journal; 62 | 63 | use super::Annotation; 64 | 65 | #[test] 66 | fn test_parsing() { 67 | let mut journal = Journal::new(); 68 | let expected_symbol = "EUR"; 69 | 70 | let actual = Annotation::parse("2023-01-10", "20", expected_symbol, &mut journal).unwrap(); 71 | 72 | assert_eq!("2023-01-10", actual.date.unwrap().to_string()); 73 | assert_eq!(actual.price.unwrap().quantity, 20.into()); 74 | 75 | let symbol = actual.price.unwrap().get_commodity().unwrap().symbol.to_owned(); 76 | assert_eq!(expected_symbol, symbol); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/ext-report-tests.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * External reports tests 3 | */ 4 | 5 | #[test] 6 | fn test_balance_minimal() { 7 | // Act 8 | let actual = ledger_rs_lib::run_command("b -f tests/minimal.ledger"); 9 | 10 | // Assert 11 | assert!(!actual.is_empty()); 12 | assert_eq!(3, actual.len()); 13 | assert_eq!("Account has balance 0", actual[0]); 14 | assert_eq!("Account Assets has balance -20", actual[1]); 15 | assert_eq!("Account Expenses has balance 20", actual[2]) 16 | } 17 | 18 | #[test] 19 | fn test_balance_basic() { 20 | let actual = ledger_rs_lib::run_command("b -f tests/basic.ledger"); 21 | 22 | assert!(!actual.is_empty()); 23 | assert_eq!(5, actual.len()); 24 | assert_eq!("Account has balance 0 EUR", actual[0]); 25 | assert_eq!("Account Assets has balance -20 EUR", actual[1]); 26 | assert_eq!("Account Assets:Cash has balance -20 EUR", actual[2]); 27 | assert_eq!("Account Expenses has balance 20 EUR", actual[3]); 28 | assert_eq!("Account Expenses:Food has balance 20 EUR", actual[4]); 29 | } 30 | 31 | #[test] 32 | fn test_accounts() { 33 | // Act 34 | let actual = ledger_rs_lib::run_command("accounts -f tests/minimal.ledger"); 35 | 36 | assert!(!actual.is_empty()); 37 | let expected = vec!["", "Assets", "Expenses"]; 38 | assert_eq!(expected, actual); 39 | } 40 | 41 | /// TODO: enable test when the functionality is implemented 42 | //#[test] 43 | fn test_account_filter() { 44 | // Act 45 | let actual = ledger_rs_lib::run_command("accounts Asset -f tests/minimal.ledger"); 46 | 47 | assert!(!actual.is_empty()); 48 | // Only Assets should be returned. 49 | let expected = vec!["Assets"]; 50 | assert_eq!(expected, actual); 51 | } 52 | 53 | /// Test Balance report, without any parameters. 54 | /// Just two accounts. 55 | #[test] 56 | fn test_balance_plain() { 57 | // TODO: This would be the Ledger output: 58 | let expected = r#"Account Balances 59 | -20 Assets 60 | 20 Expenses 61 | "#; 62 | 63 | let actual = ledger_rs_lib::run_command("b -f tests/basic.ledger"); 64 | 65 | assert!(!actual.is_empty()); 66 | assert_eq!(5, actual.len()); 67 | assert_eq!("Account has balance 0 EUR", actual[0]); 68 | assert_eq!("Account Assets has balance -20 EUR", actual[1]); 69 | assert_eq!("Account Assets:Cash has balance -20 EUR", actual[2]); 70 | assert_eq!("Account Expenses has balance 20 EUR", actual[3]); 71 | assert_eq!("Account Expenses:Food has balance 20 EUR", actual[4]); 72 | } 73 | 74 | /// TODO: Enable when implemented 75 | /// Display account balances with multiple currencies. 76 | // #[test] 77 | fn test_balance_multiple_currencies() { 78 | let actual = ledger_rs_lib::run_command("b -f tests/multiple_currencies.ledger"); 79 | 80 | assert!(false); 81 | // assert_eq!("Account Assets:Cash has balance -20 "); 82 | } -------------------------------------------------------------------------------- /src/reader.rs: -------------------------------------------------------------------------------- 1 | //! The Journal Reader. 2 | //! Reads directives from the given source and returns them as an iterator. 3 | //! 4 | //! An attempt to replace the Parser::parse() method. 5 | 6 | use std::io::{BufRead, BufReader, Cursor, Read}; 7 | 8 | use crate::directives::DirectiveType; 9 | 10 | pub fn create_reader(source: T) -> DirectiveIter { 11 | let iter = DirectiveIter::new(source); 12 | iter 13 | } 14 | 15 | pub fn create_str_reader(source: &str) -> DirectiveIter> { 16 | let cursor = Cursor::new(source); 17 | let iter: DirectiveIter> = DirectiveIter::new(cursor); 18 | iter 19 | } 20 | 21 | pub struct DirectiveIter { 22 | // source: T, 23 | reader: BufReader, 24 | buffer: String, 25 | } 26 | 27 | impl DirectiveIter { 28 | pub fn new(source: T) -> Self { 29 | let reader = BufReader::new(source); 30 | 31 | Self { 32 | // source, 33 | reader, 34 | buffer: String::new(), 35 | } 36 | } 37 | } 38 | 39 | impl Iterator for DirectiveIter { 40 | type Item = DirectiveType; 41 | 42 | fn next(self: &mut DirectiveIter) -> Option { 43 | // Read lines and recognise the directive. 44 | match self.reader.read_line(&mut self.buffer) { 45 | Err(error) => panic!("Error: {:?}", error), 46 | Ok(0) => { 47 | // end of file 48 | return None; 49 | } 50 | Ok(result) => { 51 | // TODO: Recognise directive, if any. 52 | 53 | // TODO: Read additional lines, if needed (like for Xact). 54 | // TODO: Parse and return the directive. 55 | 56 | // return Some(DirectiveType::Xact(Xact::default())) 57 | println!("Result: {:?}", result); 58 | return Some(DirectiveType::Comment); 59 | } 60 | }; 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use std::io::Cursor; 67 | 68 | use crate::reader::create_str_reader; 69 | 70 | #[test] 71 | fn basic_test() { 72 | let content = "; blah blah"; 73 | 74 | let output = create_str_reader::>(content); 75 | 76 | let mut counter = 0; 77 | for item in output { 78 | println!("item: {:?}", item); 79 | counter += 1; 80 | } 81 | assert_eq!(1, counter); 82 | } 83 | 84 | #[test] 85 | fn one_xact_test() { 86 | let content = r#"2023-03-04 Shop 87 | Expenses:Clothing 20 EUR 88 | Assets:Credit Card 89 | "#; 90 | //let iter = DirectiveIter::new(); 91 | let iter = create_str_reader::>(content); 92 | 93 | // iter.count() 94 | for x in iter { 95 | println!("Directive: {:?}", x); 96 | // assert_eq!(DirectiveType::Xact()) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/poc.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Proof-of-concept and ideas 3 | * 4 | * References between model entities 5 | * Testing Rc<> and RefCell<>, raw pointers, and other concepts to find 6 | * an optimal data structure. 7 | */ 8 | 9 | use core::panic; 10 | use std::{cell::RefCell, collections::HashMap, rc::Rc}; 11 | 12 | use ledger_rs_lib::commodity::Commodity; 13 | 14 | #[derive(Debug, PartialEq)] 15 | struct Account { 16 | pub name: String, 17 | // posts: Vec<&'a Post<'a>>, 18 | pub parent: Option>>, 19 | pub children: Vec>>, 20 | // posts: Vec Self { 25 | // Self { parent: None, children: vec![] } 26 | Self { 27 | name: name.to_owned(), 28 | parent: None, 29 | children: vec![], 30 | } 31 | } 32 | 33 | pub fn get_children(&self) -> &Vec>> { 34 | &self.children 35 | } 36 | } 37 | 38 | /// Using references with Rc> 39 | #[test] 40 | fn test_ref_w_rc() { 41 | // arrange 42 | let root = Rc::new(RefCell::new(Account::new("master"))); 43 | let mut accounts_map = HashMap::new(); 44 | { 45 | root.borrow_mut().parent = None; 46 | accounts_map.insert("master", root.clone()); 47 | 48 | // add assets to the map 49 | let assets = Rc::new(RefCell::new(Account::new("assets"))); 50 | assets.borrow_mut().parent = Some(root.clone()); 51 | accounts_map.insert("assets", assets.clone()); 52 | // add assets to root's children 53 | root.borrow_mut().children.push(assets.clone()); 54 | 55 | // add bank to the accounts map 56 | let bank = Rc::new(RefCell::new(Account::new("bank"))); 57 | bank.borrow_mut().parent = Some(assets.clone()); 58 | accounts_map.insert("bank", bank.clone()); 59 | // add bank to assets' children 60 | assets.borrow_mut().children.push(bank.clone()); 61 | } 62 | 63 | // act 64 | let bank = accounts_map.get("bank").unwrap(); 65 | let Some(assets) = &bank.borrow().parent else {panic!("yo")}; 66 | let Some(master) = &assets.borrow().parent else {panic!("yo")}; 67 | 68 | // assert 69 | assert_eq!("master", master.borrow().name); 70 | assert_eq!(1, master.borrow().children.len()); 71 | 72 | let master = accounts_map.get("master").unwrap(); 73 | let binding = master.borrow(); 74 | let binding = binding.get_children().get(0).unwrap().borrow(); 75 | let binding = binding.get_children().get(0).unwrap().borrow(); 76 | let grandchild_name = binding.name.as_str(); 77 | assert_eq!("bank", grandchild_name); 78 | 79 | // but if we copy the end value, then we don't need to break it down. 80 | let name = master 81 | .borrow() 82 | .children 83 | .get(0) 84 | .unwrap() 85 | .borrow() 86 | .name 87 | .to_owned(); 88 | assert_eq!("assets", name); 89 | 90 | let name = master 91 | .borrow() 92 | .children 93 | .get(0) 94 | .unwrap() 95 | .borrow() 96 | .children 97 | .get(0) 98 | .unwrap() 99 | .borrow() 100 | .name 101 | .to_owned(); 102 | assert_eq!("bank", name); 103 | } 104 | 105 | /// Pointer gymnastics. Pass pointers around and convert to references 106 | /// when needed. 107 | /// In a structure where the data is only populated and never deleted, 108 | /// this should be safe. 109 | #[test] 110 | fn test_pointer_passing() { 111 | // alchemy? 112 | // arrange 113 | const CURRENCY: &str = "EUR"; 114 | let mut container = HashMap::new(); 115 | let eur = Commodity::new(CURRENCY); 116 | let eur_ptr = &eur as *const Commodity; 117 | let eur_mut_ptr = eur_ptr as *mut Commodity; 118 | 119 | // act 120 | container.insert(eur.symbol.to_owned(), eur); 121 | 122 | // assert 123 | let expected_ref: &Commodity; 124 | let expected_mut_ref: &mut Commodity; 125 | unsafe { 126 | expected_ref = &*eur_ptr; 127 | expected_mut_ref = &mut*eur_mut_ptr; 128 | } 129 | assert_eq!(CURRENCY, expected_ref.symbol); 130 | assert_eq!(CURRENCY, expected_mut_ref.symbol); 131 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ledger-rs-lib 2 | Ledger-cli functionality implemented in Rust 3 | 4 | A brand new attempt at Ledger, starting from blank slate. 5 | 6 | [Early Work In Progress!] 7 | 8 | ![](https://img.shields.io/crates/v/ledger-rs-lib?style=plastic) 9 | 10 | # Introduction 11 | 12 | This library is aiming to implement the plain-text accounting principles, as demonstrated by Ledger-cli, Hledger, Beancount, etc. The base for development is [Ledger-cli](https://github.com/ledger/ledger/), from which the underlying model and concepts were applied. 13 | 14 | Part of the [Rusty Ledger](https://github.com/ledger-rs/) project. 15 | 16 | # Current State 17 | 18 | While still early work-in-progress, the basic functionality is there. Simple Ledger journal files with transactions are being parsed. The basic functionality allows retrieving the parsed entities (Transactions, Posts, Accounts, Cost, Amounts), which allow the very basic reports to be provided. 19 | 20 | The functionality will now be expanded. The direction will be tracked through work items (issues) in the repository. 21 | 22 | Any kind of contribution is wellcome, from using the library and providing feedback, to participating in discussions and submitting pull-requests with implementations and improvements. 23 | 24 | # Background 25 | 26 | After a few attempts at rewriting (pieces of) Ledger, the following conclusions seemed to crystalize: 27 | 28 | 1. One package 29 | While trying to create just a parser, it became clear that there is no need to separate the parser from the rest of the application (model, reports). They can coexist in the same crate. The parser can still be used independently of other functionality. 30 | The model and the reports are easier to include in the same crate from the beginning. These can be separated if ever needed. 31 | 32 | 1. Clean Rust 33 | Trying to convert the C++ structure into Rust just doesn't make much sense. The pointer arithmetic in the original Ledger seems next to impossible to create and maintain in Rust. The references and lifetimes make development a nightmare in Rust. A clean start, applying idiomatic Rust concepts should apply. 34 | 35 | 1. Start minimal 36 | Ledger really contains a lot of features. Start from a minimal working version and expand from there. 37 | 38 | 1. Define clear goals 39 | Trying to rewrite the whole application seems a neverending task. Rather, define clear and small, attainable goals and implement them. 40 | 41 | # Goals 42 | 43 | The goals beyond the initial requirements, which served as a proof-of-concept, will be tracked as issues in the source repository. 44 | 45 | ## Initial functional requirements 46 | 47 | The immediate goals are: 48 | 49 | - [x] Parse a minimal working transaction sample 50 | - [x] Create a minimal working reports: 51 | - [x] Accounts 52 | - [x] Balance 53 | - [ ] Compile a working WASM version 54 | - [ ] that interacts with JavaScript 55 | - [x] that works in console 56 | 57 | These should provide initial insights into Ledger's inner workings and concepts. 58 | 59 | ## Non-Functional Requirements 60 | 61 | - Fast execution 62 | - Test coverage 63 | 64 | ## Experimental Goals 65 | 66 | - Permanent storage (sqlite?) as a base for the reporting layer 67 | 68 | # WASM/WASI 69 | 70 | ## WASI 71 | 72 | To compile to Wasm for execution in WASI, run 73 | ``` 74 | cargo build --target wasm32-wasi 75 | ``` 76 | then go to the `target/wasm32-wasi/debug` folder and run 77 | ``` 78 | wasmer run --dir tests target/wasm32-wasi/debug/ledger-rs-lib.wasm -- -f tests/minimal.ledger 79 | ``` 80 | 81 | This will run the CLI's main() method. With WASI, the filesystem access permission has to be given explicitly. This is done with `--dir` argument. 82 | Note that Wasmer is using `--` to separate application switches like `-f`. 83 | 84 | You need to have the prerequisites install - the compilation target (wasm32-wasi) and a Wasm runtime (i.e. wasmer). 85 | 86 | ## WASM 87 | 88 | The library can be compiled into WASM for use in web apps. 89 | 90 | ```shell 91 | cargo install wasm-pack 92 | 93 | wasm-pack build --target web 94 | ``` 95 | 96 | ### Demo 97 | 98 | The folder `wwwroot` contains the code that uses the wasm. 99 | Serve with a web server. I.e. using Deno's file server: 100 | ``` 101 | deno install --allow-net --allow-read https://deno.land/std/http/file_server.ts 102 | 103 | file_server wwwroot 104 | ``` 105 | Add the deno plugin location to path. 106 | 107 | 108 | # Documentation 109 | 110 | - [Ledger for Developers](https://ledger-cli.org/doc/ledger3.html#Ledger-for-Developers) 111 | - [Journal Format](https://ledger-cli.org/doc/ledger3.html#Journal-Format) 112 | - Ledger source code [repo](https://github.com/ledger/ledger/) 113 | 114 | ## Journal Format 115 | 116 | I will try to document the Ledger's Journal format in a [syntax diagram](http://www.plantuml.com/plantuml/duml/LL9HhjCm4FptAHRpGLAv5o2gbAWLGeYF88fKgOgGIKnnIMpaR52hqBjm6Ux5RkAsBp_jxkpCsBDEtgCEQBwvxm8jjWO-ckPamfiUFlWXEDt2EnywZUA7qOq9KBJ6mR-_jZthdogIrw67Ny6VJOr2t6KR6BU-wup3cuBne6kyPK8aA-0ITZOGs_usi4e58yG_l9-EK2-5fU-H0FvZUQGGUQVHA3WMm-KhbnNLSYNX3yXNafj49bB1rZV4agbC6IlrrKpCw5zbWet9hQXhFpXamuwBYYldF6gqtaqI8Ywbd8Nbrfqun6onC1iJ-LQgUvzIWAABd4-3TcZn6XrzGpLvFiya3cKOILwQyCLPB8EjESjDffIIHZpR4xjzJ6WqHpzA5HSaAy8oiPrZJanIVnwwJCpDXgnoeiytIpD1inbSe2BcdaRPjEVNSTlycyjK0PeBGjmBUoyVUOBMJsW3idnSyxYt7R_CSosFhP1XRbp3N-X_). This is the Extended Backus–Naur Form (EBNF) diagram source. 117 | 118 | ![diagram](http://www.plantuml.com/plantuml/dsvg/LL9HhjCm4FptAHRpGLAv5o2gbAWLGeYF88fKgOgGIKnnIMpaR52hqBjm6Ux5RkAsBp_jxkpCsBDEtgCEQBwvxm8jjWO-ckPamfiUFlWXEDt2EnywZUA7qOq9KBJ6mR-_jZthdogIrw67Ny6VJOr2t6KR6BU-wup3cuBne6kyPK8aA-0ITZOGs_usi4e58yG_l9-EK2-5fU-H0FvZUQGGUQVHA3WMm-KhbnNLSYNX3yXNafj49bB1rZV4agbC6IlrrKpCw5zbWet9hQXhFpXamuwBYYldF6gqtaqI8Ywbd8Nbrfqun6onC1iJ-LQgUvzIWAABd4-3TcZn6XrzGpLvFiya3cKOILwQyCLPB8EjESjDffIIHZpR4xjzJ6WqHpzA5HSaAy8oiPrZJanIVnwwJCpDXgnoeiytIpD1inbSe2BcdaRPjEVNSTlycyjK0PeBGjmBUoyVUOBMJsW3idnSyxYt7R_CSosFhP1XRbp3N-X_) 119 | 120 | The original specs from Ledger's documentation: 121 | 122 | Transaction header 123 | ``` 124 | DATE[=EDATE] [*|!] [(CODE)] DESC 125 | ``` 126 | 127 | Posting 128 | ``` 129 | ACCOUNT AMOUNT [; NOTE] 130 | ``` 131 | 132 | Price 133 | ``` 134 | P DATE SYMBOL PRICE 135 | ``` 136 | 137 | ## Lots 138 | 139 | The price of a commodity is stored in commodity annotations (`amount.h`). 140 | 141 | `annotate_commodity(amount_t price, [datetime_t date, string tag])` 142 | -------------------------------------------------------------------------------- /src/journal.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Journal 3 | * The main model object. The journal files are parsed into the Journal structure. 4 | * Provides methods for fetching and iterating over the contained elements 5 | * (transactions, posts, accounts...). 6 | */ 7 | use std::io::Read; 8 | 9 | use crate::{ 10 | account::Account, 11 | commodity::Commodity, 12 | parser, 13 | pool::{CommodityIndex, CommodityPool}, 14 | post::Post, 15 | xact::Xact, 16 | }; 17 | 18 | // pub type XactIndex = usize; 19 | 20 | pub struct Journal { 21 | pub master: Box, 22 | 23 | pub commodity_pool: CommodityPool, 24 | pub xacts: Vec, 25 | } 26 | 27 | impl Journal { 28 | pub fn new() -> Self { 29 | Self { 30 | master: Box::new(Account::new("")), 31 | 32 | commodity_pool: CommodityPool::new(), 33 | xacts: vec![], 34 | // sources: Vec 35 | } 36 | } 37 | 38 | pub fn add_xact(&mut self, xact: Xact) -> &Xact { 39 | self.xacts.push(xact); 40 | //self.xacts.len() - 1 41 | self.xacts.last().unwrap() 42 | } 43 | 44 | pub fn all_posts(&self) -> Vec<&Post> { 45 | self.xacts.iter().flat_map(|x| x.posts.iter()).collect() 46 | } 47 | 48 | pub fn get_account(&self, acct_ptr: *const Account) -> &Account { 49 | unsafe { &*acct_ptr } 50 | } 51 | 52 | pub fn get_account_mut(&self, acct_ptr: *const Account) -> &mut Account { 53 | unsafe { 54 | let mut_ptr = acct_ptr as *mut Account; 55 | &mut *mut_ptr 56 | } 57 | } 58 | 59 | pub fn get_commodity(&self, index: CommodityIndex) -> &Commodity { 60 | self.commodity_pool.get_by_index(index) 61 | } 62 | 63 | /// Called to create an account during Post parsing. 64 | /// 65 | /// account_t * journal_t::register_account(const string& name, post_t * post, 66 | /// account_t * master_account) 67 | /// 68 | pub fn register_account(&mut self, name: &str) -> Option<*const Account> { 69 | if name.is_empty() { 70 | panic!("Invalid account name {:?}", name); 71 | } 72 | 73 | // todo: expand_aliases 74 | // account_t * result = expand_aliases(name); 75 | 76 | let master_account: &mut Account = &mut self.master; 77 | 78 | // Create the account object and associate it with the journal; this 79 | // is registering the account. 80 | 81 | let Some(account_ptr) = master_account.find_or_create(name, true) 82 | else { return None }; 83 | 84 | // todo: add any validity checks here. 85 | 86 | let account = self.get_account(account_ptr); 87 | Some(account) 88 | } 89 | 90 | pub fn find_account(&self, name: &str) -> Option<&Account> { 91 | self.master.find_account(name) 92 | } 93 | 94 | /// Read journal source (file or string). 95 | /// 96 | /// std::size_t journal_t::read(parse_context_stack_t& context) 97 | /// 98 | /// returns number of transactions parsed 99 | pub fn read(&mut self, source: T) -> usize { 100 | // read_textual 101 | parser::read_into_journal(source, self); 102 | 103 | self.xacts.len() 104 | } 105 | } 106 | 107 | #[cfg(test)] 108 | mod tests { 109 | use core::panic; 110 | use std::{io::Cursor, ptr::addr_of}; 111 | 112 | use super::Journal; 113 | use crate::{account::Account, parse_file}; 114 | 115 | #[test] 116 | fn test_add_account() { 117 | const ACCT_NAME: &str = "Assets"; 118 | let mut journal = Journal::new(); 119 | let ptr = journal.register_account(ACCT_NAME).unwrap(); 120 | let actual = journal.get_account(ptr); 121 | 122 | // There is master account 123 | // assert_eq!(1, i); 124 | assert_eq!(ACCT_NAME, actual.name); 125 | } 126 | 127 | #[test] 128 | fn test_add_account_to_master() { 129 | let mut journal = Journal::new(); 130 | const NAME: &str = "Assets"; 131 | 132 | let Some(ptr) = journal.register_account(NAME) else {panic!("unexpected")}; 133 | let actual = journal.get_account(ptr); 134 | 135 | assert_eq!(&*journal.master as *const Account, actual.parent); 136 | } 137 | 138 | #[test] 139 | fn test_find_account() { 140 | let mut journal = Journal::new(); 141 | parse_file("tests/basic.ledger", &mut journal); 142 | 143 | let actual = journal.find_account("Assets:Cash"); 144 | 145 | assert!(actual.is_some()); 146 | } 147 | 148 | #[test] 149 | fn test_register_account() { 150 | const NAME: &str = "Assets:Investments:Broker"; 151 | let mut j = Journal::new(); 152 | 153 | // act 154 | let new_acct = j.register_account(NAME).unwrap(); 155 | let actual = j.get_account_mut(new_acct); 156 | 157 | let journal = &j; 158 | 159 | // Asserts 160 | assert_eq!(4, journal.master.flatten_account_tree().len()); 161 | assert_eq!(NAME, actual.fullname()); 162 | 163 | // tree structure 164 | let master = &journal.master; 165 | assert_eq!("", master.name); 166 | 167 | let assets = master.find_account("Assets").unwrap(); 168 | // let assets = journal.get_account(assets_ptr); 169 | assert_eq!("Assets", assets.name); 170 | assert_eq!(&*journal.master as *const Account, assets.parent); 171 | 172 | let inv = assets.find_account("Investments").unwrap(); 173 | // let inv = journal.get_account_mut(inv_ix); 174 | assert_eq!("Investments", inv.name); 175 | assert_eq!(addr_of!(*assets), inv.parent); 176 | 177 | let broker = inv.find_account("Broker").unwrap(); 178 | // let broker = journal.get_account(broker_ix); 179 | assert_eq!("Broker", broker.name); 180 | assert_eq!(inv as *const Account, broker.parent); 181 | } 182 | 183 | /// The master account needs to be created in the Journal automatically. 184 | #[test] 185 | fn test_master_gets_created() { 186 | let j = Journal::new(); 187 | 188 | let actual = j.master; 189 | 190 | assert_eq!("", actual.name); 191 | } 192 | 193 | #[test] 194 | fn test_read() { 195 | let src = r#"2023-05-01 Test 196 | Expenses:Food 20 EUR 197 | Assets:Cash 198 | "#; 199 | let mut j = Journal::new(); 200 | 201 | // Act 202 | let num_xact = j.read(Cursor::new(src)); 203 | 204 | // Assert 205 | assert_eq!(1, num_xact); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug unit tests in library 'ledger-rs-lib'", 11 | "cargo": { 12 | "args": [ 13 | "test", 14 | "--no-run", 15 | "--lib", 16 | "--package=ledger-rs-lib" 17 | ], 18 | "filter": { 19 | "name": "ledger-rs-lib", 20 | "kind": "lib" 21 | } 22 | }, 23 | "args": [], 24 | "cwd": "${workspaceFolder}" 25 | }, 26 | { 27 | "type": "lldb", 28 | "request": "launch", 29 | "name": "Debug executable 'ledger-rs-lib'", 30 | "cargo": { 31 | "args": [ 32 | "build", 33 | "--bin=ledger-rs-lib", 34 | "--package=ledger-rs-lib" 35 | ], 36 | "filter": { 37 | "name": "ledger-rs-lib", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | }, 44 | { 45 | "type": "lldb", 46 | "request": "launch", 47 | "name": "Debug unit tests in executable 'ledger-rs-lib'", 48 | "cargo": { 49 | "args": [ 50 | "test", 51 | "--no-run", 52 | "--bin=ledger-rs-lib", 53 | "--package=ledger-rs-lib" 54 | ], 55 | "filter": { 56 | "name": "ledger-rs-lib", 57 | "kind": "bin" 58 | } 59 | }, 60 | "args": [], 61 | "cwd": "${workspaceFolder}" 62 | }, 63 | { 64 | "type": "lldb", 65 | "request": "launch", 66 | "name": "Debug integration test 'accounts-tests'", 67 | "cargo": { 68 | "args": [ 69 | "test", 70 | "--no-run", 71 | "--test=accounts-tests", 72 | "--package=ledger-rs-lib" 73 | ], 74 | "filter": { 75 | "name": "accounts-tests", 76 | "kind": "test" 77 | } 78 | }, 79 | "args": [], 80 | "cwd": "${workspaceFolder}" 81 | }, 82 | { 83 | "type": "lldb", 84 | "request": "launch", 85 | "name": "Debug integration test 'ext-parser-tests'", 86 | "cargo": { 87 | "args": [ 88 | "test", 89 | "--no-run", 90 | "--test=ext-parser-tests", 91 | "--package=ledger-rs-lib" 92 | ], 93 | "filter": { 94 | "name": "ext-parser-tests", 95 | "kind": "test" 96 | } 97 | }, 98 | "args": [], 99 | "cwd": "${workspaceFolder}" 100 | }, 101 | { 102 | "type": "lldb", 103 | "request": "launch", 104 | "name": "Debug integration test 'ext-report-tests'", 105 | "cargo": { 106 | "args": [ 107 | "test", 108 | "--no-run", 109 | "--test=ext-report-tests", 110 | "--package=ledger-rs-lib" 111 | ], 112 | "filter": { 113 | "name": "ext-report-tests", 114 | "kind": "test" 115 | } 116 | }, 117 | "args": [], 118 | "cwd": "${workspaceFolder}" 119 | }, 120 | { 121 | "type": "lldb", 122 | "request": "launch", 123 | "name": "Debug integration test 'functional-tests'", 124 | "cargo": { 125 | "args": [ 126 | "test", 127 | "--no-run", 128 | "--test=functional-tests", 129 | "--package=ledger-rs-lib" 130 | ], 131 | "filter": { 132 | "name": "functional-tests", 133 | "kind": "test" 134 | } 135 | }, 136 | "args": [], 137 | "cwd": "${workspaceFolder}" 138 | }, 139 | { 140 | "type": "lldb", 141 | "request": "launch", 142 | "name": "Debug integration test 'poc'", 143 | "cargo": { 144 | "args": [ 145 | "test", 146 | "--no-run", 147 | "--test=poc", 148 | "--package=ledger-rs-lib" 149 | ], 150 | "filter": { 151 | "name": "poc", 152 | "kind": "test" 153 | } 154 | }, 155 | "args": [], 156 | "cwd": "${workspaceFolder}" 157 | }, 158 | { 159 | "type": "lldb", 160 | "request": "launch", 161 | "name": "Debug integration test 'tests-for-wasm'", 162 | "cargo": { 163 | "args": [ 164 | "test", 165 | "--no-run", 166 | "--test=tests-for-wasm", 167 | "--package=ledger-rs-lib" 168 | ], 169 | "filter": { 170 | "name": "tests-for-wasm", 171 | "kind": "test" 172 | } 173 | }, 174 | "args": [], 175 | "cwd": "${workspaceFolder}" 176 | }, 177 | { 178 | "type": "lldb", 179 | "request": "launch", 180 | "name": "Debug integration test 'utility-tests'", 181 | "cargo": { 182 | "args": [ 183 | "test", 184 | "--no-run", 185 | "--test=utility-tests", 186 | "--package=ledger-rs-lib" 187 | ], 188 | "filter": { 189 | "name": "utility-tests", 190 | "kind": "test" 191 | } 192 | }, 193 | "args": [], 194 | "cwd": "${workspaceFolder}" 195 | } 196 | ] 197 | } -------------------------------------------------------------------------------- /tests/ext-parser-tests.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * External parser tests 3 | */ 4 | 5 | use chrono::NaiveDate; 6 | use ledger_rs_lib::{ 7 | amount::{Amount, Quantity}, 8 | journal::Journal, 9 | parse_file, 10 | pool::CommodityIndex, parse_text, commodity::Commodity, 11 | }; 12 | 13 | #[test] 14 | fn smoke_test_parsing() { 15 | let file_path = "tests/minimal.ledger"; 16 | let mut journal = Journal::new(); 17 | 18 | ledger_rs_lib::parse_file(file_path, &mut journal); 19 | 20 | assert_eq!(1, journal.xacts.len()); 21 | let xact = &journal.xacts[0]; 22 | assert_eq!(2, xact.posts.len()); 23 | } 24 | 25 | /// Testing reading the blank lines. Seems to be an issue on Windows? 26 | #[test] 27 | fn test_parsing_two_xact() { 28 | let file_path = "tests/two_xact.ledger"; 29 | let mut journal = Journal::new(); 30 | 31 | ledger_rs_lib::parse_file(file_path, &mut journal); 32 | 33 | assert_eq!(2, journal.xacts.len()); 34 | let xact0 = &journal.xacts[0]; 35 | let xact1 = &journal.xacts[1]; 36 | assert_eq!(2, xact0.posts.len()); 37 | assert_eq!(2, xact1.posts.len()); 38 | } 39 | 40 | #[test] 41 | fn detailed_basic_test() { 42 | let file_path = "tests/basic.ledger"; 43 | let mut journal = Journal::new(); 44 | 45 | // Act 46 | ledger_rs_lib::parse_file(file_path, &mut journal); 47 | 48 | // Assert 49 | assert_eq!(1, journal.xacts.len()); 50 | let xact = journal.xacts.first().unwrap(); 51 | assert_eq!( 52 | NaiveDate::parse_from_str("2023-04-21", "%Y-%m-%d").unwrap(), 53 | xact.date.unwrap() 54 | ); 55 | assert_eq!("Supermarket", xact.payee); 56 | // Posts 57 | assert_eq!(2, journal.all_posts().len()); 58 | let post1 = &xact.posts[0]; 59 | assert_eq!("Food", journal.get_account(post1.account).name); 60 | let amount1 = &post1.amount.as_ref().unwrap(); 61 | assert_eq!(Quantity::from(20), amount1.quantity); 62 | let symbol = &amount1.get_commodity().unwrap().symbol; 63 | assert_eq!("EUR", symbol); 64 | 65 | let post2 = &xact.posts[1]; 66 | assert_eq!("Cash", journal.get_account(post2.account).name); 67 | let amount2 = &post2.amount.as_ref().unwrap(); 68 | assert_eq!(Quantity::from(-20), amount2.quantity); 69 | let symbol = &amount2.get_commodity().unwrap().symbol; 70 | assert_eq!("EUR", symbol); 71 | } 72 | 73 | /// TODO: include when the feature is implemented 74 | //#[test] 75 | fn test_include() { 76 | // let args = split("accounts -f tests/include.ledger").unwrap(); 77 | let input = "include tests/minimal.ledger"; 78 | let mut journal = Journal::new(); 79 | 80 | ledger_rs_lib::parse_text(input, &mut journal); 81 | 82 | assert_eq!(1, journal.xacts.len()); 83 | todo!("complete the feature") 84 | } 85 | 86 | #[test] 87 | fn test_parsing_multiple_currencies() { 88 | // Arrange 89 | let file_path = "tests/multiple_currencies.ledger"; 90 | let mut journal = Journal::new(); 91 | 92 | // Act 93 | ledger_rs_lib::parse_file(file_path, &mut journal); 94 | 95 | // Assert 96 | assert!(!journal.xacts.is_empty()); 97 | assert!(!journal.all_posts().is_empty()); 98 | } 99 | 100 | #[test] 101 | fn test_parsing_account_tree() { 102 | // Arrange 103 | let file_path = "tests/basic.ledger"; 104 | let mut journal = Journal::new(); 105 | 106 | // Act 107 | ledger_rs_lib::parse_file(file_path, &mut journal); 108 | 109 | // Assert 110 | assert!(!journal.xacts.is_empty()); 111 | assert_eq!(5, journal.master.flatten_account_tree().len()); 112 | } 113 | 114 | #[test] 115 | fn test_parsing_lots_per_unit() { 116 | let mut journal = Journal::new(); 117 | 118 | parse_file("tests/trade-buy-sell.ledger", &mut journal); 119 | 120 | // Assert 121 | 122 | // xacts 123 | assert!(!journal.xacts.is_empty()); 124 | assert_eq!(2, journal.xacts.len()); 125 | 126 | // posts 127 | assert_eq!(4, journal.all_posts().len()); 128 | // buy xact 129 | let buy_xact = &journal.xacts[0]; 130 | let post = &buy_xact.posts[0]; 131 | let Some(cost) = post.cost else { panic!("no cost!")}; 132 | assert_eq!(cost.quantity, 200.into()); 133 | // sell 134 | // let cur_index: CommodityIndex = 1.into(); 135 | let cdty = journal.commodity_pool.find("EUR").unwrap(); 136 | let expected_cost = Amount::new((-250).into(), Some(cdty)); 137 | let xact1 = &journal.xacts[1]; 138 | assert_eq!(expected_cost, xact1.posts[0].cost.unwrap()); 139 | 140 | // let cur = journal.get_commodity(cur_index); 141 | assert_eq!("EUR", cdty.symbol); 142 | } 143 | 144 | #[test] 145 | fn test_parsing_lots_full_price() { 146 | // arrange 147 | let mut journal = Journal::new(); 148 | 149 | // act 150 | parse_file("tests/trade-buy-sell-full-price.ledger", &mut journal); 151 | 152 | // Assert 153 | 154 | // xacts 155 | assert!(!journal.xacts.is_empty()); 156 | assert_eq!(2, journal.xacts.len()); 157 | 158 | // posts 159 | assert_eq!(4, journal.all_posts().len()); 160 | let xact = &journal.xacts[1]; 161 | let eur = journal.commodity_pool.find("EUR").unwrap() as *const Commodity; 162 | let expected_cost = Amount::new(25.into(), Some(eur)); 163 | assert_eq!(expected_cost, xact.posts[0].cost.unwrap()); 164 | } 165 | 166 | // TODO: #[test] 167 | fn test_lot_sale() { 168 | // arrange 169 | let input = r#"2023-05-01 Sell Stocks 170 | Assets:Stocks -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR 171 | Assets:Cash 172 | "#; 173 | let mut journal = Journal::new(); 174 | 175 | // act 176 | parse_text(input, &mut journal); 177 | 178 | // assert 179 | assert_eq!(1, journal.xacts.len()); 180 | assert_eq!(2, journal.all_posts().len()); 181 | assert_eq!(2, journal.commodity_pool.len()); 182 | 183 | let veur = journal.commodity_pool.find_index("VEUR"); 184 | let eur = journal.commodity_pool.find_index("EUR"); 185 | 186 | let xact = &journal.xacts[0]; 187 | let sale_post = &xact.posts[1]; 188 | assert_eq!(sale_post.amount.unwrap().quantity, (-10).into()); 189 | assert_eq!(sale_post.amount.unwrap().get_commodity().unwrap().graph_index, veur); 190 | 191 | // annotations 192 | // todo!("annotations") 193 | 194 | // cost 195 | assert_eq!(sale_post.cost.unwrap().quantity, (250).into()); 196 | assert_eq!(sale_post.cost.unwrap().get_commodity().unwrap().graph_index, eur); 197 | } 198 | 199 | // #[test] 200 | fn test_parsing_trade_lot() { 201 | let mut journal = Journal::new(); 202 | 203 | parse_file("tests/trade-buy-sell-lot.ledger", &mut journal); 204 | 205 | // Assert 206 | assert_eq!(2, journal.xacts.len()); 207 | let sale_xact = &journal.xacts[1]; 208 | let posts = &sale_xact.posts; 209 | let sale_post = &posts[0]; 210 | assert_eq!(sale_post.amount.unwrap().quantity, (-10).into()); 211 | assert_eq!(Quantity::from(-250), sale_post.cost.unwrap().quantity); 212 | } 213 | -------------------------------------------------------------------------------- /src/report.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Reports module containing the report definitions 3 | */ 4 | 5 | use crate::{balance::Balance, journal::Journal, account::Account}; 6 | 7 | /// Accounts report. Command: `accounts`. 8 | /// 9 | /// void report_t::posts_report(post_handler_ptr handler) 10 | /// in output.cc 11 | /// report_accounts 12 | pub fn report_accounts(journal: &Journal) -> Vec { 13 | let accts = journal.master.flatten_account_tree(); 14 | accts 15 | .iter() 16 | .map(|account| account.name.to_string()) 17 | .collect() 18 | } 19 | 20 | fn report_commodities() { 21 | todo!() 22 | } 23 | 24 | fn report_payees() { 25 | todo!() 26 | } 27 | 28 | /// Balance report. Invoked with 'b' command. 29 | /// Or accounts_report in ledger. 30 | /// Vec 31 | pub fn balance_report(journal: &Journal) -> Vec { 32 | log::debug!("Running the balance report"); 33 | 34 | // let balances = get_account_balances(&journal); 35 | // Now that the account totals are implemented, simply walk the master account. 36 | // Format output 37 | // format_balance_report(balances, &journal) 38 | 39 | get_children_lines(&journal.master, journal) 40 | } 41 | 42 | /// Quick test of the account traversal for assembling the totals. 43 | fn get_children_lines<'a>(account: &'a Account, journal: &'a Journal) -> Vec { 44 | let mut result = vec![]; 45 | 46 | let mut balance_line = String::new(); 47 | let total = account.total(); 48 | for amount in total.amounts { 49 | balance_line += amount.quantity.to_string().as_str(); 50 | if amount.get_commodity().is_some() { 51 | if let Some(c) = amount.get_commodity() { 52 | balance_line += " "; 53 | balance_line += c.symbol.as_str(); 54 | } 55 | } 56 | } 57 | result.push(format!("Account {} has balance {}", account.fullname(), balance_line)); 58 | 59 | // Sort child account names alphabetically. Mainly for consistent output. 60 | let mut acct_names: Vec<_> = account.accounts.keys().collect(); 61 | acct_names.sort(); 62 | 63 | // children amounts 64 | for acct_name in acct_names { 65 | let acct = account.accounts.get(acct_name).unwrap(); 66 | result.extend(get_children_lines(acct, journal)); 67 | } 68 | 69 | result 70 | } 71 | 72 | /// To be deprecated, unless significantly faster than the account traversing. 73 | /// Calculates account balances. 74 | /// returns (account_name, balance) 75 | /// 76 | fn get_account_balances(journal: &Journal) -> Vec<(&str, Balance)> { 77 | let mut balances = vec![]; 78 | 79 | // calculate balances 80 | for acc in journal.master.flatten_account_tree() { 81 | // get posts for this account. 82 | let filtered_posts = journal 83 | .xacts.iter().flat_map(|x| x.posts.iter()) 84 | .filter(|post| post.account == acc); 85 | 86 | // TODO: separate balance per currency 87 | 88 | let mut balance: Balance = Balance::new(); 89 | for post in filtered_posts { 90 | balance.add(&post.amount.as_ref().unwrap()); 91 | } 92 | 93 | balances.push((acc.fullname(), balance)); 94 | } 95 | balances 96 | } 97 | 98 | /// To be deprecated. 99 | fn format_balance_report(mut balances: Vec<(String, Balance)>, journal: &Journal) -> Vec { 100 | // sort accounts 101 | balances.sort_by(|(acc1, _bal1), (acc2, _bal2)| acc1.cmp(&acc2)); 102 | 103 | let mut output = vec![]; 104 | for (account, balance) in balances { 105 | let mut bal_text: String = String::new(); 106 | for amount in &balance.amounts { 107 | // 108 | let symbol = match amount.get_commodity() { 109 | Some(c) => c.symbol.as_str(), 110 | None => "", 111 | }; 112 | 113 | if !bal_text.is_empty() { 114 | bal_text += ", "; 115 | } 116 | 117 | bal_text += amount.quantity.to_string().as_str(); 118 | 119 | if !symbol.is_empty() { 120 | bal_text += " "; 121 | bal_text += symbol; 122 | } 123 | } 124 | let line = format!("Account {} has balance {}", account, bal_text); 125 | output.push(line); 126 | } 127 | output 128 | } 129 | 130 | /// Calculates market price, `-X` 131 | /// 132 | /// report.cc 133 | /// value_t report_t::fn_market(call_scope_t& args) 134 | /// 135 | fn market(target_commodity: &str) { 136 | 137 | } 138 | 139 | #[cfg(test)] 140 | mod tests { 141 | use std::io::Cursor; 142 | 143 | use super::balance_report; 144 | use crate::{journal::Journal, parser}; 145 | 146 | #[test] 147 | fn test_balance_report_one_xact() { 148 | let src = r#"; 149 | 2023-05-05 Payee 150 | Expenses 25 EUR 151 | Assets 152 | 153 | "#; 154 | let mut journal = Journal::new(); 155 | parser::read_into_journal(Cursor::new(src), &mut journal); 156 | 157 | let actual: Vec = balance_report(&journal); 158 | 159 | assert!(!actual.is_empty()); 160 | assert_eq!(3, actual.len()); 161 | assert_eq!("Account has balance 0 EUR", actual[0]); 162 | assert_eq!("Account Assets has balance -25 EUR", actual[1]); 163 | assert_eq!("Account Expenses has balance 25 EUR", actual[2]); 164 | // assert_eq!("Account has balance ", actual[0]); 165 | // assert_eq!("Account Assets has balance -25 EUR", actual[1]); 166 | // assert_eq!("Account Expenses has balance 25 EUR", actual[2]); 167 | } 168 | 169 | #[test] 170 | fn test_bal_report_two_commodities() { 171 | let src = r#"; 172 | 2023-05-05 Payee 173 | Expenses 25 EUR 174 | Assets 175 | 176 | 2023-05-05 Payee 2 177 | Expenses 13 BAM 178 | Assets 179 | "#; 180 | let source = Cursor::new(src); 181 | let mut journal = Journal::new(); 182 | parser::read_into_journal(source, &mut journal); 183 | 184 | // Act 185 | let actual: Vec = balance_report(&journal); 186 | 187 | // Assert 188 | assert!(!actual.is_empty()); 189 | assert_eq!(3, actual.len()); 190 | assert_eq!("Account has balance 0 EUR0 BAM", actual[0]); 191 | assert_eq!("Account Assets has balance -25 EUR-13 BAM", actual[1]); 192 | assert_eq!("Account Expenses has balance 25 EUR13 BAM", actual[2]); 193 | } 194 | 195 | #[test] 196 | fn test_bal_multiple_commodities_in_the_same_xact() { 197 | let src = r#"; 198 | 2023-05-05 Payee 199 | Assets:Cash EUR -25 EUR 200 | Assets:Cash USD 30 USD 201 | "#; 202 | let source = Cursor::new(src); 203 | let mut journal = Journal::new(); 204 | parser::read_into_journal(source, &mut journal); 205 | 206 | // Act 207 | let actual: Vec = balance_report(&journal); 208 | 209 | // Assert 210 | assert!(!actual.is_empty()); 211 | assert_eq!(4, actual.len()); 212 | assert_eq!("Account has balance -25 EUR30 USD", actual[0]); 213 | assert_eq!("Account Assets has balance -25 EUR30 USD", actual[1]); 214 | assert_eq!("Account Assets:Cash EUR has balance -25 EUR", actual[2]); 215 | assert_eq!("Account Assets:Cash USD has balance 30 USD", actual[3]); 216 | } 217 | 218 | // TODO: #[test] 219 | fn test_bal_market_prices() { 220 | // add a price, 221 | // then run the balance report 222 | // in one currency (-X EUR) 223 | 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | # Ledger-rs library 3 | 4 | Ledger-cli functionality implemented in Rust 5 | 6 | Early work-in-progress. 7 | 8 | The basic functionality demo: 9 | 10 | Given a `basic.ledger` text file, with the contents 11 | 12 | ```ledger 13 | 2023-04-21 Supermarket 14 | Expenses:Food 20 EUR 15 | Assets:Cash 16 | ``` 17 | 18 | you can use the library to parse the transactions from the file and provide a basic 19 | report on account balances 20 | 21 | ``` 22 | let actual = ledger_rs_lib::run_command("b -f tests/basic.ledger"); 23 | 24 | assert!(!actual.is_empty()); 25 | assert_eq!(5, actual.len()); 26 | assert_eq!("Account has balance 0 EUR", actual[0]); 27 | assert_eq!("Account Assets has balance -20 EUR", actual[1]); 28 | assert_eq!("Account Assets:Cash has balance -20 EUR", actual[2]); 29 | assert_eq!("Account Expenses has balance 20 EUR", actual[3]); 30 | assert_eq!("Account Expenses:Food has balance 20 EUR", actual[4]); 31 | ``` 32 | */ 33 | use std::{fs::File, io::Cursor}; 34 | 35 | use journal::Journal; 36 | use option::InputOptions; 37 | 38 | #[cfg(target_arch = "wasm32")] 39 | use wasm_bindgen::prelude::*; 40 | 41 | pub mod account; 42 | mod annotate; 43 | pub mod amount; 44 | mod balance; 45 | pub mod commodity; 46 | mod directives; 47 | mod reader; 48 | pub mod history; 49 | mod iterator; 50 | pub mod journal; 51 | mod journalreader; 52 | mod option; 53 | pub mod parser; 54 | pub mod pool; 55 | pub mod post; 56 | pub mod report; 57 | pub mod scanner; 58 | pub mod utilities; 59 | mod value; 60 | pub mod xact; 61 | 62 | /// An entry point for the CLIs. 63 | /// The commands and arguments sent to the CLI are processed here. This is 64 | /// so that 3rd-party clients can pass argv and get the same result. 65 | /// The arguments should be compatible with Ledger, so that the functionality is comparable. 66 | /// 67 | pub fn run(args: Vec) -> Vec { 68 | // separates commands from the options 69 | let (commands, options) = option::process_arguments(args); 70 | 71 | execute_command(commands, options) 72 | } 73 | 74 | /// A convenient entry point if you want to use a command string directly. 75 | /// command: &str A Ledger-style command, i.e. "balance -f journal.ledger" 76 | /// 77 | pub fn run_command(command: &str) -> Vec { 78 | let args = shell_words::split(command).unwrap(); 79 | run(args) 80 | } 81 | 82 | /// global::execute_command equivalent 83 | fn execute_command(commands: Vec, input_options: InputOptions) -> Vec { 84 | let verb = commands.iter().nth(0).unwrap(); 85 | 86 | // todo: look for pre-command 87 | // look_for_precommand(verb); 88 | 89 | // if !precommand 90 | // if !at_repl 91 | let journal = session_read_journal_files(&input_options); 92 | 93 | // todo: lookup(COMMAND, verb) 94 | 95 | let command_args = &commands[1..]; 96 | 97 | // execute command 98 | match verb.chars().next().unwrap() { 99 | 'a' => { 100 | // accounts? 101 | // TODO: replace this temporary report 102 | let mut output = report::report_accounts(&journal); 103 | output.sort(); 104 | output 105 | } 106 | 'b' => { 107 | match verb.as_str() { 108 | "b" | "bal" | "balance" => { 109 | // balance report 110 | report::balance_report(&journal) 111 | } 112 | "budget" => { 113 | // budget 114 | todo!("budget!") 115 | } 116 | _ => { 117 | todo!("?") 118 | } 119 | } 120 | } 121 | _ => todo!("handle"), 122 | } 123 | } 124 | 125 | fn look_for_precommand(verb: &str) { 126 | todo!() 127 | } 128 | 129 | fn session_read_journal_files(options: &InputOptions) -> Journal { 130 | // Minimalistic approach: 131 | // get the file input 132 | 133 | // multiple filenames 134 | let mut journal = Journal::new(); 135 | for filename in &options.filenames { 136 | // parse the journal file(s) 137 | parse_file(filename, &mut journal); 138 | } 139 | 140 | journal 141 | } 142 | 143 | /// Parse input and return the model structure. 144 | pub fn parse_file(file_path: &str, journal: &mut Journal) { 145 | let file = File::open(file_path).expect("file opened"); 146 | parser::read_into_journal(file, journal); 147 | } 148 | 149 | /// Parses text containing Ledger-style journal. 150 | /// text: &str A Ledger-style journal. The same content that is normally 151 | /// stored in text files 152 | /// journal: &mut Journal The result are stored in the given Journal instance. 153 | pub fn parse_text(text: &str, journal: &mut Journal) { 154 | let source = Cursor::new(text); 155 | parser::read_into_journal(source, journal); 156 | } 157 | 158 | pub fn parser_experiment() { 159 | // read line from the Journal 160 | // determine the type 161 | // DirectiveType 162 | // scan the line 163 | // parse into a model instance 164 | // read additional lines, as needed. Ie for Xact/Posts. 165 | // return the directive with the entity, if created 166 | 167 | todo!("try the new approach") 168 | } 169 | 170 | #[cfg_attr(target_arch = "wasm32", wasm_bindgen)] 171 | pub fn wasm_test() -> String { 172 | "hello from wasm".to_owned() 173 | } 174 | 175 | #[cfg(test)] 176 | mod lib_tests { 177 | use std::assert_eq; 178 | 179 | use crate::{amount::{Amount, Quantity}, option, run}; 180 | 181 | // Try to understand why this test fails when dereferencing. 182 | #[test] 183 | fn test_minimal() { 184 | // create a ledger command 185 | let command = "b -f tests/minimal.ledger"; 186 | let args = shell_words::split(command).unwrap(); 187 | let expected = r#"Account has balance 0 188 | Account Assets has balance -20 189 | Account Expenses has balance 20"#; 190 | 191 | // the test fails when checking the parent in fullname() for 192 | // Assets (the first child account). 193 | 194 | let actual = run(args).join("\n"); 195 | 196 | // Assert 197 | assert!(!actual.is_empty()); 198 | assert_eq!(expected, actual); 199 | } 200 | 201 | #[test] 202 | fn test_multiple_files() { 203 | // arrange 204 | let args = 205 | shell_words::split("accounts -f tests/minimal.ledger -f tests/basic.ledger").unwrap(); 206 | let (_commands, input_options) = option::process_arguments(args); 207 | // let cdty = Commodity::new("EUR"); 208 | 209 | // Act 210 | let journal = super::session_read_journal_files(&input_options); 211 | 212 | // Assert 213 | let xact0 = &journal.xacts[0]; 214 | let xact1 = &journal.xacts[1]; 215 | 216 | // xacts 217 | assert_eq!(2, journal.xacts.len()); 218 | assert_eq!("Payee", xact0.payee); 219 | assert_eq!("Supermarket", xact1.payee); 220 | 221 | // posts 222 | // assert_eq!(4, journal.posts.len()); 223 | assert_eq!(2, xact0.posts.len()); 224 | assert_eq!(2, xact1.posts.len()); 225 | assert_eq!(Some(Amount::new(20.into(), None)), xact0.posts[0].amount); 226 | // amount 227 | assert_eq!(xact1.posts[0].amount.unwrap().quantity, Quantity::from_str("20").unwrap()); 228 | assert_eq!(xact1.posts[0].amount.unwrap().get_commodity().unwrap().symbol, "EUR"); 229 | 230 | // accounts 231 | let mut accounts = journal.master.flatten_account_tree(); 232 | // hack for the test, since the order of items in a hashmap is not guaranteed. 233 | accounts.sort_unstable_by_key(|acc| &acc.name); 234 | 235 | assert_eq!("", accounts[0].name); 236 | assert_eq!("Assets", accounts[1].name); 237 | assert_eq!("Cash", accounts[2].name); 238 | assert_eq!("Expenses", accounts[3].name); 239 | assert_eq!("Food", accounts[4].name); 240 | 241 | // commodities 242 | assert_eq!(1, journal.commodity_pool.commodities.len()); 243 | assert_eq!( 244 | "EUR", 245 | journal.commodity_pool.find("EUR").unwrap().symbol 246 | ); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/balance.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Stores the balance 3 | * 4 | * balance.h + .cc 5 | * 6 | * Intended to help with storing amounts in multiple commodities. 7 | */ 8 | 9 | use std::ops::{AddAssign, SubAssign}; 10 | 11 | use crate::amount::Amount; 12 | 13 | /// Balance 14 | #[derive(Debug)] 15 | pub struct Balance { 16 | /// Map of commodity index, Amount 17 | // pub amounts: HashMap, // try Symbol/Amount for easier search. 18 | // amounts: HashMap 19 | 20 | /// Amounts, in different currencies. 21 | /// The currency information is contained within the Amount instance. 22 | pub amounts: Vec, 23 | } 24 | 25 | impl Balance { 26 | pub fn new() -> Self { 27 | // Add null commodity 28 | // let mut amounts: HashMap = HashMap::new(); 29 | // amounts.insert("", Amount::new(0, None)); 30 | 31 | Self { 32 | // amounts: HashMap::new(), 33 | amounts: vec![], 34 | } 35 | } 36 | 37 | /// Add an Amount to the Balance. 38 | /// If an amount in the same commodity is found, it is added, 39 | /// otherwise, a new Amount is created. 40 | pub fn add(&mut self, amount: &Amount) { 41 | match self 42 | .amounts 43 | .iter_mut() 44 | .find(|amt| amt.get_commodity() == amount.get_commodity()) 45 | { 46 | Some(existing_amount) => { 47 | // append to the amount 48 | existing_amount.add(&amount); 49 | } 50 | None => { 51 | // Balance not found for the commodity. Create new. 52 | log::debug!("Balance not found. Creating for {:?}", amount); 53 | 54 | self.amounts.push(Amount::copy_from(amount)); 55 | } 56 | }; 57 | } 58 | } 59 | 60 | impl SubAssign for Balance { 61 | fn sub_assign(&mut self, amount: Amount) { 62 | match self 63 | .amounts 64 | .iter_mut() 65 | .find(|amt| amt.get_commodity() == amount.get_commodity()) 66 | { 67 | Some(existing_amount) => { 68 | // append to the amount 69 | *existing_amount -= amount; 70 | } 71 | None => { 72 | // Balance not found for the commodity. Create new. 73 | self.amounts.push(Amount::copy_from(&amount.inverse())); 74 | } 75 | }; 76 | } 77 | } 78 | 79 | impl AddAssign for Balance { 80 | fn add_assign(&mut self, other: Amount) { 81 | self.add(&other); 82 | } 83 | } 84 | 85 | impl AddAssign for Balance { 86 | fn add_assign(&mut self, other: Balance) { 87 | for amount in other.amounts { 88 | self.add(&amount); 89 | } 90 | } 91 | } 92 | 93 | // impl fmt::Display for Balance { 94 | // fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 95 | // for amount in &self.amounts { 96 | // write!(f, "{}", amount.quantity)?; 97 | // // amount.commodity_index 98 | // } 99 | // Ok(()) 100 | // } 101 | // } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use super::Balance; 106 | use crate::{ 107 | amount::{Amount, Quantity}, 108 | commodity::Commodity, 109 | }; 110 | // use crate::pool::CommodityIndex; 111 | 112 | #[test] 113 | fn test_adding_first_amount_no_commodity() { 114 | let amount = Amount::new(25.into(), None); 115 | let mut balance = Balance::new(); 116 | 117 | balance.add(&amount); 118 | 119 | // Assert 120 | assert!(!balance.amounts.is_empty()); 121 | assert_eq!(1, balance.amounts.len()); 122 | assert_eq!( 123 | Quantity::from(25), 124 | balance.amounts.iter().next().unwrap().quantity 125 | ); 126 | assert_eq!(None, balance.amounts.iter().next().unwrap().get_commodity()); 127 | } 128 | 129 | #[test] 130 | fn test_adding_two_amounts_no_commodity() { 131 | let mut balance = Balance::new(); 132 | 133 | // Act 134 | let amount = Amount::new(25.into(), None); 135 | balance.add(&amount); 136 | 137 | let amount = Amount::new(5.into(), None); 138 | balance.add(&amount); 139 | 140 | // Assert 141 | assert!(!balance.amounts.is_empty()); 142 | assert_eq!(1, balance.amounts.len()); 143 | assert_eq!( 144 | Quantity::from(30), 145 | balance.amounts.iter().next().unwrap().quantity 146 | ); 147 | assert_eq!(None, balance.amounts.iter().next().unwrap().get_commodity()); 148 | } 149 | 150 | #[test] 151 | fn test_adding_two_amounts_with_commodities() { 152 | let cdty = Commodity::new("ABC"); 153 | let mut balance = Balance::new(); 154 | 155 | // Act 156 | let amount = Amount::new(25.into(), Some(&cdty)); 157 | balance.add(&amount); 158 | 159 | let amount = Amount::new(5.into(), None); 160 | balance.add(&amount); 161 | 162 | // Assert 163 | assert!(!balance.amounts.is_empty()); 164 | assert_eq!(2, balance.amounts.len()); 165 | assert_eq!( 166 | Quantity::from(25), 167 | balance.amounts.iter().nth(0).unwrap().quantity 168 | ); 169 | assert_eq!( 170 | Some(&cdty), 171 | balance.amounts.iter().nth(0).unwrap().get_commodity() 172 | ); 173 | 174 | assert_eq!( 175 | Quantity::from(5), 176 | balance.amounts.iter().nth(1).unwrap().quantity 177 | ); 178 | assert_eq!(None, balance.amounts.iter().nth(1).unwrap().get_commodity()); 179 | } 180 | 181 | #[test] 182 | fn test_adding_two_amounts_with_some_commodities() { 183 | let cdty1 = Commodity::new("CD1"); 184 | let cdty2 = Commodity::new("CD2"); 185 | let mut balance = Balance::new(); 186 | 187 | // Act 188 | let amount = Amount::new(25.into(), Some(&cdty1)); 189 | balance.add(&amount); 190 | 191 | let amount = Amount::new(5.into(), Some(&cdty2)); 192 | balance.add(&amount); 193 | 194 | // Assert 195 | assert!(!balance.amounts.is_empty()); 196 | assert_eq!(2, balance.amounts.len()); 197 | 198 | assert_eq!( 199 | Quantity::from(25), 200 | balance.amounts.iter().nth(0).unwrap().quantity 201 | ); 202 | assert_eq!( 203 | Some(&cdty1), 204 | balance.amounts.iter().nth(0).unwrap().get_commodity() 205 | ); 206 | 207 | assert_eq!( 208 | Quantity::from(5), 209 | balance.amounts.iter().nth(1).unwrap().quantity 210 | ); 211 | assert_eq!( 212 | Some(&cdty2), 213 | balance.amounts.iter().nth(1).unwrap().get_commodity() 214 | ); 215 | } 216 | 217 | #[test] 218 | fn test_adding_two_amounts_with_same_commodity() { 219 | let cdty = Commodity::new("BAM"); 220 | let mut balance = Balance::new(); 221 | 222 | // Act 223 | let amount = Amount::new(25.into(), Some(&cdty)); 224 | balance.add(&amount); 225 | 226 | let amount = Amount::new(5.into(), Some(&cdty)); 227 | balance.add(&amount); 228 | 229 | // Assert 230 | assert!(!balance.amounts.is_empty()); 231 | assert_eq!(1, balance.amounts.len()); 232 | 233 | assert_eq!( 234 | Quantity::from(30), 235 | balance.amounts.iter().nth(0).unwrap().quantity 236 | ); 237 | assert_eq!( 238 | Some(&cdty), 239 | balance.amounts.iter().nth(0).unwrap().get_commodity() 240 | ); 241 | } 242 | 243 | #[test] 244 | fn test_sub_assign() { 245 | let mut bal = Balance::new(); 246 | let amount = Amount::new(10.into(), Some(&Commodity::new("ABC"))); 247 | let expected = amount.inverse(); 248 | 249 | bal -= amount; 250 | 251 | assert_eq!(1, bal.amounts.len()); 252 | assert_eq!(expected, bal.amounts[0]); 253 | } 254 | 255 | #[test] 256 | fn test_addition() { 257 | let cdty = Commodity::new("ABC"); 258 | let mut bal1 = Balance::new(); 259 | bal1.add(&Amount::new(10.into(), Some(&cdty))); 260 | let mut bal2 = Balance::new(); 261 | bal2.add(&Amount::new(15.into(), Some(&cdty))); 262 | 263 | bal2 += bal1; 264 | 265 | assert_eq!(1, bal2.amounts.len()); 266 | assert_eq!(bal2.amounts[0].quantity, 25.into()); 267 | assert_eq!(bal2.amounts[0].get_commodity(), Some(&cdty)); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/amount.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Amount and the Decimal numeric type 3 | */ 4 | 5 | use std::{ 6 | fmt, 7 | ops::{Add, AddAssign, Div, Mul, MulAssign, SubAssign}, 8 | }; 9 | 10 | use rust_decimal::prelude::{FromPrimitive, ToPrimitive}; 11 | 12 | use crate::commodity::Commodity; 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 15 | pub struct Amount { 16 | pub quantity: Quantity, 17 | pub(crate) commodity: *const Commodity, 18 | } 19 | 20 | impl Amount { 21 | pub fn new(quantity: Quantity, commodity: Option<*const Commodity>) -> Self { 22 | Self { 23 | quantity, 24 | commodity: match commodity { 25 | Some(c) => c, 26 | _ => std::ptr::null() 27 | } 28 | } 29 | } 30 | 31 | /// Returns an absolute (positive) Amount. 32 | pub fn abs(&self) -> Amount { 33 | let mut result = self.clone(); 34 | result.quantity.set_sign_positive(); 35 | result 36 | } 37 | 38 | pub fn copy_from(other: &Amount) -> Self { 39 | Self { 40 | quantity: other.quantity, 41 | commodity: other.commodity, 42 | } 43 | } 44 | 45 | pub fn null() -> Self { 46 | Self { 47 | quantity: 0.into(), 48 | commodity: std::ptr::null(), 49 | } 50 | } 51 | 52 | pub fn add(&mut self, other: &Amount) { 53 | if self.commodity != other.commodity { 54 | log::error!("different commodities"); 55 | panic!("don't know yet how to handle this") 56 | } 57 | if other.quantity.is_zero() { 58 | // nothing to do 59 | return; 60 | } 61 | 62 | self.quantity += other.quantity; 63 | } 64 | 65 | pub fn get_commodity(&self) -> Option<&Commodity> { 66 | if self.commodity.is_null() { 67 | None 68 | } else { 69 | unsafe { 70 | Some(&*self.commodity) 71 | } 72 | } 73 | } 74 | 75 | /// Creates an amount with the opposite sign on the quantity. 76 | pub fn inverse(&self) -> Amount { 77 | let new_quantity = if self.quantity.is_sign_positive() { 78 | let mut x = self.quantity.clone(); 79 | x.set_sign_negative(); 80 | x 81 | } else { 82 | self.quantity 83 | }; 84 | 85 | unsafe { Amount::new(new_quantity, Some(&*self.commodity)) } 86 | } 87 | 88 | /// Inverts the sign on the amount. 89 | pub fn invert(&mut self) { 90 | if self.quantity.is_sign_positive() { 91 | self.quantity.set_sign_negative(); 92 | } else { 93 | self.quantity.set_sign_positive(); 94 | } 95 | } 96 | 97 | /// Indicates whether the amount is initialized. 98 | /// This is a 0 quantity and no Commodity. 99 | pub fn is_null(&self) -> bool { 100 | if self.quantity.is_zero() { 101 | return self.commodity.is_null(); 102 | } else { 103 | false 104 | } 105 | } 106 | 107 | pub fn is_zero(&self) -> bool { 108 | self.quantity.is_zero() 109 | } 110 | 111 | pub fn remove_commodity(&mut self) { 112 | self.commodity = std::ptr::null(); 113 | } 114 | } 115 | 116 | impl Add for Amount { 117 | type Output = Amount; 118 | 119 | fn add(self, rhs: Amount) -> Self::Output { 120 | if self.commodity != rhs.commodity { 121 | panic!("don't know yet how to handle this") 122 | } 123 | 124 | let sum = self.quantity + rhs.quantity; 125 | 126 | unsafe { Amount::new(sum, Some(&*self.commodity)) } 127 | } 128 | } 129 | 130 | impl AddAssign for Amount { 131 | fn add_assign(&mut self, other: Amount) { 132 | if self.commodity != other.commodity { 133 | panic!("don't know yet how to handle this") 134 | } 135 | 136 | self.quantity += other.quantity; 137 | } 138 | } 139 | 140 | impl Div for Amount { 141 | type Output = Amount; 142 | 143 | fn div(self, rhs: Self) -> Self::Output { 144 | let mut result = Amount::new(0.into(), None); 145 | 146 | if self.commodity.is_null() { 147 | result.commodity = rhs.commodity; 148 | } else { 149 | result.commodity = self.commodity 150 | } 151 | 152 | result.quantity = self.quantity / rhs.quantity; 153 | 154 | result 155 | } 156 | } 157 | 158 | impl Mul for Amount { 159 | type Output = Amount; 160 | 161 | fn mul(self, other: Amount) -> Amount { 162 | let quantity = self.quantity * other.quantity; 163 | 164 | let commodity = if self.commodity.is_null() { 165 | other.commodity 166 | } else { 167 | self.commodity 168 | }; 169 | 170 | unsafe { Amount::new(quantity, Some(&*commodity)) } 171 | } 172 | } 173 | 174 | impl From for Amount { 175 | fn from(value: i32) -> Self { 176 | Amount::new(Quantity::from(value), None) 177 | } 178 | } 179 | 180 | impl SubAssign for Amount { 181 | fn sub_assign(&mut self, other: Amount) { 182 | if self.commodity != other.commodity { 183 | panic!("The commodities do not match"); 184 | } 185 | 186 | self.quantity -= other.quantity; 187 | } 188 | } 189 | 190 | impl MulAssign for Amount { 191 | fn mul_assign(&mut self, rhs: Amount) { 192 | // multiply the quantity 193 | self.quantity *= rhs.quantity; 194 | 195 | // get the other commodity, if we don't have one. 196 | if self.commodity.is_null() && !rhs.commodity.is_null() { 197 | self.commodity = rhs.commodity; 198 | } 199 | } 200 | } 201 | 202 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 203 | pub struct Quantity(rust_decimal::Decimal); 204 | 205 | impl Quantity { 206 | pub const ZERO: Quantity = Quantity(rust_decimal::Decimal::ZERO); 207 | pub const ONE: Quantity = Quantity(rust_decimal::Decimal::ONE); 208 | 209 | pub fn from_str(str: &str) -> Option { 210 | let parsed = rust_decimal::Decimal::from_str_exact(str); 211 | if parsed.is_err() { 212 | return None; 213 | } 214 | 215 | Some(Self(parsed.unwrap())) 216 | } 217 | 218 | pub fn is_sign_positive(&self) -> bool { 219 | self.0.is_sign_positive() 220 | } 221 | 222 | pub fn is_zero(&self) -> bool { 223 | self.0.is_zero() 224 | } 225 | 226 | pub fn set_sign_negative(&mut self) { 227 | self.0.set_sign_negative(true) 228 | } 229 | 230 | pub fn set_sign_positive(&mut self) { 231 | self.0.set_sign_positive(true) 232 | } 233 | } 234 | 235 | impl From for Quantity { 236 | fn from(value: i32) -> Self { 237 | Quantity(rust_decimal::Decimal::from(value)) 238 | } 239 | } 240 | 241 | impl From for Quantity { 242 | fn from(value: f32) -> Self { 243 | Quantity(rust_decimal::Decimal::from_f32(value).unwrap()) 244 | } 245 | } 246 | 247 | /// Creates a Decimal value from a string. Panics if invalid. 248 | impl From<&str> for Quantity { 249 | fn from(value: &str) -> Self { 250 | Self(rust_decimal::Decimal::from_str_exact(value).unwrap()) 251 | // Decimal::from_str(value).unwrap() 252 | } 253 | } 254 | 255 | impl Into for Quantity { 256 | fn into(self) -> i32 { 257 | self.0.to_i32().unwrap() 258 | } 259 | } 260 | 261 | impl Add for Quantity { 262 | type Output = Quantity; 263 | 264 | fn add(self, other: Quantity) -> Quantity { 265 | Quantity(self.0 + other.0) 266 | } 267 | } 268 | 269 | impl AddAssign for Quantity { 270 | fn add_assign(&mut self, other: Quantity) { 271 | self.0 += other.0; 272 | } 273 | } 274 | 275 | impl Div for Quantity { 276 | type Output = Quantity; 277 | 278 | fn div(self, other: Quantity) -> Quantity { 279 | Self(self.0.div(other.0)) 280 | } 281 | } 282 | 283 | impl Mul for Quantity { 284 | type Output = Quantity; 285 | 286 | fn mul(self, other: Quantity) -> Quantity { 287 | Self(self.0 * other.0) 288 | } 289 | } 290 | 291 | impl MulAssign for Quantity { 292 | fn mul_assign(&mut self, rhs: Quantity) { 293 | self.0 *= rhs.0; 294 | } 295 | } 296 | 297 | impl SubAssign for Quantity { 298 | fn sub_assign(&mut self, other: Quantity) { 299 | self.0 -= other.0; 300 | } 301 | } 302 | 303 | impl fmt::Display for Quantity { 304 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 305 | write!(f, "{}", self.0) 306 | } 307 | } 308 | 309 | #[cfg(test)] 310 | mod tests { 311 | use rust_decimal::prelude::ToPrimitive; 312 | 313 | use crate::commodity::Commodity; 314 | 315 | use super::{Amount, Quantity}; 316 | 317 | #[test] 318 | fn test_decimal() { 319 | let x = Quantity::from(5); 320 | 321 | assert_eq!(Some(5), x.0.to_i32()); 322 | } 323 | 324 | #[test] 325 | fn test_division() { 326 | let currency = Commodity::new("EUR"); 327 | let a = Amount::new(10.into(), Some(¤cy)); 328 | let b = Amount::new(5.into(), Some(¤cy)); 329 | let expected = Amount::new(2.into(), Some(¤cy)); 330 | 331 | let c = a / b; 332 | 333 | assert_eq!(expected, c); 334 | } 335 | 336 | #[test] 337 | fn test_multiply_assign() { 338 | let a = Amount::from(10); 339 | let b = Amount::from(5); 340 | 341 | let actual = a * b; 342 | 343 | assert_eq!(Amount::new(Quantity::from_str("50").unwrap(), None), actual); 344 | } 345 | 346 | #[test] 347 | fn test_from_str() { 348 | // act 349 | let actual = Quantity::from_str("3"); 350 | 351 | // assert 352 | assert!(actual.is_some()); 353 | assert_eq!(Quantity::from(3), actual.unwrap()) 354 | } 355 | 356 | #[test] 357 | fn test_from_str_num_only() { 358 | let actual = Quantity::from_str("3 USD"); 359 | 360 | assert!(actual.is_none()); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/xact.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Transaction module 3 | * 4 | * Transaction, or Xact abbreviated, is the main element of the Journal. 5 | * It contains contains Postings. 6 | */ 7 | 8 | use chrono::NaiveDate; 9 | 10 | use crate::{balance::Balance, journal::Journal, parser, post::Post}; 11 | 12 | #[derive(Debug)] 13 | pub struct Xact { 14 | pub journal: *const Journal, 15 | pub date: Option, 16 | pub aux_date: Option, 17 | pub payee: String, 18 | pub posts: Vec, 19 | pub note: Option, 20 | // pub balance: Amount, 21 | } 22 | 23 | impl Xact { 24 | pub fn new(date: Option, payee: &str, note: Option) -> Self { 25 | // code: Option 26 | 27 | Self { 28 | payee: payee.to_owned(), 29 | note, 30 | date, 31 | aux_date: None, 32 | journal: std::ptr::null(), 33 | posts: vec![], 34 | // balance: Amount::null(), 35 | } 36 | } 37 | 38 | /// Creates a new Transaction from the scanned tokens. 39 | pub fn create(date: &str, aux_date: &str, payee: &str, note: &str) -> Self { 40 | let _date = if date.is_empty() { 41 | None 42 | } else { 43 | Some(parser::parse_date(date)) 44 | }; 45 | 46 | let _aux_date = if aux_date.is_empty() { 47 | None 48 | } else { 49 | Some(parser::parse_date(aux_date)) 50 | }; 51 | 52 | let _payee = if payee.is_empty() { 53 | "Unknown Payee".to_string() 54 | } else { 55 | payee.to_string() 56 | }; 57 | 58 | let _note = if note.is_empty() { 59 | None 60 | } else { 61 | Some(note.to_string()) 62 | }; 63 | 64 | Self { 65 | date: _date, 66 | payee: _payee, 67 | note: _note, 68 | aux_date: _aux_date, 69 | journal: std::ptr::null(), 70 | posts: vec![], 71 | } 72 | } 73 | 74 | pub fn add_note(&mut self, note: &str) { 75 | self.note = Some(note.into()); 76 | } 77 | 78 | /// Adds the post to the collection and returns the reference to it. 79 | pub fn add_post(&mut self, mut post: Post) -> &Post { 80 | post.xact_ptr = self as *const Xact; 81 | self.posts.push(post); 82 | 83 | self.posts.last().unwrap() 84 | } 85 | } 86 | 87 | impl Default for Xact { 88 | fn default() -> Self { 89 | Self { 90 | journal: std::ptr::null(), 91 | date: Default::default(), 92 | aux_date: Default::default(), 93 | payee: Default::default(), 94 | posts: Default::default(), 95 | note: Default::default(), 96 | } 97 | } 98 | } 99 | 100 | /// Finalize transaction. 101 | /// Adds the Xact and the Posts to the Journal. 102 | /// 103 | /// `bool xact_base_t::finalize()` 104 | /// 105 | pub fn finalize(xact_ptr: *const Xact, journal: &mut Journal) { 106 | // Scan through and compute the total balance for the xact. This is used 107 | // for auto-calculating the value of xacts with no cost, and the per-unit 108 | // price of unpriced commodities. 109 | 110 | let mut balance = Balance::new(); 111 | // The pointer to the post that has no amount. 112 | let mut null_post: *mut Post = std::ptr::null_mut(); 113 | // let xact = journal.xacts.get_mut(xact_index).expect("xact"); 114 | let xact: &mut Xact; 115 | unsafe { 116 | xact = &mut *(xact_ptr.cast_mut()); 117 | } 118 | 119 | // Balance 120 | for post in &mut xact.posts { 121 | // must balance? 122 | // if (! post->must_balance()) 123 | 124 | log::debug!("finalizing {:?}", post); 125 | 126 | let amount = if post.cost.is_some() { 127 | post.cost 128 | } else { 129 | post.amount 130 | }; 131 | 132 | if amount.is_some() { 133 | // Add to balance. 134 | let Some(amt) = &amount 135 | else {panic!("should not happen")}; 136 | 137 | balance.add(amt); 138 | } else if !null_post.is_null() { 139 | todo!() 140 | } else { 141 | null_post = post as *mut Post; 142 | } 143 | } 144 | 145 | // If there is only one post, balance against the default account if one has 146 | // been set. 147 | if xact.posts.len() == 1 { 148 | todo!("handle") 149 | } 150 | 151 | if null_post.is_null() && balance.amounts.len() == 2 { 152 | // When an xact involves two different commodities (regardless of how 153 | // many posts there are) determine the conversion ratio by dividing the 154 | // total value of one commodity by the total value of the other. This 155 | // establishes the per-unit cost for this post for both commodities. 156 | 157 | let mut top_post: Option<&Post> = None; 158 | for post in &xact.posts { 159 | if post.amount.is_some() && top_post.is_none() { 160 | top_post = Some(post); 161 | } 162 | } 163 | 164 | // if !saw_cost && top_post 165 | if top_post.is_some() { 166 | // log::debug("there were no costs, and a valid top_post") 167 | 168 | // We need a separate readonly reference for reading the amounts. 169 | let const_bal: &Balance; 170 | unsafe { 171 | let const_ptr = &balance as *const Balance; 172 | const_bal = &*const_ptr; 173 | } 174 | 175 | let mut x = const_bal.amounts.iter().nth(0).unwrap(); 176 | let mut y = const_bal.amounts.iter().nth(1).unwrap(); 177 | 178 | // if x && y 179 | if !x.is_zero() && !y.is_zero() { 180 | if x.get_commodity() != top_post.unwrap().amount.unwrap().get_commodity() { 181 | (x, y) = (y, x); 182 | } 183 | 184 | let comm = x.get_commodity(); 185 | let per_unit_cost = (*y / *x).abs(); 186 | 187 | for post in &mut xact.posts { 188 | let amt = post.amount.unwrap(); 189 | 190 | if amt.get_commodity() == comm { 191 | // todo!("check below"); 192 | balance -= amt; 193 | post.cost = Some(per_unit_cost * amt); 194 | balance += post.cost.unwrap(); 195 | } 196 | } 197 | } 198 | } 199 | } 200 | 201 | // if (has_date()) 202 | { 203 | for p in &mut xact.posts { 204 | // let p = journal.posts.get_mut(*post_index).unwrap(); 205 | if p.cost.is_none() { 206 | continue; 207 | } 208 | 209 | let Some(amt) = &p.amount else {panic!("No amount found on the posting")}; 210 | let Some(cost) = &p.cost else {panic!("No cost found on the posting")}; 211 | if amt.get_commodity() == cost.get_commodity() { 212 | panic!("A posting's cost must be of a different commodity than its amount"); 213 | } 214 | 215 | { 216 | // Cost breakdown 217 | // todo: virtual cost does not create a price 218 | 219 | let moment = xact.date.unwrap().and_hms_opt(0, 0, 0).unwrap(); 220 | let (breakdown, new_price_opt) = 221 | journal.commodity_pool.exchange(amt, cost, false, moment); 222 | // add price(s) 223 | if let Some(new_price) = new_price_opt { 224 | journal.commodity_pool.add_price_struct(new_price); 225 | } 226 | // TODO: this is probably redundant now? 227 | // if amt.commodity_index != cost.commodity_index { 228 | // log::debug!("adding price amt: {:?} date: {:?}, cost: {:?}", amt.commodity_index, moment, cost); 229 | 230 | // journal 231 | // .commodity_pool 232 | // .add_price(amt.commodity_index.unwrap(), moment, *cost); 233 | // } 234 | 235 | p.amount = Some(breakdown.amount); 236 | } 237 | } 238 | } 239 | 240 | // Handle null-amount post. 241 | if !null_post.is_null() { 242 | // If one post has no value at all, its value will become the inverse of 243 | // the rest. If multiple commodities are involved, multiple posts are 244 | // generated to balance them all. 245 | 246 | log::debug!("There was a null posting"); 247 | 248 | // let Some(null_post_index) = null_post 249 | // else {panic!("should not happen")}; 250 | // let Some(post) = journal.posts.get_mut(null_post_index) 251 | // else {panic!("should not happen")}; 252 | let post: &mut Post; 253 | unsafe { 254 | post = &mut *null_post; 255 | } 256 | 257 | // use inverse amount 258 | let amt = if balance.amounts.len() == 1 { 259 | // only one commodity 260 | let amt_bal = balance.amounts.iter().nth(0).unwrap(); 261 | 262 | log::debug!("null-post amount reversing {:?}", amt_bal); 263 | 264 | amt_bal.inverse() 265 | } else { 266 | // TODO: handle option when there are multiple currencies and only one blank posting. 267 | 268 | todo!("check this option") 269 | }; 270 | 271 | post.amount = Some(amt); 272 | 273 | null_post = std::ptr::null_mut(); 274 | } 275 | 276 | // TODO: Process Commodities? 277 | // TODO: Process Account records from Posts. 278 | } 279 | 280 | #[cfg(test)] 281 | mod tests { 282 | use crate::post::Post; 283 | 284 | use super::Xact; 285 | 286 | #[test] 287 | fn test_add_post() { 288 | let post = Post::new(std::ptr::null(), std::ptr::null(), None, None, None); 289 | let mut xact = Xact::default(); 290 | 291 | // act 292 | xact.add_post(post); 293 | 294 | // assert 295 | assert_eq!(1, xact.posts.len()); 296 | assert!(!xact.posts[0].xact_ptr.is_null()); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/account.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Account definition and operations 3 | */ 4 | 5 | use std::{collections::HashMap, ptr::addr_of, vec}; 6 | 7 | use crate::{balance::Balance, post::Post}; 8 | 9 | #[derive(Debug, PartialEq)] 10 | pub struct Account { 11 | pub(crate) parent: *const Account, 12 | pub name: String, 13 | // note 14 | // depth 15 | pub accounts: HashMap, 16 | pub posts: Vec<*const Post>, 17 | // deferred posts 18 | // value_expr 19 | fullname: String, 20 | } 21 | 22 | impl Account { 23 | pub fn new(name: &str) -> Self { 24 | Self { 25 | parent: std::ptr::null(), 26 | name: name.to_owned(), 27 | // note 28 | accounts: HashMap::new(), 29 | posts: vec![], 30 | fullname: "".to_string(), 31 | // post_indices: vec![], 32 | } 33 | } 34 | 35 | /// called from find_or_create. 36 | fn create_account(&self, first: &str) -> &Account { 37 | let mut new_account = Account::new(first); 38 | new_account.set_parent(self); 39 | 40 | let self_mut = self.get_account_mut(self as *const Account as *mut Account); 41 | 42 | self_mut.accounts.insert(first.into(), new_account); 43 | 44 | let Some(new_ref) = self.accounts.get(first) 45 | else {panic!("should not happen")}; 46 | 47 | log::debug!("The new account {:?} reference: {:p}", new_ref.name, new_ref); 48 | new_ref 49 | } 50 | 51 | pub fn fullname(&self) -> &str { 52 | // skip the master account. 53 | if self.parent.is_null() { 54 | return ""; 55 | } 56 | 57 | if !self.fullname.is_empty() { 58 | return &self.fullname; 59 | } 60 | 61 | let mut fullname = self.name.to_owned(); 62 | let mut first = self; 63 | 64 | while !first.parent.is_null() { 65 | // If there is a parent account, use it. 66 | first = self.get_account_mut(first.parent); 67 | 68 | if !first.name.is_empty() { 69 | fullname = format!("{}:{}", &first.name, fullname); 70 | } 71 | } 72 | 73 | self.set_fullname(fullname); 74 | 75 | &self.fullname 76 | } 77 | 78 | fn set_fullname(&self, fullname: String) { 79 | // alchemy? 80 | // let ptr = self as *const Account; 81 | let ptr = addr_of!(*self); 82 | let subject = self.get_account_mut(ptr); 83 | 84 | subject.fullname = fullname; 85 | } 86 | 87 | /// Finds account by full name. 88 | /// i.e. "Assets:Cash" 89 | pub fn find_account(&self, name: &str) -> Option<&Account> { 90 | if let Some(ptr) = self.find_or_create(name, false) { 91 | let acct = Account::from_ptr(ptr); 92 | return Some(acct); 93 | } else { 94 | return None; 95 | } 96 | } 97 | 98 | /// The variant with all the parameters. 99 | /// account_t * find_account(const string& name, bool auto_create = true); 100 | pub fn find_or_create(&self, name: &str, auto_create: bool) -> Option<*const Account> { 101 | // search for direct hit. 102 | if let Some(found) = self.accounts.get(name) { 103 | return Some(found); 104 | } 105 | 106 | // otherwise search for name parts in between the `:` 107 | 108 | let mut account: *const Account; 109 | let first: &str; 110 | let rest: &str; 111 | if let Some(separator_index) = name.find(':') { 112 | // Contains separators 113 | first = &name[..separator_index]; 114 | rest = &name[separator_index + 1..]; 115 | } else { 116 | // take all 117 | first = name; 118 | rest = ""; 119 | } 120 | 121 | if let Some(account_opt) = self.accounts.get(first) { 122 | // keep this value 123 | account = account_opt; 124 | } else { 125 | if !auto_create { 126 | return None; 127 | } 128 | 129 | account = self.create_account(first); 130 | } 131 | 132 | // Search recursively. 133 | if !rest.is_empty() { 134 | let acct = self.get_account_mut(account); 135 | account = acct.find_or_create(rest, auto_create).unwrap(); 136 | } 137 | 138 | Some(account) 139 | } 140 | 141 | pub fn from_ptr<'a>(acct_ptr: *const Account) -> &'a Account { 142 | unsafe { &*acct_ptr } 143 | } 144 | 145 | pub fn get_account_mut(&self, acct_ptr: *const Account) -> &mut Account { 146 | let mut_ptr = acct_ptr as *mut Account; 147 | unsafe { &mut *mut_ptr } 148 | } 149 | 150 | pub fn flatten_account_tree(&self) -> Vec<&Account> { 151 | let mut list: Vec<&Account> = vec![]; 152 | self.flatten(&mut list); 153 | list 154 | } 155 | 156 | /// Returns the amount of this account only. 157 | pub fn amount(&self) -> Balance { 158 | let mut bal = Balance::new(); 159 | 160 | for post_ptr in &self.posts { 161 | let post: &Post; 162 | unsafe { 163 | post = &**post_ptr; 164 | } 165 | if let Some(amt) = post.amount { 166 | bal.add(&amt); 167 | } 168 | } 169 | 170 | bal 171 | } 172 | 173 | fn flatten<'a>(&'a self, nodes: &mut Vec<&'a Account>) { 174 | // Push the current node to the Vec 175 | nodes.push(self); 176 | // 177 | let mut children: Vec<&Account> = self.accounts.values().into_iter().collect(); 178 | children.sort_unstable_by_key(|acc| &acc.name); 179 | // If the node has children, recursively call flatten on them 180 | for child in children { 181 | child.flatten(nodes); 182 | } 183 | } 184 | 185 | pub(crate) fn set_parent(&mut self, parent: &Account) { 186 | // Confirms the pointers are the same: 187 | // assert_eq!(parent as *const Account, addr_of!(*parent)); 188 | // self.parent = parent as *const Account; 189 | 190 | self.parent = addr_of!(*parent); 191 | 192 | // log::debug!("Setting the {:?} parent to {:?}, {:p}", self.name, parent.name, self.parent); 193 | } 194 | 195 | /// Returns the balance of this account and all sub-accounts. 196 | pub fn total(&self) -> Balance { 197 | let mut total = Balance::new(); 198 | 199 | // Sort the accounts by name 200 | let mut acct_names: Vec<_> = self.accounts.keys().collect(); 201 | acct_names.sort(); 202 | 203 | // iterate through children and get their totals 204 | for acct_name in acct_names { 205 | let subacct = self.accounts.get(acct_name).unwrap(); 206 | // let subacct = journal.get_account(*index); 207 | let subtotal = subacct.total(); 208 | 209 | total += subtotal; 210 | } 211 | 212 | // Add the balance of this account 213 | total += self.amount(); 214 | 215 | total 216 | } 217 | } 218 | 219 | #[cfg(test)] 220 | mod tests { 221 | use std::{io::Cursor, ptr::addr_of}; 222 | 223 | use crate::{amount::Quantity, journal::Journal, parse_file, parse_text, parser}; 224 | 225 | use super::Account; 226 | 227 | #[test] 228 | fn test_flatten() { 229 | let mut j = Journal::new(); 230 | let _acct = j.register_account("Assets:Cash"); 231 | let mut nodes: Vec<&Account> = vec![]; 232 | 233 | j.master.flatten(&mut nodes); 234 | 235 | assert_eq!(3, nodes.len()); 236 | } 237 | 238 | #[test] 239 | fn test_account_iterator() { 240 | let mut j = Journal::new(); 241 | let mut counter: u8 = 0; 242 | 243 | let _acct = j.register_account("Assets:Cash"); 244 | for _a in j.master.flatten_account_tree() { 245 | //println!("sub-account: {:?}", a); 246 | counter += 1; 247 | } 248 | 249 | assert_eq!(3, counter); 250 | } 251 | 252 | /// Search for an account by the full account name. 253 | #[test] 254 | fn test_fullname() { 255 | let input = r#"2023-05-01 Test 256 | Expenses:Food 10 EUR 257 | Assets:Cash 258 | "#; 259 | let mut journal = Journal::new(); 260 | parser::read_into_journal(Cursor::new(input), &mut journal); 261 | 262 | let account = journal.find_account("Expenses:Food").unwrap(); 263 | 264 | let actual = account.fullname(); 265 | 266 | assert_eq!(5, journal.master.flatten_account_tree().len()); 267 | assert_eq!("Food", account.name); 268 | assert_eq!("Expenses:Food", actual); 269 | } 270 | 271 | /// Test parsing of Amount 272 | #[test] 273 | fn test_amount_parsing() { 274 | let mut journal = Journal::new(); 275 | 276 | // act 277 | parse_file("tests/basic.ledger", &mut journal); 278 | 279 | let ptr = journal.find_account("Assets:Cash").unwrap(); 280 | let account = journal.get_account(ptr); 281 | 282 | let actual = account.amount(); 283 | 284 | // assert 285 | assert!(!actual.amounts.is_empty()); 286 | assert_eq!(Quantity::from(-20), actual.amounts[0].quantity); 287 | let commodity = actual.amounts[0].get_commodity().unwrap(); 288 | assert_eq!("EUR", commodity.symbol); 289 | } 290 | 291 | /// Test calculation of the account totals. 292 | #[test_log::test] 293 | fn test_total() { 294 | let mut journal = Journal::new(); 295 | parse_file("tests/two-xact-sub-acct.ledger", &mut journal); 296 | let ptr = journal.find_account("Assets").unwrap(); 297 | let assets = journal.get_account(ptr); 298 | 299 | // act 300 | let actual = assets.total(); 301 | 302 | // assert 303 | assert_eq!(1, actual.amounts.len()); 304 | log::debug!( 305 | "Amount 1: {:?}, {:?}", 306 | actual.amounts[0], 307 | actual.amounts[0].get_commodity() 308 | ); 309 | 310 | assert_eq!(actual.amounts[0].quantity, (-30).into()); 311 | assert_eq!(actual.amounts[0].get_commodity().unwrap().symbol, "EUR"); 312 | } 313 | 314 | #[test] 315 | fn test_parent_pointer() { 316 | let input = r#"2023-05-05 Payee 317 | Expenses 20 318 | Assets 319 | "#; 320 | let mut journal = Journal::new(); 321 | 322 | // act 323 | parse_text(input, &mut journal); 324 | 325 | let ptr = journal.master.find_account("Assets").unwrap(); 326 | let assets = journal.get_account(ptr); 327 | 328 | assert_eq!(&*journal.master as *const Account, assets.parent); 329 | } 330 | 331 | #[test] 332 | fn test_parent_pointer_after_fullname() { 333 | let input = r#"2023-05-05 Payee 334 | Expenses 20 335 | Assets 336 | "#; 337 | let mut journal = Journal::new(); 338 | parse_text(input, &mut journal); 339 | 340 | // test parent 341 | let ptr = journal.master.find_account("Assets").unwrap(); 342 | let assets = journal.get_account(ptr); 343 | 344 | assert_eq!(&*journal.master as *const Account, assets.parent); 345 | 346 | // test fullname 347 | let assets_fullname = journal.master.accounts.get("Assets").unwrap().fullname(); 348 | let expenses_fullname = journal.master.accounts.get("Expenses").unwrap().fullname(); 349 | 350 | assert_eq!("Assets", assets_fullname); 351 | assert_eq!("Expenses", expenses_fullname); 352 | 353 | // test parent 354 | let ptr = journal.master.find_account("Assets").unwrap(); 355 | let assets = journal.get_account(ptr); 356 | 357 | assert_eq!(&*journal.master as *const Account, assets.parent); 358 | } 359 | 360 | #[test_log::test] 361 | fn test_parent_pointers() { 362 | let input = r#"2023-05-05 Payee 363 | Expenses:Groceries 20 364 | Assets:Cash 365 | "#; 366 | let mut journal = Journal::new(); 367 | 368 | parse_text(input, &mut journal); 369 | 370 | // expenses 371 | let expenses = journal.master.find_account("Expenses").unwrap(); 372 | assert_eq!(&*journal.master as *const Account, expenses.parent); 373 | 374 | // groceries 375 | let groceries = expenses.find_account("Groceries").unwrap(); 376 | assert_eq!(expenses as *const Account, groceries.parent); 377 | 378 | // assets 379 | let assets = journal.master.find_account("Assets").unwrap(); 380 | assert_eq!(&*journal.master as *const Account, assets.parent); 381 | 382 | // confirm that addr_of! and `as *const` are the same. 383 | assert_eq!(assets as *const Account, addr_of!(*assets)); 384 | 385 | // cash 386 | let cash = assets.find_account("Cash").unwrap(); 387 | assert_eq!(assets as *const Account, cash.parent); 388 | 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/option.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Processes command arguments and options. 3 | * 4 | * option.cc 5 | * 6 | * In Ledger, these are handled in lookup() and lookup_option() funcions in: 7 | * - global 8 | * - session 9 | * - report 10 | */ 11 | 12 | pub enum Kind { 13 | UNKNOWN, 14 | FUNCTION, 15 | OPTION, 16 | PRECOMMAND, 17 | COMMAND, 18 | DIRECTIVE, 19 | FORMAT, 20 | } 21 | 22 | /// Recognize arguments. 23 | /// returns (commands, options) 24 | /// Commands are application commands, with optional arguments, ie "accounts Asset" 25 | /// Options are the options with '-' or "--" prefix, ie "-f " 26 | pub fn process_arguments(args: Vec) -> (Vec, InputOptions) { 27 | let mut options: Vec = vec![]; 28 | let mut commands: Vec = vec![]; 29 | 30 | // iterate through the list 31 | let mut iter = args.iter(); 32 | while let Some(arg) = iter.next() { 33 | if !arg.starts_with('-') { 34 | // otherwise return 35 | commands.push(arg.to_owned()); 36 | continue; 37 | } 38 | 39 | // otherwise, it's an argument 40 | // if an item contains "-f", it is a real argument 41 | 42 | // long option 43 | if arg.starts_with("--") { 44 | // long argument 45 | if arg.len() == 2 { 46 | // it's a --, ending options processing 47 | todo!("handle this case?") 48 | } else if arg.len() == 1 { 49 | panic!("illegar option {}", arg) 50 | } 51 | 52 | // todo: check if there is '=' contained 53 | // else 54 | let option_name = &arg[2..]; 55 | 56 | // TODO: find_option(option_name); 57 | 58 | // TODO: get argument value 59 | 60 | // TODO: process_option(); 61 | 62 | todo!("complete") 63 | } else { 64 | // single-char option 65 | 66 | let mut option_queue = vec![]; 67 | 68 | // iterate through all characters and identify options, 69 | for (i, c) in arg.char_indices() { 70 | if i == 0 { 71 | // skipping the first ('-'). 72 | continue; 73 | } 74 | 75 | // check for a valid option and if it requires an argument? 76 | // Also links to a handler. 77 | // option = find_option(c); 78 | // ^^^ This is done later. Just parse here. 79 | 80 | let mut temp_option = String::from('-'); 81 | temp_option.push(c); 82 | 83 | // add option to the option queue 84 | option_queue.push(temp_option); 85 | } 86 | 87 | // multiple arguments are possible after "-". 88 | // The values come after the options. 89 | // Iterate through the option_queue and retrieve the value if required. 90 | for option in option_queue { 91 | // todo: there needs to be an indicator if the option requires a value. 92 | // if requires_value && 93 | if let Some(value) = iter.next() { 94 | // let mut whence = String::from("-"); 95 | // whence.push(arg.chars().nth(0).unwrap()); 96 | 97 | // TODO: check for validity, etc. 98 | // the magic seems to be happening here. 99 | // process_option(whence, Some(value.to_owned())); 100 | 101 | // for now, just add 102 | options.push(option); 103 | options.push(value.to_owned()); 104 | } else { 105 | panic!("Missing option argument for {}", arg); 106 | } 107 | } 108 | } 109 | } 110 | 111 | // Convert input options 112 | let input_options = get_input_options(options); 113 | 114 | (commands, input_options) 115 | } 116 | 117 | /// Searches through scopes for the option with the given letter. 118 | /// Then links to a handler function(?). 119 | fn find_option(letter: char) { 120 | let mut name = String::from(letter); 121 | name.push('_'); 122 | 123 | // lookup first checks Session 124 | session_lookup(Kind::OPTION, &name); 125 | 126 | todo!() 127 | } 128 | 129 | /// find_option() from global.cc 130 | fn lookup_option_global(kind: Kind, letter: char) { 131 | match kind { 132 | Kind::PRECOMMAND => { 133 | // p => push, pop 134 | } 135 | _ => todo!(), 136 | } 137 | 138 | // adhiostv 139 | match letter { 140 | 's' => todo!("script"), 141 | 't' => todo!("trace"), 142 | _ => todo!("other chars"), 143 | } 144 | } 145 | 146 | fn process_option(whence: String, value: Option) { 147 | let mut args = vec![]; 148 | 149 | // add the argument and the value to a collection 150 | args.push(whence); 151 | 152 | match value { 153 | Some(val) => args.push(val), 154 | None => (), 155 | } 156 | 157 | // TODO: check for validity 158 | // if wants_arg ... 159 | // there have to be 2 args. 160 | } 161 | 162 | /// Lookup options for session 163 | fn session_lookup(kind: Kind, name: &str) { 164 | let option = name.chars().nth(0).unwrap(); 165 | 166 | match kind { 167 | Kind::FUNCTION => todo!(), 168 | Kind::OPTION => { 169 | // handler = 170 | session_lookup_option(option) 171 | // TODO: make_opt_handler(Session, handler) 172 | } 173 | _ => todo!(), 174 | } 175 | } 176 | 177 | /// Searches for a short-version option. i.e. -f for file 178 | fn session_lookup_option(option: char) { 179 | match option { 180 | 'Q' => todo!(), 181 | 'Z' => todo!(), 182 | 'c' => todo!(), 183 | 'd' => todo!(), 184 | 'e' => todo!(), 185 | 'f' => { 186 | // OPT_(file_) 187 | todo!("option file_") 188 | } 189 | 'i' => todo!(), 190 | 'l' => todo!(), 191 | 'm' => todo!(), 192 | 'n' => todo!(), 193 | 'p' => todo!(), 194 | 'r' => todo!(), 195 | 's' => todo!(), 196 | 't' => todo!(), 197 | 'v' => todo!(), 198 | _ => todo!("return NULL"), 199 | } 200 | } 201 | 202 | /// Lookup options for reports 203 | fn lookup_report(kind: Kind, name: &str) { 204 | let letter: char = name.chars().nth(0).unwrap(); 205 | 206 | match kind { 207 | Kind::FUNCTION => { 208 | todo!() 209 | } 210 | Kind::COMMAND => { 211 | match letter { 212 | 'a' => { 213 | if name == "accounts" { 214 | todo!("accounts") 215 | // POSTS_REPORTER(report_accounts) 216 | } 217 | } 218 | 'b' => { 219 | // FORMATTED_ACCOUNTS_REPORTER(balance_format_) 220 | todo!("balance") 221 | // or budget 222 | } 223 | 224 | // cdel 225 | 'p' => { 226 | // print, 227 | // POSTS_REPORTER(print_xacts) 228 | 229 | // prices, 230 | // pricedb, 231 | // FORMATTED_COMMODITIES_REPORTER(pricedb_format_) 232 | 233 | // pricemap, 234 | // report_t::pricemap_command 235 | 236 | // payees 237 | // POSTS_REPORTER(report_payees) 238 | } 239 | 'r' => { 240 | // r, reg, register 241 | // FORMATTED_POSTS_REPORTER(register_format_) 242 | 243 | // reload 244 | // report_t::reload_command 245 | 246 | todo!("register") 247 | } 248 | 249 | // stx 250 | _ => todo!("the rest"), 251 | } 252 | } 253 | Kind::PRECOMMAND => { 254 | match letter { 255 | 'a' => { 256 | todo!("args") 257 | // WRAP_FUNCTOR(query_command) 258 | } 259 | // efgpqst 260 | _ => todo!("handle pre-command"), 261 | } 262 | } 263 | _ => todo!("handle"), 264 | } 265 | 266 | todo!("go through the report options") 267 | } 268 | 269 | fn lookup_option_report(letter: char) { 270 | // t: 271 | // amount, tail, total, total_data, truncate, total_width, time_report 272 | 273 | match letter { 274 | // %ABCDEFGHIJLMOPRSTUVWXYabcdefghijlmnopqrstuvwy 275 | 'G' => todo!("gain"), // OPT_CH(gain) 276 | 'S' => todo!("sort"), // OPT_CH(sort_) 277 | 'X' => todo!("exchange"), // OPT_CH(exchange_) 278 | 'a' => { 279 | // OPT(abbrev_len_); 280 | // else OPT_(account_); 281 | // else OPT(actual); 282 | // else OPT(add_budget); 283 | // else OPT(amount_); 284 | // else OPT(amount_data); 285 | // else OPT_ALT(primary_date, actual_dates); 286 | // else OPT(anon); 287 | // else OPT_ALT(color, ansi); 288 | // else OPT(auto_match); 289 | // else OPT(aux_date); 290 | // else OPT(average); 291 | // else OPT(account_width_); 292 | // else OPT(amount_width_); 293 | // else OPT(average_lot_prices); 294 | todo!() 295 | } 296 | _ => todo!("the rest"), 297 | } 298 | } 299 | 300 | pub struct InputOptions { 301 | pub filenames: Vec, 302 | } 303 | 304 | impl InputOptions { 305 | pub fn new() -> Self { 306 | Self { filenames: vec![] } 307 | } 308 | } 309 | 310 | pub(crate) fn get_input_options(options: Vec) -> InputOptions { 311 | let mut result = InputOptions::new(); 312 | 313 | let mut iter = options.into_iter(); 314 | loop { 315 | match iter.next() { 316 | Some(opt) => { 317 | match opt.as_str() { 318 | "-f" => { 319 | let Some(filename) = iter.next() else { panic!("missing filename argument!"); }; 320 | result.filenames.push(filename); 321 | } 322 | _ => panic!("Unrecognized argument!") 323 | } 324 | }, 325 | None => break, 326 | } 327 | } 328 | result 329 | } 330 | 331 | #[cfg(test)] 332 | mod tests { 333 | use shell_words::split; 334 | 335 | use crate::option::{get_input_options, process_arguments}; 336 | 337 | #[test] 338 | fn test_process_arguments() { 339 | let args = split("accounts -f basic.ledger").unwrap(); 340 | 341 | let (commands, options) = process_arguments(args); 342 | 343 | assert_eq!(1, commands.len()); 344 | assert_eq!("accounts", commands[0]); 345 | 346 | // options 347 | assert_eq!(1, options.filenames.len()); 348 | assert_eq!("basic.ledger", options.filenames[0]); 349 | } 350 | 351 | // #[test] 352 | // fn test_process_multiple_arguments() { 353 | // let args = split("cmd -ab value_a value_b").unwrap(); 354 | 355 | // let (commands, options) = process_arguments(args); 356 | 357 | // assert_eq!(1, commands.len()); 358 | // assert_eq!("cmd", commands[0]); 359 | 360 | // // options 361 | // assert_eq!(4, options.len()); 362 | 363 | // assert_eq!("-a", options[0]); 364 | // assert_eq!("value_a", options[1]); 365 | 366 | // assert_eq!("-b", options[2]); 367 | // assert_eq!("value_b", options[3]); 368 | // } 369 | 370 | #[test] 371 | fn test_multiple_commands() { 372 | let args: Vec = shell_words::split("accounts b -f tests/minimal.ledger").unwrap(); 373 | 374 | let (commands, options) = process_arguments(args); 375 | 376 | assert_eq!(2, commands.len()); 377 | assert_eq!("accounts", commands[0]); 378 | assert_eq!("b", commands[1]); 379 | } 380 | 381 | #[test] 382 | fn test_get_file_arg() { 383 | let command = "b -f tests/minimal.ledger"; 384 | let args = shell_words::split(command).expect("arguments parsed"); 385 | let expected = "tests/minimal.ledger"; 386 | 387 | let (commands, options) = process_arguments(args); 388 | 389 | let actual = options.filenames.first().unwrap(); 390 | assert_eq!(expected, actual.as_str()); 391 | } 392 | 393 | #[test] 394 | fn test_multiple_filenames() { 395 | let args = split("accounts -f one -f two").unwrap(); 396 | 397 | let (commands, options) = process_arguments(args); 398 | 399 | assert_eq!(2, options.filenames.len()); 400 | assert_eq!("one", options.filenames[0]); 401 | assert_eq!("two", options.filenames[1]); 402 | } 403 | 404 | #[test] 405 | fn test_creating_input_options() { 406 | let options: Vec = vec!["-f".into(), "one".into(), "-f".into(), "two".into()]; 407 | 408 | let actual = get_input_options(options); 409 | 410 | assert_eq!(2, actual.filenames.len()); 411 | assert_eq!("one", actual.filenames[0]); 412 | assert_eq!("two", actual.filenames[1]); 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /src/history.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Commodity price history 3 | * 4 | * history.h + .cc 5 | * 6 | * Commodities are nodes (vertices). 7 | * 8 | * All edges are weights computed as the absolute difference between 9 | * the reference time of a search and a known price point. A 10 | * filtered_graph is used to select the recent price point to the 11 | * reference time before performing the search. 12 | */ 13 | 14 | use std::{ 15 | collections::BTreeMap, 16 | ops::{Deref, DerefMut}, 17 | }; 18 | 19 | use chrono::{Local, NaiveDateTime}; 20 | use petgraph::{algo::astar, stable_graph::NodeIndex, Graph}; 21 | 22 | use crate::{ 23 | amount::{Amount, Quantity}, 24 | commodity::{self, Commodity, PricePoint}, 25 | pool::CommodityIndex, 26 | }; 27 | 28 | type PriceMap = BTreeMap; 29 | 30 | /// commodity_history_t or commodity_history_impl_t? 31 | // pub(crate) struct CommodityHistory { 32 | // pub(crate) graph: Graph<*const Commodity, PriceMap>, 33 | // } 34 | 35 | // pub(crate) type CommodityHistory = Graph; 36 | pub(crate) struct CommodityHistory(Graph<*const Commodity, PriceMap>); 37 | 38 | impl CommodityHistory { 39 | pub fn new() -> Self { 40 | Self(Graph::new()) 41 | } 42 | 43 | /// Adds the commodity to the commodity graph. 44 | pub fn add_commodity(&mut self, commodity: *const Commodity) -> CommodityIndex { 45 | self.add_node(commodity) 46 | } 47 | 48 | /// Adds a new price point. 49 | /// i.e. 1 EUR = 1.12 USD 50 | /// source: EUR 51 | /// date 52 | /// price: 1.12 USD 53 | pub fn add_price( 54 | &mut self, 55 | source_ptr: *const Commodity, 56 | datetime: NaiveDateTime, 57 | price: Amount, 58 | ) { 59 | let source = commodity::from_ptr(source_ptr); 60 | assert!(Some(source) != price.get_commodity()); 61 | 62 | log::debug!( 63 | "adding price for {:?}, date: {:?}, price: {:?}", 64 | source.symbol, 65 | datetime, 66 | price 67 | ); 68 | 69 | let index = match self.0.find_edge( 70 | source.graph_index.unwrap(), 71 | price.get_commodity().unwrap().graph_index.unwrap(), 72 | ) { 73 | Some(index) => index, 74 | None => { 75 | let dest = price.get_commodity().unwrap().graph_index.unwrap(); 76 | self.add_edge(source.graph_index.unwrap(), dest, PriceMap::new()) 77 | } 78 | }; 79 | 80 | let prices = self.edge_weight_mut(index).unwrap(); 81 | 82 | // Add the price to the price history. 83 | // prices.entry(key) ? 84 | prices.insert(datetime, price.quantity); 85 | } 86 | 87 | pub fn get_commodity(&self, index: NodeIndex) -> &Commodity { 88 | let ptr = self.node_weight(index).expect("index should be valid"); 89 | unsafe { &**ptr } 90 | } 91 | 92 | // pub fn get_commodity_mut(&mut self, index: NodeIndex) -> &mut Commodity { 93 | // let ptr = self.node_weight_mut(index).expect("index should be valid"); 94 | // unsafe { &mut ptr.read() } 95 | // } 96 | 97 | pub fn map_prices(&self) { 98 | todo!() 99 | } 100 | 101 | /// find_price(source, target, moment, oldest); 102 | pub fn find_price( 103 | &self, 104 | source_ptr: *const Commodity, 105 | target_ptr: *const Commodity, 106 | moment: NaiveDateTime, 107 | oldest: NaiveDateTime, 108 | ) -> Option { 109 | assert_ne!(source_ptr, target_ptr); 110 | 111 | let source: CommodityIndex = commodity::from_ptr(source_ptr).graph_index.unwrap(); 112 | let target: CommodityIndex = commodity::from_ptr(target_ptr).graph_index.unwrap(); 113 | 114 | // Search for the shortest path using a*. 115 | let shortest_path = astar(&self.0, source, |finish| finish == target, |e| 1, |_| 0); 116 | if shortest_path.is_none() { 117 | return None; 118 | } 119 | 120 | // Get the price. 121 | let Some((distance, path)) = shortest_path else { 122 | panic!("should not happen") 123 | }; 124 | 125 | log::debug!( 126 | "Shortest path found: hops={:?}, nodes={:?}", 127 | distance, 128 | &path 129 | ); 130 | 131 | let (date, quantity); 132 | if distance == 1 { 133 | // direct link 134 | let Some((&x, &y)) = self.get_direct_price(source, target) else { 135 | panic!("should not happen!") 136 | }; 137 | date = x; 138 | quantity = y; 139 | } else { 140 | // else calculate the rate 141 | let (x, y) = self.calculate_rate(source, target_ptr, path); 142 | date = x; 143 | quantity = y; 144 | } 145 | let pp = PricePoint::new(date, Amount::new(quantity, Some(target_ptr))); 146 | return Some(pp); 147 | } 148 | 149 | /// Calculate the exchange rate by using the existing rates, through multiple 150 | /// hops through intermediaries between two commodities. 151 | /// i.e. EUR->AUD->USD. 152 | /// 153 | /// The final date of the price, when multiple hops involved, is the least recent date of the available 154 | /// intermediate rates. 155 | fn calculate_rate(&self, source: CommodityIndex, target_ptr: *const Commodity, path: Vec) -> (NaiveDateTime, Quantity) { 156 | let mut result = Amount::new(Quantity::ONE, Some(target_ptr)); 157 | let mut temp_source = source; 158 | let mut least_recent: NaiveDateTime = Local::now().naive_local(); 159 | 160 | // iterate through intermediate rates and calculate (multiply). 161 | for temp_target in path { 162 | // skip self 163 | if temp_target == temp_source { 164 | continue; 165 | } 166 | 167 | // include the datetime 168 | // get the price 169 | let (&temp_date, &temp_quantity) = self 170 | .get_direct_price( 171 | temp_source, 172 | temp_target, 173 | ) 174 | .expect("price"); // , moment, oldest); 175 | 176 | // date 177 | if temp_date < least_recent { 178 | least_recent = temp_date; 179 | } 180 | 181 | // calculate the amount. 182 | result.quantity *= temp_quantity; 183 | // temp_price.when 184 | 185 | log::debug!("intermediate price from {:?} to {:?} = {:?} on {:?}", temp_source, temp_target, temp_quantity, temp_date); 186 | 187 | // source for the next leg 188 | temp_source = temp_target; 189 | } 190 | 191 | let when = least_recent; 192 | 193 | // TODO: add to the price map. 194 | // self.add_price(commodity_index, datetime, price); 195 | 196 | // Some(PricePoint::new(when, result)) 197 | (when, result.quantity) 198 | } 199 | 200 | /// Finds the price 201 | /// i.e. 1 EUR = 1.10 USD 202 | /// source: EUR 203 | /// target: USD 204 | pub fn get_direct_price( 205 | &self, 206 | source: CommodityIndex, 207 | target: CommodityIndex, 208 | ) -> Option<(&NaiveDateTime, &Quantity)> { 209 | let direct = self.find_edge(source, target); 210 | if let Some(edge_index) = direct { 211 | let price_history = self.edge_weight(edge_index).unwrap(); 212 | if let Some(price_point) = get_latest_price(price_history) { 213 | Some(price_point) 214 | } else { 215 | None 216 | } 217 | } else { 218 | None 219 | } 220 | } 221 | 222 | fn print_map(&self) { 223 | todo!() 224 | } 225 | } 226 | 227 | impl Deref for CommodityHistory { 228 | // specify the target type as i32 229 | type Target = Graph<*const Commodity, PriceMap>; 230 | 231 | // define the deref method that returns a reference to the inner value 232 | fn deref(&self) -> &Self::Target { 233 | &self.0 234 | } 235 | } 236 | 237 | impl DerefMut for CommodityHistory { 238 | fn deref_mut(&mut self) -> &mut Self::Target { 239 | &mut self.0 240 | } 241 | } 242 | 243 | /// Returns the latest (newest) price from the prices map. 244 | /// 245 | /// BTree is doing all the work here, sorting the keys (dates). 246 | fn get_latest_price( 247 | prices: &BTreeMap, 248 | ) -> Option<(&NaiveDateTime, &Quantity)> { 249 | if prices.is_empty() { 250 | return None; 251 | } 252 | 253 | // BTreeMap orders by key (date) by default. 254 | prices.last_key_value() 255 | } 256 | 257 | /// Represents a price of a commodity. 258 | /// i.e. (1) EUR = 1.20 AUD 259 | /// 260 | /// TODO: Compare with price_point_t, which does not have the commodity_index, 261 | /// if one type would be enough. 262 | #[derive(Debug)] 263 | pub struct Price { 264 | /// The commodity being priced. 265 | pub commodity: *const Commodity, 266 | /// Point in time at which the price is valid. 267 | pub datetime: NaiveDateTime, 268 | /// Price of the commodity. i.e. 1.20 AUD 269 | pub price: Amount, 270 | } 271 | 272 | impl Price { 273 | pub fn new(commodity: *const Commodity, datetime: NaiveDateTime, cost: Amount) -> Self { 274 | Self { 275 | commodity, 276 | datetime, 277 | price: cost, 278 | } 279 | } 280 | pub fn get_commodity(&self) -> &Commodity { 281 | unsafe { &*self.commodity } 282 | } 283 | } 284 | 285 | #[cfg(test)] 286 | mod tests { 287 | use chrono::Local; 288 | use petgraph::stable_graph::NodeIndex; 289 | 290 | use super::{get_latest_price, CommodityHistory, PriceMap}; 291 | use crate::{ 292 | amount::{Amount, Quantity}, 293 | commodity::{self, Commodity, PricePoint}, 294 | journal::Journal, 295 | parser::{parse_amount, parse_datetime}, 296 | }; 297 | 298 | #[test] 299 | fn test_adding_commodity() { 300 | let mut hist = CommodityHistory::new(); 301 | let c = Commodity::new("EUR"); 302 | 303 | // Act 304 | let cdty_index = hist.add_commodity(&c); 305 | 306 | // Assert 307 | assert_eq!(1, hist.node_count()); 308 | // TODO: assert_eq!("EUR", hist.node_weight(cdty_index).unwrap().symbol); 309 | } 310 | 311 | #[test] 312 | fn test_get_commodity() { 313 | let mut hist = CommodityHistory::new(); 314 | let c = Commodity::new("EUR"); 315 | let id = hist.add_commodity(&c); 316 | 317 | let actual = hist.get_commodity(id); 318 | 319 | assert_eq!("EUR", actual.symbol); 320 | } 321 | 322 | #[test] 323 | fn test_adding_price() { 324 | // Arrange 325 | let mut journal = Journal::new(); 326 | let eur = journal.commodity_pool.create("EUR", None); 327 | let usd = journal.commodity_pool.create("USD", None); 328 | let local = Local::now(); 329 | let today = local.naive_local(); 330 | let price = Amount::new(25.into(), Some(usd)); 331 | let hist = &mut journal.commodity_pool.commodity_history; 332 | 333 | // Act 334 | hist.add_price(eur, today, price); 335 | 336 | // Assert 337 | assert_eq!(2, hist.node_count()); 338 | assert_eq!(1, hist.edge_count()); 339 | 340 | let edge = hist.edge_weights().nth(0).unwrap(); 341 | assert_eq!(&Quantity::from(25), edge.values().nth(0).unwrap()); 342 | } 343 | 344 | #[test] 345 | fn test_index() { 346 | let mut graph = CommodityHistory::new(); 347 | let eur = Commodity::new("EUR"); 348 | let x = graph.add_commodity(&eur); 349 | let y = x.index(); 350 | let z = NodeIndex::new(y); 351 | 352 | assert_eq!(z, x); 353 | } 354 | 355 | /// Gets the latest price. 356 | #[test] 357 | fn test_get_latest_price() { 358 | let mut prices = PriceMap::new(); 359 | prices.insert(parse_datetime("2023-05-05").unwrap(), Quantity::from(10)); 360 | prices.insert(parse_datetime("2023-05-01").unwrap(), Quantity::from(20)); 361 | let newest_date = parse_datetime("2023-05-10").unwrap(); 362 | prices.insert(newest_date, Quantity::from(30)); 363 | prices.insert(parse_datetime("2023-05-02").unwrap(), Quantity::from(40)); 364 | 365 | // act 366 | let Some((&actual_date, &actual_quantity)) = get_latest_price(&prices) else { 367 | panic!("Should not happen!") 368 | }; 369 | 370 | // assert!(actual.is_some()); 371 | assert_eq!(newest_date, actual_date); 372 | assert_eq!(Quantity::from(30), actual_quantity); 373 | } 374 | 375 | #[test] 376 | fn test_get_direct_price() { 377 | let journal = &mut Journal::new(); 378 | let eur_ptr = journal.commodity_pool.create("EUR", None); 379 | let usd_ptr = journal.commodity_pool.create("USD", None); 380 | // add price 381 | let date = parse_datetime("2023-05-01").unwrap(); 382 | let price = parse_amount("1.20 USD", journal).unwrap(); 383 | assert!(!price.get_commodity().unwrap().symbol.is_empty()); 384 | journal.commodity_pool.add_price(eur_ptr, date, price); 385 | 386 | // act 387 | let (actual_date, actual_quantity) = journal 388 | .commodity_pool 389 | .commodity_history 390 | .get_direct_price( 391 | commodity::from_ptr(eur_ptr).graph_index.unwrap(), 392 | commodity::from_ptr(usd_ptr).graph_index.unwrap(), 393 | ) 394 | .unwrap(); 395 | 396 | // assert 397 | // assert_eq!(eur_ptr, actual.price.commodity); 398 | // assert_eq!("2023-05-01 00:00:00", actual.datetime.to_string()); 399 | // assert_eq!(actual.price.quantity, 1.20.into()); 400 | assert_eq!(actual_date, &date); 401 | assert_eq!(Quantity::from("1.20"), *actual_quantity); 402 | } 403 | 404 | /// Test commodity exchange when there is a direct rate. EUR->USD 405 | #[test_log::test] 406 | fn test_find_price_1_hop() { 407 | let mut journal = Journal::new(); 408 | // add commodities 409 | let eur_ptr = journal.commodity_pool.create("EUR", None); 410 | let usd_ptr = journal.commodity_pool.create("USD", None); 411 | // add price 412 | let date = parse_datetime("2023-05-01").unwrap(); 413 | let price = parse_amount("1.20 USD", &mut journal).unwrap(); 414 | let oldest = Local::now().naive_local(); 415 | journal.commodity_pool.add_price(eur_ptr, date, price); 416 | 417 | // act 418 | let actual = journal 419 | .commodity_pool 420 | .commodity_history 421 | .find_price(eur_ptr, usd_ptr, date, oldest) 422 | .expect("price found"); 423 | 424 | // assert 425 | assert_eq!(actual.when, date); 426 | assert_eq!(actual.price.quantity, "1.20".into()); 427 | assert_eq!( 428 | actual.price.get_commodity().unwrap(), 429 | commodity::from_ptr(usd_ptr) 430 | ); 431 | } 432 | 433 | /// Test calculating the rate. 434 | /// EUR->AUD->USD 435 | /// where 1 EUR = 2 AUD, 1 AUD = 3 USD => 1 EUR = 2 AUD = 6 USD. 436 | #[test_log::test] 437 | fn test_calculate_rate() { 438 | // arrange 439 | let mut journal = Journal::new(); 440 | let eur_ptr = journal.commodity_pool.create("EUR", None); 441 | let aud_ptr = journal.commodity_pool.create("AUD", None); 442 | let usd_ptr = journal.commodity_pool.create("USD", None); 443 | let source = commodity::from_ptr(eur_ptr).graph_index.unwrap(); 444 | let path = vec![NodeIndex::new(0), NodeIndex::new(1), NodeIndex::new(2)]; 445 | // prices 446 | let date = parse_datetime("2023-05-01").unwrap(); 447 | // 1 EUR = 2 AUD 448 | let two_aud = parse_amount("2 AUD", &mut journal).unwrap(); 449 | journal.commodity_pool.add_price(eur_ptr, date, two_aud); 450 | // 1 AUD = 3 USD 451 | let three_usd = parse_amount("3 USD", &mut journal).unwrap(); 452 | journal.commodity_pool.add_price(aud_ptr, date, three_usd); 453 | 454 | // act 455 | let (actual_date, actual_quantity) = journal.commodity_pool.commodity_history.calculate_rate(source, usd_ptr, path); 456 | 457 | // assert 458 | assert_eq!(date, actual_date); 459 | assert_eq!(Quantity::from_str("6").unwrap(), actual_quantity); 460 | } 461 | 462 | /// Test commodity exchange via an intermediary. EUR->AUD->USD 463 | #[test_log::test] 464 | fn test_find_price_2_hops() { 465 | let mut journal = Journal::new(); 466 | let eur_ptr = journal.commodity_pool.create("EUR", None); 467 | let aud_ptr = journal.commodity_pool.create("AUD", None); 468 | let usd_ptr = journal.commodity_pool.create("USD", None); 469 | // prices 470 | let date = parse_datetime("2023-05-01").unwrap(); 471 | // 1 EUR = 2 AUD 472 | let euraud = parse_amount("2 AUD", &mut journal).unwrap(); 473 | journal.commodity_pool.add_price(eur_ptr, date, euraud); 474 | // 1 AUD = 3 USD 475 | let audusd = parse_amount("3 USD", &mut journal).unwrap(); 476 | journal.commodity_pool.add_price(aud_ptr, date, audusd); 477 | let oldest = Local::now().naive_local(); 478 | 479 | // act 480 | let actual = journal 481 | .commodity_pool 482 | .commodity_history 483 | .find_price(eur_ptr, usd_ptr, date, oldest) 484 | .unwrap(); 485 | 486 | // assert 487 | assert_eq!(actual.when, date); 488 | // 1 EUR = 2 AUD = 6 USD 489 | assert_eq!(actual.price.quantity, 6.into()); 490 | assert_eq!(actual.price.get_commodity().unwrap().symbol, "USD"); 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/pool.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Commodity Pool 3 | * 4 | * The Commodities collection contains all the commodities. 5 | * 6 | */ 7 | use std::collections::HashMap; 8 | 9 | use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; 10 | use petgraph::stable_graph::NodeIndex; 11 | 12 | use crate::{ 13 | amount::{Amount, Quantity}, 14 | annotate::Annotation, 15 | commodity::Commodity, 16 | history::{CommodityHistory, Price}, 17 | parser::{ISO_DATE_FORMAT, ISO_TIME_FORMAT}, 18 | scanner, 19 | }; 20 | 21 | /// Commodity Index is the index of the node in the history graph. 22 | pub type CommodityIndex = NodeIndex; 23 | 24 | pub struct CommodityPool { 25 | /// Map (symbol, commodity) 26 | // pub(crate) commodities: HashMap, 27 | pub(crate) commodities: HashMap, 28 | // pub(crate) commodities: HashMap>, 29 | /// Commodity annotations. symbol, annotation 30 | pub(crate) annotated_commodities: HashMap, 31 | pub(crate) commodity_history: CommodityHistory, 32 | null_commodity: *const Commodity, 33 | default_commodity: *const Commodity, 34 | // pricedb 35 | } 36 | 37 | impl CommodityPool { 38 | pub fn new() -> Self { 39 | Self { 40 | commodities: HashMap::new(), 41 | annotated_commodities: HashMap::new(), 42 | commodity_history: CommodityHistory::new(), 43 | null_commodity: std::ptr::null(), 44 | default_commodity: std::ptr::null(), 45 | } 46 | } 47 | 48 | pub fn add_price_struct(&mut self, price: Price) { 49 | self.commodity_history 50 | .add_price(price.get_commodity(), price.datetime, price.price); 51 | } 52 | 53 | /// Adds a new price point. 54 | /// i.e. (1) EUR = 1.12 USD 55 | /// commodity_index = index of the commodity, i.e. `EUR` 56 | /// date = date of pricing 57 | /// price: Amount = the price of the commodity, i.e. `1.12 USD` 58 | pub fn add_price(&mut self, commodity: *const Commodity, datetime: NaiveDateTime, price: Amount) { 59 | self.commodity_history.add_price(commodity, datetime, price) 60 | } 61 | 62 | /// Creates a new Commodity for the given Symbol. 63 | pub fn create(&mut self, symbol: &str, annotation_option: Option) -> *const Commodity { 64 | // todo: handle double quotes 65 | 66 | let mut c = Commodity::new(symbol); 67 | 68 | // Annotation 69 | if let Some(ann) = annotation_option { 70 | // Create an annotated commodity. 71 | // TODO: assert that the commodity does not have an annotation already. 72 | 73 | c.annotated = true; 74 | 75 | // Add annotation 76 | self.annotated_commodities.insert(symbol.to_owned(), ann); 77 | } 78 | 79 | // move to map 80 | self.commodities.insert(symbol.to_string(), c); 81 | // get the new address. 82 | let new_commodity = self.commodities.get(symbol).unwrap(); 83 | 84 | // add to price history graph. 85 | let cdty_ptr = new_commodity as *const Commodity; 86 | // log::debug!("commodity pointer: {:?} for {:?}", cdty_ptr, symbol); 87 | let i = self.commodity_history.add_commodity(cdty_ptr); 88 | 89 | unsafe { 90 | let mut_ptr = cdty_ptr as *mut Commodity; 91 | let mut_cdty = &mut *mut_ptr; 92 | mut_cdty.graph_index = Some(i); 93 | } 94 | 95 | log::debug!("Commodity {:?} created. index: {:?}, addr:{:?}", symbol, i, cdty_ptr); 96 | 97 | cdty_ptr 98 | } 99 | 100 | pub fn find(&self, symbol: &str) -> Option<&Commodity> { 101 | self.commodities.get(symbol) 102 | } 103 | 104 | pub fn find_index(&self, symbol: &str) -> Option { 105 | let x = self.commodities.get(symbol); 106 | x.unwrap().graph_index 107 | } 108 | 109 | /// Finds a commodity with the given symbol, or creates one. 110 | /// 111 | pub fn find_or_create( 112 | &mut self, 113 | symbol: &str, 114 | annotation: Option, 115 | ) -> *const Commodity { 116 | if symbol.is_empty() { 117 | return std::ptr::null(); 118 | } 119 | 120 | // Try using entry? 121 | // self.commodities.entry(symbol). 122 | 123 | if let Some(c) = self.commodities.get(symbol) { 124 | // check if annotation exists and add if not. 125 | if annotation.is_some() && !self.annotated_commodities.contains_key(symbol) { 126 | // append annotation 127 | self.annotated_commodities 128 | .insert(symbol.to_owned(), annotation.unwrap()); 129 | } 130 | 131 | c 132 | } else { 133 | self.create(symbol, annotation) 134 | } 135 | } 136 | 137 | pub fn get_by_index(&self, index: CommodityIndex) -> &Commodity { 138 | self.commodity_history.get_commodity(index) 139 | } 140 | 141 | /// This is the exchange() method but, due to mutability of references, it **does not** 142 | /// create new prices. This needs to be explicitly done by the caller before/aftert the exchange. 143 | /// 144 | /// Instead of passing the `add_price` parameter, invoke `add_price` on journal's commodity_pool. 145 | /// `journal.commodity_pool.add_price_struct(new_price);` 146 | /// 147 | /// Returns (CostBreakdown, New Price) 148 | /// The New Price is the price that needs to be added to the Commodity Pool. 149 | /// 150 | /// "Exchange one commodity for another, while recording the factored price." 151 | /// 152 | pub fn exchange( 153 | &mut self, 154 | amount: &Amount, 155 | cost: &Amount, 156 | is_per_unit: bool, 157 | moment: NaiveDateTime, 158 | ) -> (CostBreakdown, Option) { 159 | 160 | // annotations 161 | let annotation_opt: Option<&Annotation> = 162 | if let Some(commodity_index) = amount.get_commodity().unwrap().graph_index { 163 | let commodity = self.get_by_index(commodity_index); 164 | self.annotated_commodities.get(&commodity.symbol) 165 | } else { 166 | None 167 | }; 168 | 169 | let mut per_unit_cost = if is_per_unit || amount.is_zero() { 170 | cost.abs() 171 | } else { 172 | (*cost / *amount).abs() 173 | }; 174 | 175 | if cost.get_commodity().is_none() { 176 | per_unit_cost.remove_commodity(); 177 | } 178 | 179 | // DEBUG("commodity.prices.add", 180 | 181 | // Do not record commodity exchanges where amount's commodity has a 182 | // fixated price, since this does not establish a market value for the 183 | // base commodity. 184 | let new_price: Option; 185 | // if add_price 186 | if !per_unit_cost.is_zero() && amount.get_commodity() != per_unit_cost.get_commodity() { 187 | // self.add_price(amount.commodity_index.unwrap(), moment, per_unit_cost); 188 | // Instead, return the new price and have the caller store it. 189 | new_price = Some(Price::new( 190 | amount.commodity, 191 | moment, 192 | per_unit_cost, 193 | )); 194 | } else { 195 | new_price = None; 196 | } 197 | 198 | let mut breakdown = CostBreakdown::new(); 199 | // final cost 200 | breakdown.final_cost = if !is_per_unit { 201 | *cost 202 | } else { 203 | *cost * amount.abs() 204 | }; 205 | 206 | // "exchange: basis-cost = " 207 | if let Some(annotation) = annotation_opt { 208 | if let Some(ann_price) = annotation.price { 209 | breakdown.basis_cost = ann_price * (*amount); 210 | } 211 | } else { 212 | breakdown.basis_cost = breakdown.final_cost; 213 | } 214 | 215 | breakdown.amount = *amount; 216 | 217 | (breakdown, new_price) 218 | } 219 | 220 | pub fn len(&self) -> usize { 221 | self.commodities.len() 222 | } 223 | 224 | pub fn parse_price_directive(&mut self, line: &str) { 225 | let tokens = scanner::scan_price_directive(line); 226 | 227 | // date 228 | let date = NaiveDate::parse_from_str(tokens[0], ISO_DATE_FORMAT).expect("date parsed"); 229 | // time 230 | let time = if !tokens[1].is_empty() { 231 | NaiveTime::parse_from_str(tokens[1], ISO_TIME_FORMAT).expect("time parsed") 232 | } else { 233 | NaiveTime::MIN 234 | }; 235 | let datetime = NaiveDateTime::new(date, time); 236 | 237 | // commodity 238 | let commodity_ptr = self.find_or_create(tokens[2], None); 239 | 240 | // quantity 241 | let quantity = Quantity::from_str(tokens[3]).expect("quantity parsed"); 242 | 243 | // cost commodity 244 | let cost_commodity = self.find_or_create(tokens[4], None); 245 | 246 | // cost 247 | let cost = Amount::new(quantity, Some(cost_commodity)); 248 | 249 | // Add price for commodity 250 | self.commodity_history 251 | .add_price(commodity_ptr, datetime, cost); 252 | } 253 | } 254 | 255 | /// Cost Breakdown is used to track the commodity costs. 256 | /// i.e. when lots are used 257 | /// 258 | /// `-10 VEUR {20 EUR} [2023-04-01] @ 25 EUR` 259 | /// 260 | /// The amount is -10 VEUR, 261 | /// per unit cost is 25 EUR, 262 | /// basis cost = 200 EUR 263 | /// final cost = 250 EUR 264 | pub struct CostBreakdown { 265 | pub amount: Amount, 266 | pub final_cost: Amount, 267 | pub basis_cost: Amount, 268 | } 269 | 270 | impl CostBreakdown { 271 | pub fn new() -> Self { 272 | Self { 273 | amount: 0.into(), 274 | final_cost: 0.into(), 275 | basis_cost: 0.into(), 276 | } 277 | } 278 | } 279 | 280 | #[cfg(test)] 281 | mod tests { 282 | use super::CommodityPool; 283 | use crate::{amount::Quantity, annotate::Annotation, journal::Journal, parse_file, parse_text, commodity::Commodity}; 284 | 285 | #[test] 286 | fn test_create() { 287 | const SYMBOL: &str = "DHI"; 288 | let mut pool = CommodityPool::new(); 289 | 290 | let cdty_ptr = pool.create(SYMBOL, None); 291 | let cdty: &Commodity; 292 | unsafe { 293 | cdty = &*cdty_ptr; 294 | } 295 | 296 | assert_eq!(SYMBOL, cdty.symbol); 297 | 298 | assert_eq!(SYMBOL, pool.find(SYMBOL).unwrap().symbol); 299 | } 300 | 301 | #[test] 302 | fn test_adding_commodity() { 303 | let symbol = "EUR"; 304 | let mut pool = CommodityPool::new(); 305 | 306 | // Act 307 | pool.create(symbol, None); 308 | 309 | // Assert 310 | assert_eq!(1, pool.commodities.len()); 311 | assert!(pool.commodities.contains_key("EUR")); 312 | } 313 | 314 | #[test] 315 | fn test_parsing_price_directive() { 316 | let line = "P 2022-03-03 13:00:00 EUR 1.12 USD"; 317 | let mut pool = CommodityPool::new(); 318 | 319 | // Act 320 | pool.parse_price_directive(line); 321 | 322 | // Assert 323 | assert_eq!(2, pool.commodities.len()); 324 | assert_eq!(2, pool.commodity_history.node_count()); 325 | assert_eq!(1, pool.commodity_history.edge_count()); 326 | 327 | // Currencies in the map. 328 | assert!(pool.commodities.contains_key("EUR")); 329 | assert!(pool.commodities.contains_key("USD")); 330 | 331 | // Currencies as nodes in the graph. 332 | assert_eq!( 333 | "EUR", 334 | // pool.commodity_history.node_weights().nth(0).unwrap().symbol 335 | pool.commodities.get("EUR").unwrap().symbol 336 | ); 337 | assert_eq!( 338 | "USD", 339 | // pool.commodity_history.node_weights().nth(1).unwrap().symbol 340 | pool.commodities.get("USD").unwrap().symbol 341 | ); 342 | 343 | // Rate, edge 344 | let rates = pool.commodity_history.edge_weights().nth(0).unwrap(); 345 | assert_eq!(1, rates.len()); 346 | let datetime_string = rates.keys().nth(0).unwrap().to_string(); 347 | // date/time 348 | assert_eq!("2022-03-03 13:00:00", datetime_string); 349 | // rate 350 | assert_eq!(&Quantity::from(1.12), rates.values().nth(0).unwrap()); 351 | } 352 | 353 | /// Annotation must exist for the given symbol after creation. 354 | #[test] 355 | fn test_create_annotated() { 356 | // arrange 357 | let symbol = "EUR"; 358 | let annotation = Annotation::new(None, None); 359 | let mut pool = CommodityPool::new(); 360 | 361 | // act 362 | pool.create(symbol, Some(annotation)); 363 | 364 | // assert 365 | let actual = pool.annotated_commodities.get(symbol); 366 | assert!(actual.is_some()); 367 | let Some(actual_annotation) = actual else {panic!()}; 368 | assert_eq!(None, actual_annotation.date); 369 | assert_eq!(None, actual_annotation.price); 370 | } 371 | 372 | /// Calling exchange will store the base cost. 373 | #[test] 374 | fn test_exchange_stores_base_cost() { 375 | let input = r#"2023-05-01 Sell Stocks 376 | Assets:Stocks -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR 377 | Assets:Cash 378 | "#; 379 | let journal = &mut Journal::new(); 380 | 381 | parse_text(input, journal); 382 | 383 | // assert 384 | // The prices (edges) are directional, so we need to get the edges for VEUR. 385 | let veur = journal.commodity_pool.find_index("VEUR").unwrap(); 386 | let mut veur_edges = journal.commodity_pool.commodity_history.edges(veur); 387 | let edge = veur_edges.next().unwrap(); 388 | let price_history = edge.weight(); 389 | assert_eq!(1, price_history.len()); 390 | 391 | let (datetime, quantity) = price_history.iter().nth(0).unwrap(); 392 | assert_eq!("2023-05-01 00:00:00", datetime.to_string()); 393 | assert_eq!(quantity, &25.into()); 394 | } 395 | 396 | /// Test exchanging a currency after a price directive is parsed 397 | // #[test] 398 | fn test_exchange() { 399 | let line = "P 2022-03-03 13:00:00 EUR 1.12 USD"; 400 | let mut journal = Journal::new(); 401 | parse_text(line, &mut journal); 402 | 403 | // act 404 | // exchange_commodities() 405 | todo!() 406 | 407 | // assert 408 | } 409 | 410 | /// Test exchanging a currency after an implicit price is created from an exchange xact 411 | // #[test] 412 | fn test_exchange_implicit() { 413 | let mut journal = Journal::new(); 414 | parse_file("tests/trade.ledger", &mut journal); 415 | 416 | todo!() 417 | } 418 | } 419 | 420 | #[cfg(test)] 421 | mod tests_algos { 422 | use petgraph::{ 423 | algo::{bellman_ford, dijkstra, floyd_warshall}, 424 | dot::Dot, 425 | Graph, 426 | }; 427 | 428 | #[test] 429 | fn test_pet_graph() { 430 | // Arrange 431 | let mut hist = Graph::<&str, &str>::new(); 432 | // edges are commodities 433 | let eur = hist.add_node("eur"); 434 | let usd = hist.add_node("usd"); 435 | let aud = hist.add_node("aud"); 436 | // edges are prices / exchange rates 437 | hist.add_edge(aud, eur, "1.65"); 438 | hist.add_edge(aud, usd, "1.30"); 439 | 440 | // Act 441 | // Given the adge eur->aud->usd, get the rate eur/usd 442 | let dot = format!("{:?}", Dot::new(&hist)); 443 | 444 | assert!(!dot.is_empty()); 445 | } 446 | 447 | /// Test the Dijkstra algorithm, the shortest path between the nodes / commodities. 448 | #[test] 449 | fn test_dijkstra() { 450 | // Arrange 451 | let mut hist = Graph::<&str, f32>::new(); 452 | // edges are commodities 453 | let eur = hist.add_node("eur"); 454 | let usd = hist.add_node("usd"); 455 | let aud = hist.add_node("aud"); 456 | // edges are prices / exchange rates 457 | hist.add_edge(eur, aud, 0.85); 458 | hist.add_edge(aud, usd, 1.30); 459 | 460 | // Act 461 | let actual = dijkstra(&hist, eur, Some(usd), |_| 1); 462 | 463 | // Assert 464 | assert!(!actual.is_empty()); 465 | // eur->aud->usd has three nodes. 466 | assert_eq!(3, actual.len()); 467 | } 468 | 469 | /// Dijkstra algorithm should be enough for our purpose. It just needs to give us the shortest 470 | /// path between the desired currencies. The rates are all positive. 471 | /// However, this test is wrong, since it just adds edges, which is not what we need. 472 | #[test] 473 | fn test_exchange_with_dijkstra() { 474 | // Arrange 475 | let mut hist = Graph::<&str, f32>::new(); 476 | // edges are commodities 477 | let eur = hist.add_node("eur"); 478 | let usd = hist.add_node("usd"); 479 | let aud = hist.add_node("aud"); 480 | // edges are prices / exchange rates 481 | hist.add_edge(eur, aud, 1.65); 482 | hist.add_edge(aud, usd, 0.6520); 483 | 484 | // Act 485 | let actual = dijkstra(&hist, eur, Some(usd), |e| *e.weight()); 486 | 487 | // Assert 488 | assert!(!actual.is_empty()); 489 | assert_eq!(3, actual.len()); 490 | 491 | // The order is not guaranteed. 492 | // let (i, member_i32) = actual.iter().nth(0).unwrap(); 493 | // let member = hist.node_weight(*i).unwrap(); 494 | // assert_eq!("eur", *member); 495 | 496 | // let (i, member_i32) = actual.iter().nth(1).unwrap(); 497 | // let member = hist.node_weight(*i).unwrap(); 498 | // assert_eq!("aud", *member); 499 | 500 | // let (i, member_i32) = actual.iter().nth(2).unwrap(); 501 | // let member = hist.node_weight(*i).unwrap(); 502 | // assert_eq!("usd", *member); 503 | } 504 | 505 | /// Bellman-Ford algorhythm finds the shortest route but allows for negative edge cost. 506 | #[test] 507 | fn test_bellman_ford() { 508 | // Arrange 509 | let mut hist = Graph::<&str, f32>::new(); 510 | // edges are commodities 511 | let eur = hist.add_node("eur"); 512 | let usd = hist.add_node("usd"); 513 | let aud = hist.add_node("aud"); 514 | // edges are prices / exchange rates 515 | hist.add_edge(eur, aud, 0.85); 516 | hist.add_edge(aud, usd, 1.30); 517 | 518 | // Act 519 | let actual = bellman_ford(&hist, eur).unwrap(); 520 | 521 | // Assert 522 | assert!(!actual.distances.is_empty()); 523 | assert_eq!(3, actual.distances.len()); 524 | } 525 | 526 | /// floyd_warshall algorithm 527 | /// Compute shortest paths in a weighted graph with positive or negative edge weights (but with no negative cycles) 528 | #[test] 529 | fn test_floyd_warshall() { 530 | // Arrange 531 | let mut hist = Graph::<&str, f32>::new(); 532 | // edges are commodities 533 | let eur = hist.add_node("eur"); 534 | let usd = hist.add_node("usd"); 535 | let aud = hist.add_node("aud"); 536 | // edges are prices / exchange rates 537 | hist.add_edge(eur, aud, 0.85); 538 | hist.add_edge(aud, usd, 1.30); 539 | 540 | // Act 541 | let actual = floyd_warshall(&hist, |_| 1).unwrap(); 542 | 543 | assert!(!actual.is_empty()); 544 | } 545 | 546 | // search for edge (direct exchange rate). 547 | #[test] 548 | fn test_search() { 549 | // Arrange 550 | let mut hist = Graph::<&str, f32>::new(); 551 | // edges are commodities 552 | let eur = hist.add_node("eur"); 553 | let usd = hist.add_node("usd"); 554 | let aud = hist.add_node("aud"); 555 | // edges are prices / exchange rates 556 | hist.add_edge(eur, aud, 1.65); 557 | hist.add_edge(aud, usd, 0.6520); 558 | 559 | // Act 560 | let actual = hist.find_edge(eur, aud); 561 | assert!(actual.is_some()); 562 | 563 | let Some(euraud) = actual else {panic!()}; 564 | let weight = hist.edge_weight(euraud).unwrap(); 565 | assert_eq!(&1.65, weight); 566 | } 567 | } 568 | -------------------------------------------------------------------------------- /src/scanner.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Scanner scans the input text and returns tokens (groups of characters) back for parsing. 3 | * Scans/tokenizes the journal files. 4 | * There are scanner functions for every element of the journal. 5 | */ 6 | 7 | /// Tokens after scanning a Posting line. 8 | /// 9 | /// ` Assets:Stocks -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR` 10 | /// 11 | pub(crate) struct PostTokens<'a> { 12 | pub account: &'a str, 13 | pub quantity: &'a str, 14 | pub symbol: &'a str, 15 | pub price_quantity: &'a str, 16 | pub price_commodity: &'a str, 17 | pub price_date: &'a str, 18 | pub cost_quantity: &'a str, 19 | pub cost_symbol: &'a str, 20 | pub is_per_unit: bool, 21 | } 22 | 23 | impl PostTokens<'_> { 24 | pub fn create_empty() -> Self { 25 | Self { 26 | account: "", 27 | quantity: "", 28 | symbol: "", 29 | price_quantity: "", 30 | price_commodity: "", 31 | price_date: "", 32 | cost_quantity: "", 33 | cost_symbol: "", 34 | is_per_unit: false, 35 | } 36 | } 37 | } 38 | 39 | /// Structure for the tokens from scanning the Amount part of the Posting. 40 | pub struct AmountTokens<'a> { 41 | pub quantity: &'a str, 42 | pub symbol: &'a str, 43 | } 44 | 45 | struct AnnotationTokens<'a> { 46 | quantity: &'a str, 47 | symbol: &'a str, 48 | date: &'a str, 49 | } 50 | 51 | impl<'a> AnnotationTokens<'a> { 52 | pub fn empty() -> Self { 53 | Self { 54 | quantity: "", 55 | symbol: "", 56 | date: "", 57 | } 58 | } 59 | } 60 | 61 | struct CostTokens<'a> { 62 | pub quantity: &'a str, 63 | pub symbol: &'a str, 64 | pub is_per_unit: bool, 65 | pub remainder: &'a str, 66 | } 67 | 68 | impl<'a> CostTokens<'a> { 69 | pub fn new() -> Self { 70 | Self { 71 | quantity: "", 72 | symbol: "", 73 | is_per_unit: false, 74 | remainder: "", 75 | } 76 | } 77 | } 78 | 79 | /// Parse Xact header record. 80 | /// 2023-05-05=2023-05-01 Payee ; Note 81 | /// 82 | /// returns [date, aux_date, payee, note] 83 | /// 84 | /// Check for .is_empty() after receiving the result and handle appropriately. 85 | /// 86 | /// Ledger's documentation specifies the following format 87 | /// 88 | /// DATE[=EDATE] [*|!] [(CODE)] DESC 89 | /// 90 | /// but the DESC is not mandatory. is used in that case. 91 | /// So, the Payee/Description is mandatory in the model but not in the input. 92 | pub(crate) fn tokenize_xact_header(input: &str) -> [&str; 4] { 93 | if input.is_empty() { 94 | panic!("Invalid input for Xact record.") 95 | } 96 | 97 | // Dates. 98 | // Date has to be at the beginning. 99 | 100 | let (date, input) = scan_date(input); 101 | 102 | // aux date 103 | let (aux_date, input) = tokenize_aux_date(input); 104 | 105 | // Payee 106 | 107 | let (payee, input) = tokenize_payee(input); 108 | 109 | // Note 110 | let note = tokenize_note(input); 111 | 112 | [date, aux_date, payee, note] 113 | } 114 | 115 | /// Parse date from the input string. 116 | /// 117 | /// returns the (date string, remaining string) 118 | fn scan_date(input: &str) -> (&str, &str) { 119 | match input.find(|c| c == '=' || c == ' ') { 120 | Some(index) => { 121 | // offset = index; 122 | //date = &input[..index]; 123 | return (&input[..index], &input[index..]); 124 | } 125 | None => { 126 | // offset = input.len(); 127 | // date = &input; 128 | // return [date, "", "", ""]; 129 | return (&input, ""); 130 | } 131 | }; 132 | // log::debug!("date: {:?}", date); 133 | // (date, offset) 134 | } 135 | 136 | /// Parse auxillary date. 137 | /// Returns the (date_str, remains). 138 | fn tokenize_aux_date(input: &str) -> (&str, &str) { 139 | let aux_date: &str; 140 | // let mut cursor: usize = 0; 141 | // skip ws 142 | // let input = input.trim_start(); 143 | 144 | match input.chars().peekable().peek() { 145 | Some('=') => { 146 | // have aux date. 147 | // skip '=' sign 148 | let input = input.trim_start_matches('='); 149 | 150 | // find the next separator 151 | match input.find(' ') { 152 | Some(i) => return (&input[..i], &input[i..]), 153 | None => return (input, ""), 154 | }; 155 | } 156 | _ => { 157 | // end of line, or character other than '=' 158 | return ("", input); 159 | } 160 | } 161 | } 162 | 163 | fn tokenize_note(input: &str) -> &str { 164 | match input.is_empty() { 165 | true => "", 166 | false => &input[3..].trim(), 167 | } 168 | // log::debug!("note: {:?}", note); 169 | } 170 | 171 | /// Parse payee from the input string. 172 | /// Returns (payee, processed length) 173 | fn tokenize_payee(input: &str) -> (&str, &str) { 174 | match input.find(" ;") { 175 | Some(index) => (&input[..index].trim(), &input[index..]), 176 | None => (input.trim(), ""), 177 | } 178 | } 179 | 180 | /// Parse tokens from a Post line. 181 | /// ACCOUNT AMOUNT [; NOTE] 182 | /// 183 | /// The possible syntax for an amount is: 184 | /// [-]NUM[ ]SYM [@ AMOUNT] 185 | /// SYM[ ][-]NUM [@ AMOUNT] 186 | /// 187 | /// input: &str Post content 188 | /// returns (account, quantity, symbol, cost_q, cost_s, is_per_unit) 189 | /// 190 | /// Reference methods: 191 | /// - amount_t::parse 192 | /// 193 | pub(crate) fn scan_post(input: &str) -> PostTokens { 194 | // clear the initial whitespace. 195 | let input = input.trim_start(); 196 | 197 | // todo: state = * cleared, ! pending 198 | 199 | if input.is_empty() || input.chars().nth(0) == Some(';') { 200 | panic!("Posting has no account") 201 | } 202 | 203 | // todo: virtual, deferred account [] () <> 204 | 205 | // two spaces is a separator betweer the account and amount. 206 | // Eventually, also support the tab as a separator: 207 | // something like |p| p == " " || p == '\t' 208 | 209 | let Some(sep_index) = input.find(" ") else { 210 | let mut post_tokens = PostTokens::create_empty(); 211 | post_tokens.account = input.trim_end(); 212 | return post_tokens; 213 | }; 214 | 215 | // there's more content 216 | 217 | let account = &input[..sep_index]; 218 | let (amount_tokens, input) = scan_amount(&input[sep_index + 2..]); 219 | let (annotation_tokens, input) = scan_annotations(input); 220 | let cost_tokens = match input.is_empty() { 221 | true => CostTokens::new(), 222 | false => scan_cost(input), 223 | }; 224 | 225 | // TODO: handle post comment 226 | // scan_xyz(input) 227 | 228 | return PostTokens { 229 | account, 230 | quantity: amount_tokens.quantity, 231 | symbol: amount_tokens.symbol, 232 | price_quantity: annotation_tokens.quantity, 233 | price_commodity: annotation_tokens.symbol, 234 | price_date: annotation_tokens.date, 235 | cost_quantity: cost_tokens.quantity, 236 | cost_symbol: cost_tokens.symbol, 237 | is_per_unit: cost_tokens.is_per_unit, 238 | }; 239 | } 240 | 241 | /// Scans the first Amount from the input 242 | /// 243 | /// returns: AmountTokens 244 | /// 245 | /// The amount line can be `-10 VEUR {20 EUR} [2023-04-01] @ 25 EUR` 246 | /// of which, the amount is "-10 VEUR" and the rest is the cost, stored in 247 | /// an annotation. 248 | pub fn scan_amount(input: &str) -> (AmountTokens, &str) { 249 | let input = input.trim_start(); 250 | 251 | // Check the next character 252 | let c = *input.chars().peekable().peek().expect("A valid character"); 253 | 254 | if c.is_digit(10) || c == '-' || c == '.' || c == ',' { 255 | // scan_amount_number_first(input) 256 | let (quantity, input) = scan_quantity(input); 257 | let (symbol, input) = scan_symbol(input); 258 | (AmountTokens { quantity, symbol }, input) 259 | } else { 260 | // scan_amount_symbol_first(input) 261 | let (symbol, input) = scan_symbol(input); 262 | let (quantity, input) = scan_quantity(input); 263 | (AmountTokens { quantity, symbol }, input) 264 | } 265 | } 266 | 267 | fn scan_annotations(input: &str) -> (AnnotationTokens, &str) { 268 | let mut input = input.trim_start(); 269 | if input.is_empty() { 270 | return (AnnotationTokens::empty(), input); 271 | } 272 | 273 | let mut result = AnnotationTokens::empty(); 274 | 275 | loop { 276 | let Some(next_char) = input.chars().nth(0) 277 | else { break }; 278 | if next_char == '{' { 279 | if !result.quantity.is_empty() { 280 | panic!("Commodity specifies more than one price"); 281 | } 282 | 283 | // todo: Is it per unit or total? {{25 EUR}} 284 | // todo: is it fixated price {=xyz} 285 | 286 | let (amount, rest) = scan_until(&input[1..], '}'); 287 | let (amount_tokens, _) = scan_amount(amount); 288 | 289 | result.quantity = amount_tokens.quantity; 290 | result.symbol = amount_tokens.symbol; 291 | 292 | // Skip the closing curly brace. 293 | input = &rest[1..]; 294 | // and the ws 295 | input = input.trim_start(); 296 | } else if next_char == '[' { 297 | if !result.date.is_empty() { 298 | panic!("Commodity specifies more than one date"); 299 | } 300 | let (date_input, rest) = scan_until(&input[1..], ']'); 301 | let (date, _) = scan_date(date_input); 302 | 303 | result.date = date; 304 | 305 | // skip the closing ] 306 | input = &rest[1..]; 307 | // and the ws 308 | input = input.trim_start(); 309 | } else if next_char == '(' { 310 | // Commodity specifies more than one valuation expression 311 | 312 | todo!("valuation expression") 313 | } else { 314 | break; 315 | } 316 | } 317 | 318 | (result, input) 319 | } 320 | 321 | /// Scans until the given separator is found 322 | fn scan_until<'a>(input: &'a str, separator: char) -> (&'a str, &'a str) { 323 | let Some(i) = input.find(separator) 324 | else { panic!("work-out the correct return values") }; 325 | 326 | (&input[..i], &input[i..]) 327 | } 328 | 329 | /// Reads the quantity string. 330 | /// Returns [quantity, remainder] 331 | fn scan_quantity(input: &str) -> (&str, &str) { 332 | for (i, c) in input.char_indices() { 333 | // stop if an invalid number character encountered. 334 | if c.is_digit(10) || c == '-' || c == '.' || c == ',' { 335 | // continue 336 | } else { 337 | return (&input[..i], &input[i..].trim_start()); 338 | } 339 | } 340 | // else, return the full input. 341 | (input, "") 342 | } 343 | 344 | /// Scans the symbol in the input string. 345 | /// Returns (symbol, remainder) 346 | fn scan_symbol(input: &str) -> (&str, &str) { 347 | let input = input.trim_start(); 348 | 349 | // TODO: check for valid double quotes 350 | 351 | for (i, c) in input.char_indices() { 352 | // Return when a separator or a number is found. 353 | if c.is_whitespace() || c == '@' || c.is_digit(10) || c == '-' { 354 | return (&input[..i], &input[i..].trim_start()); 355 | } 356 | } 357 | // else return the whole input. 358 | (input, "") 359 | } 360 | 361 | /// Scans the cost 362 | /// 363 | /// @ AMOUNT or @@ AMOUNT 364 | /// 365 | /// The first is per-unit cost and the second is the total cost. 366 | /// Returns 367 | /// [quantity, symbol, remainder, is_per_unit] 368 | fn scan_cost(input: &str) -> CostTokens { 369 | // @ or () or @@ 370 | if input.chars().peekable().peek() != Some(&'@') { 371 | return CostTokens { 372 | quantity: "", 373 | symbol: "", 374 | is_per_unit: false, 375 | remainder: "", 376 | }; 377 | } 378 | 379 | // We have a price. 380 | // () is a virtual cost. Ignore for now. 381 | 382 | let (first_char, is_per_unit) = if input.chars().nth(1) != Some('@') { 383 | // per-unit cost 384 | (2, true) 385 | } else { 386 | // total cost 387 | (3, false) 388 | }; 389 | let input = &input[first_char..].trim_start(); 390 | let (amount_tokens, input) = scan_amount(input); 391 | 392 | CostTokens { 393 | quantity: amount_tokens.quantity, 394 | symbol: amount_tokens.symbol, 395 | is_per_unit, 396 | remainder: input, 397 | } 398 | } 399 | 400 | /// Scans the Price directive 401 | /// 402 | /// i.e. 403 | /// P 2022-03-03 13:00:00 EUR 1.12 USD 404 | /// 405 | /// returns [date, time, commodity, quantity, price_commodity] 406 | pub(crate) fn scan_price_directive(input: &str) -> [&str; 5] { 407 | // Skip the starting P and whitespace. 408 | let input = input[1..].trim_start(); 409 | 410 | // date 411 | let (date, input) = scan_price_element(input); 412 | 413 | // time 414 | let input = input.trim_start(); 415 | let (time, input) = match input.chars().peekable().peek().unwrap().is_digit(10) { 416 | // time 417 | true => scan_price_element(input), 418 | // no time 419 | false => ("", input), 420 | }; 421 | 422 | // commodity 423 | let input = input.trim_start(); 424 | let (commodity, input) = scan_price_element(input); 425 | 426 | // price, quantity 427 | let input = input.trim_start(); 428 | let (quantity, input) = scan_price_element(input); 429 | 430 | // price, commodity 431 | let input = input.trim_start(); 432 | let (price_commodity, _input) = scan_price_element(input); 433 | 434 | [date, time, commodity, quantity, price_commodity] 435 | } 436 | 437 | fn find_next_separator(input: &str) -> Option { 438 | input.find(|c| c == ' ' || c == '\t') 439 | } 440 | 441 | fn scan_price_element(input: &str) -> (&str, &str) { 442 | let Some(separator_index) = find_next_separator(input) 443 | else { 444 | return (input, "") 445 | }; 446 | 447 | // date, rest 448 | (&input[..separator_index], &input[separator_index..]) 449 | } 450 | 451 | /// identifies the next element, the content until the next separator. 452 | fn next_element(input: &str) -> Option<&str> { 453 | // assuming the element starts at the beginning, no whitespace. 454 | if let Some(next_sep) = find_next_separator(input) { 455 | Some(&input[..next_sep]) 456 | } else { 457 | None 458 | } 459 | } 460 | 461 | #[cfg(test)] 462 | mod scanner_tests_xact { 463 | use super::{scan_date, tokenize_xact_header}; 464 | 465 | #[test] 466 | fn test_parsing_xact_header() { 467 | std::env::set_var("RUST_LOG", "trace"); 468 | 469 | let input = "2023-05-01 Payee ; Note"; 470 | 471 | let mut iter = tokenize_xact_header(input).into_iter(); 472 | // let [date, aux_date, payee, note] = iter.as_slice(); 473 | 474 | assert_eq!("2023-05-01", iter.next().unwrap()); 475 | assert_eq!("", iter.next().unwrap()); 476 | assert_eq!("Payee", iter.next().unwrap()); 477 | assert_eq!("Note", iter.next().unwrap()); 478 | } 479 | 480 | #[test] 481 | fn test_parsing_xact_header_aux_dates() { 482 | let input = "2023-05-02=2023-05-01 Payee ; Note"; 483 | 484 | let mut iter = tokenize_xact_header(input).into_iter(); 485 | 486 | assert_eq!("2023-05-02", iter.next().unwrap()); 487 | assert_eq!("2023-05-01", iter.next().unwrap()); 488 | assert_eq!("Payee", iter.next().unwrap()); 489 | assert_eq!("Note", iter.next().unwrap()); 490 | } 491 | 492 | #[test] 493 | fn test_parsing_xact_header_no_note() { 494 | let input = "2023-05-01 Payee"; 495 | 496 | let mut iter = tokenize_xact_header(input).into_iter(); 497 | 498 | assert_eq!("2023-05-01", iter.next().unwrap()); 499 | assert_eq!("", iter.next().unwrap()); 500 | assert_eq!("Payee", iter.next().unwrap()); 501 | assert_eq!("", iter.next().unwrap()); 502 | } 503 | 504 | #[test] 505 | fn test_parsing_xact_header_no_payee_w_note() { 506 | let input = "2023-05-01 ; Note"; 507 | 508 | let mut iter = tokenize_xact_header(input).into_iter(); 509 | 510 | assert_eq!("2023-05-01", iter.next().unwrap()); 511 | assert_eq!("", iter.next().unwrap()); 512 | assert_eq!("", iter.next().unwrap()); 513 | assert_eq!("Note", iter.next().unwrap()); 514 | } 515 | 516 | #[test] 517 | fn test_parsing_xact_header_date_only() { 518 | let input = "2023-05-01"; 519 | 520 | let mut iter = tokenize_xact_header(input).into_iter(); 521 | 522 | assert_eq!(input, iter.next().unwrap()); 523 | assert_eq!("", iter.next().unwrap()); 524 | assert_eq!("", iter.next().unwrap()); 525 | assert_eq!("", iter.next().unwrap()); 526 | } 527 | 528 | #[test] 529 | fn test_date_w_aux() { 530 | let input = "2023-05-01=2023"; 531 | 532 | let (date, remains) = scan_date(input); 533 | 534 | assert_eq!("2023-05-01", date); 535 | assert_eq!("=2023", remains); 536 | } 537 | 538 | // test built-in ws removal with .trim() 539 | #[test] 540 | fn test_ws_skip() { 541 | // see if trim removes tabs 542 | let input = "\t \t Text \t"; 543 | let actual = input.trim(); 544 | 545 | assert_eq!("Text", actual); 546 | 547 | // This confirms that .trim() and variants can be used for skipping whitespace. 548 | } 549 | } 550 | 551 | #[cfg(test)] 552 | mod scanner_tests_post { 553 | use super::{scan_post, scan_symbol}; 554 | use crate::scanner::scan_amount; 555 | 556 | #[test] 557 | fn test_tokenize_post_full() { 558 | let input = " Assets 20 VEUR @ 25.6 EUR"; 559 | 560 | // Act 561 | let tokens = scan_post(input); 562 | 563 | // Assert 564 | assert_eq!("Assets", tokens.account); 565 | assert_eq!("20", tokens.quantity); 566 | assert_eq!("VEUR", tokens.symbol); 567 | assert_eq!("25.6", tokens.cost_quantity); 568 | assert_eq!("EUR", tokens.cost_symbol); 569 | } 570 | 571 | #[test] 572 | fn test_tokenize_post_w_amount() { 573 | let input = "Assets 20 EUR"; 574 | 575 | // Act 576 | let tokens = scan_post(input); 577 | 578 | // Assert 579 | assert_eq!("Assets", tokens.account); 580 | assert_eq!("20", tokens.quantity); 581 | assert_eq!("EUR", tokens.symbol); 582 | assert_eq!("", tokens.cost_quantity); 583 | assert_eq!("", tokens.cost_symbol); 584 | assert_eq!(false, tokens.is_per_unit); 585 | } 586 | 587 | #[test] 588 | fn test_tokenize_post_quantity_only() { 589 | let input = "Assets 20"; 590 | 591 | // Act 592 | let tokens = scan_post(input); 593 | 594 | // Assert 595 | assert_eq!("Assets", tokens.account); 596 | assert_eq!("20", tokens.quantity); 597 | } 598 | 599 | #[test] 600 | fn test_tokenize_post_account() { 601 | let input = " Assets"; 602 | 603 | // Act 604 | let tokens = scan_post(input); 605 | 606 | // Assert 607 | assert_eq!("Assets", tokens.account); 608 | assert_eq!("", tokens.quantity); 609 | } 610 | 611 | #[test] 612 | fn test_tokenize_amount() { 613 | let input = " Assets 25 EUR"; 614 | 615 | let tokens = scan_post(input); 616 | 617 | assert_eq!("25", tokens.quantity); 618 | assert_eq!("EUR", tokens.symbol); 619 | assert_eq!("", tokens.cost_quantity); 620 | assert_eq!("", tokens.cost_symbol); 621 | } 622 | 623 | #[test] 624 | fn test_tokenize_neg_amount() { 625 | let input = " Expenses -25 EUR"; 626 | 627 | let actual = scan_post(input); 628 | 629 | assert_eq!("-25", actual.quantity); 630 | assert_eq!("EUR", actual.symbol); 631 | } 632 | 633 | #[test] 634 | fn test_tokenize_amount_dec_sep() { 635 | let input = " Expenses 25.0 EUR"; 636 | 637 | let actual = scan_post(input); 638 | 639 | assert_eq!("25.0", actual.quantity); 640 | assert_eq!("EUR", actual.symbol); 641 | } 642 | 643 | #[test] 644 | fn test_tokenize_amount_th_sep() { 645 | let input = " Expenses 25,00 EUR"; 646 | 647 | let actual = scan_post(input); 648 | 649 | assert_eq!("25,00", actual.quantity); 650 | assert_eq!("EUR", actual.symbol); 651 | } 652 | 653 | #[test] 654 | fn test_tokenize_amount_all_sep() { 655 | let input = " Expenses 25,0.01 EUR"; 656 | 657 | let actual = scan_post(input); 658 | 659 | assert_eq!("25,0.01", actual.quantity); 660 | assert_eq!("EUR", actual.symbol); 661 | } 662 | 663 | #[test] 664 | fn test_tokenize_amount_symbol_first() { 665 | let input = " Expenses €25"; 666 | 667 | let actual = scan_post(input); 668 | 669 | assert_eq!("25", actual.quantity); 670 | assert_eq!("€", actual.symbol); 671 | } 672 | 673 | #[test] 674 | fn test_scan_amount_number_first_ws() { 675 | let input = " Expenses 25,0.01 EUR"; 676 | 677 | let actual = scan_post(input); 678 | 679 | assert_eq!("Expenses", actual.account); 680 | assert_eq!("25,0.01", actual.quantity); 681 | assert_eq!("EUR", actual.symbol); 682 | assert_eq!("", actual.cost_quantity); 683 | assert_eq!("", actual.cost_symbol); 684 | } 685 | 686 | #[test] 687 | fn test_scan_amount_number_first() { 688 | let input = " Expenses 25,0.01EUR"; 689 | 690 | let tokens = scan_post(input); 691 | 692 | assert_eq!("Expenses", tokens.account); 693 | assert_eq!("25,0.01", tokens.quantity); 694 | assert_eq!("EUR", tokens.symbol); 695 | assert_eq!("", tokens.cost_quantity); 696 | assert_eq!("", tokens.cost_symbol); 697 | } 698 | 699 | #[test] 700 | fn test_scan_amount_symbol_first_ws() { 701 | let input = "EUR 25,0.01"; 702 | 703 | let (tokens, _) = scan_amount(input); 704 | 705 | assert_eq!("25,0.01", tokens.quantity); 706 | assert_eq!("EUR", tokens.symbol); 707 | } 708 | 709 | #[test] 710 | fn test_scan_amount_symbol_first() { 711 | let input = "EUR25,0.01"; 712 | 713 | let (tokens, _rest) = scan_amount(input); 714 | 715 | assert_eq!("25,0.01", tokens.quantity); 716 | assert_eq!("EUR", tokens.symbol); 717 | } 718 | 719 | #[test] 720 | fn test_scan_amount_symbol_first_neg() { 721 | let input = "EUR-25,0.01"; 722 | 723 | let (tokens, _rest) = scan_amount(input); 724 | 725 | assert_eq!("-25,0.01", tokens.quantity); 726 | assert_eq!("EUR", tokens.symbol); 727 | // assert_eq!("", actual[2]); 728 | // assert_eq!("", actual[3]); 729 | } 730 | 731 | #[test] 732 | fn test_scan_quantity_full() { 733 | let input = "5 VECP @ 13.68 EUR"; 734 | 735 | let (tokens, rest) = scan_amount(input); 736 | 737 | assert_eq!("5", tokens.quantity); 738 | assert_eq!("VECP", tokens.symbol); 739 | assert_eq!("@ 13.68 EUR", rest); 740 | } 741 | 742 | #[test] 743 | fn test_scan_symbol_quotes() { 744 | let input = " \"VECP\" @ 13.68 EUR"; 745 | 746 | let (actual, remainder) = scan_symbol(input); 747 | 748 | assert_eq!("\"VECP\"", actual); 749 | assert_eq!("@ 13.68 EUR", remainder); 750 | } 751 | 752 | #[test] 753 | fn test_scan_symbol() { 754 | let input = " VECP @ 13.68 EUR"; 755 | 756 | let (actual, remainder) = scan_symbol(input); 757 | 758 | assert_eq!("VECP", actual); 759 | assert_eq!("@ 13.68 EUR", remainder); 760 | } 761 | 762 | #[test] 763 | fn test_scan_symbol_only() { 764 | let input = " VECP "; 765 | 766 | let (actual, remainder) = scan_symbol(input); 767 | 768 | assert_eq!("VECP", actual); 769 | assert_eq!("", remainder); 770 | } 771 | 772 | #[test] 773 | fn test_scanning_cost() { 774 | let input = " Account 5 VAS @ 13.21 AUD"; 775 | 776 | let tokens = scan_post(input); 777 | 778 | // Check that the cost has been scanned 779 | assert_eq!("Account", tokens.account); 780 | assert_eq!("5", tokens.quantity); 781 | assert_eq!("VAS", tokens.symbol); 782 | assert_eq!("13.21", tokens.cost_quantity); 783 | assert_eq!("AUD", tokens.cost_symbol); 784 | assert_eq!(true, tokens.is_per_unit); 785 | } 786 | 787 | #[test] 788 | fn test_scanning_total_cost() { 789 | let input = " Account 5 VAS @@ 10 AUD"; 790 | 791 | let tokens = scan_post(input); 792 | 793 | // Check that the cost has been scanned 794 | assert_eq!("Account", tokens.account); 795 | assert_eq!("5", tokens.quantity); 796 | assert_eq!("VAS", tokens.symbol); 797 | assert_eq!("10", tokens.cost_quantity); 798 | assert_eq!("AUD", tokens.cost_symbol); 799 | } 800 | } 801 | 802 | #[cfg(test)] 803 | mod scan_annotations_tests { 804 | use crate::scanner::scan_post; 805 | use super::scan_annotations; 806 | 807 | #[test] 808 | fn test_scan_annotation_price() { 809 | let input = "{20 EUR}"; 810 | 811 | let (tokens, rest) = scan_annotations(input); 812 | 813 | assert_eq!("20", tokens.quantity); 814 | assert_eq!("EUR", tokens.symbol); 815 | assert_eq!("", rest); 816 | } 817 | 818 | #[test] 819 | fn test_scan_annotation_date() { 820 | let input = "[2023-11-07]"; 821 | 822 | let (tokens, rest) = scan_annotations(input); 823 | 824 | assert_eq!("2023-11-07", tokens.date); 825 | assert_eq!("", rest); 826 | } 827 | 828 | #[test] 829 | fn test_scan_annotation_price_and_date() { 830 | let input = "{20 EUR} [2023-11-07]"; 831 | 832 | let (tokens, rest) = scan_annotations(input); 833 | 834 | assert_eq!("20", tokens.quantity); 835 | assert_eq!("EUR", tokens.symbol); 836 | assert_eq!("2023-11-07", tokens.date); 837 | assert_eq!("", rest); 838 | } 839 | 840 | #[test] 841 | fn test_scan_sale_lot() { 842 | let input = " Assets:Stocks -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR"; 843 | 844 | let tokens = scan_post(input); 845 | 846 | // Assert 847 | assert_eq!("Assets:Stocks", tokens.account); 848 | assert_eq!("-10", tokens.quantity); 849 | assert_eq!("VEUR", tokens.symbol); 850 | // annotations 851 | assert_eq!("20", tokens.price_quantity); 852 | assert_eq!("EUR", tokens.price_commodity); 853 | assert_eq!("2023-04-01", tokens.price_date); 854 | // price 855 | assert_eq!("25", tokens.cost_quantity); 856 | assert_eq!("EUR", tokens.cost_symbol); 857 | } 858 | } 859 | 860 | #[cfg(test)] 861 | mod scanner_tests_amount { 862 | use super::scan_cost; 863 | 864 | #[test] 865 | fn test_scanning_costs() { 866 | let input = "@ 25.86 EUR"; 867 | 868 | let tokens = scan_cost(input); 869 | 870 | assert_eq!("25.86", tokens.quantity); 871 | assert_eq!("EUR", tokens.symbol); 872 | assert_eq!(true, tokens.is_per_unit); 873 | assert_eq!("", tokens.remainder); 874 | } 875 | 876 | #[test] 877 | fn test_scanning_cost_full() { 878 | let input = "@@ 25.86 EUR"; 879 | 880 | let tokens = scan_cost(input); 881 | 882 | assert_eq!("25.86", tokens.quantity); 883 | assert_eq!("EUR", tokens.symbol); 884 | assert_eq!(false, tokens.is_per_unit); 885 | assert_eq!("", tokens.remainder); 886 | } 887 | } 888 | 889 | #[cfg(test)] 890 | mod scanner_tests_price_directive { 891 | use super::scan_price_directive; 892 | 893 | #[test] 894 | fn test_scan_price_directive() { 895 | let line = "P 2022-03-03 13:00:00 EUR 1.12 USD"; 896 | 897 | let actual = scan_price_directive(line); 898 | 899 | assert_eq!("2022-03-03", actual[0]); 900 | assert_eq!("13:00:00", actual[1]); 901 | assert_eq!("EUR", actual[2]); 902 | assert_eq!("1.12", actual[3]); 903 | assert_eq!("USD", actual[4]); 904 | } 905 | } 906 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | * Parser with iterators 3 | * 4 | * Parses string tokens into model entities (Account, Transaction, Post, Amount...) 5 | * 6 | * The main idea here is to minimize memory allocations. 7 | * The parsing is done in functions, not objects. 8 | * Each parser will provide an iterator over the tokens it recognizes, i.e. Xact parser 9 | * will iterate over the Xact header items: date, payee, note. 10 | * Post parser provides an iterator over Account, Amount. Amount parser provides 11 | * sign, quantity, symbol, price. 12 | * Iterator returns None if a token is not present. 13 | * 14 | * Tokens are then handled by lexer, which creates instances of Structs and populates 15 | * the collections in the Journal. 16 | * It also creates links among the models. This functionality is from finalize() function. 17 | */ 18 | use core::panic; 19 | use std::{ 20 | env, 21 | io::{BufRead, BufReader, Read}, 22 | path::PathBuf, 23 | str::FromStr, 24 | todo, 25 | }; 26 | 27 | use anyhow::Error; 28 | use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; 29 | 30 | use crate::{ 31 | amount::{Amount, Quantity}, 32 | annotate::Annotation, 33 | journal::Journal, 34 | post::Post, 35 | scanner::{self, PostTokens}, 36 | xact::Xact, parse_file, 37 | }; 38 | 39 | pub const ISO_DATE_FORMAT: &str = "%Y-%m-%d"; 40 | pub const ISO_TIME_FORMAT: &str = "%H:%M:%S"; 41 | 42 | pub(crate) fn read_into_journal(source: T, journal: &mut Journal) { 43 | let mut parser = Parser::new(source, journal); 44 | 45 | parser.parse(); 46 | } 47 | 48 | /// Parses ISO-formatted date string, like 2023-07-23 49 | pub(crate) fn parse_date(date_str: &str) -> NaiveDate { 50 | // todo: support more date formats? 51 | 52 | NaiveDate::parse_from_str(date_str, ISO_DATE_FORMAT).expect("date parsed") 53 | } 54 | 55 | /// Create DateTime from date string only. 56 | pub fn parse_datetime(iso_str: &str) -> Result { 57 | Ok(NaiveDateTime::new( 58 | NaiveDate::parse_from_str(iso_str, ISO_DATE_FORMAT)?, 59 | NaiveTime::MIN, 60 | )) 61 | } 62 | 63 | pub fn parse_amount(amount_str: &str, journal: &mut Journal) -> Option { 64 | let (tokens, _) = scanner::scan_amount(amount_str); 65 | parse_amount_parts(tokens.quantity, tokens.symbol, journal) 66 | } 67 | 68 | /// Parse amount parts (quantity, commodity), i.e. "25", "AUD". 69 | /// Returns Amount. 70 | /// Panics if parsing fails. 71 | pub fn parse_amount_parts( 72 | quantity: &str, 73 | commodity: &str, 74 | journal: &mut Journal, 75 | ) -> Option { 76 | // Create Commodity, add to collection 77 | let commodity_ptr = journal.commodity_pool.find_or_create(commodity, None); 78 | 79 | if let Some(quantity) = Quantity::from_str(quantity) { 80 | Some(Amount::new(quantity, Some(commodity_ptr))) 81 | } else { 82 | None 83 | } 84 | } 85 | 86 | pub(crate) struct Parser<'j, T: Read> { 87 | pub journal: &'j mut Journal, 88 | 89 | reader: BufReader, 90 | buffer: String, 91 | } 92 | 93 | impl<'j, T: Read> Parser<'j, T> { 94 | pub fn new(source: T, journal: &'j mut Journal) -> Self { 95 | let reader = BufReader::new(source); 96 | // To avoid allocation, reuse the String variable. 97 | let buffer = String::new(); 98 | 99 | Self { 100 | reader, 101 | buffer, 102 | journal, 103 | } 104 | } 105 | 106 | /// Parse given input. 107 | /// Fill the Journal with parsed elements. 108 | pub fn parse(&mut self) { 109 | loop { 110 | match self.reader.read_line(&mut self.buffer) { 111 | Err(err) => { 112 | println!("Error: {:?}", err); 113 | break; 114 | } 115 | Ok(0) => { 116 | // end of file 117 | break; 118 | } 119 | Ok(_) => { 120 | // Remove the trailing newline characters 121 | // let trimmed = &line.trim_end(); 122 | 123 | match self.read_next_directive() { 124 | Ok(_) => (), // continue 125 | Err(err) => { 126 | log::error!("Error: {:?}", err); 127 | println!("Error: {:?}", err); 128 | break; 129 | } 130 | }; 131 | } 132 | } 133 | 134 | // clear the buffer before reading the next line. 135 | self.buffer.clear(); 136 | } 137 | } 138 | 139 | fn read_next_directive(&mut self) -> Result<(), String> { 140 | // if self.buffer.is_empty() { 141 | // return Ok(()); 142 | // } 143 | // let length = self.buffer.len(); 144 | // log::debug!("line length: {:?}", length); 145 | if self.buffer == "\r\n" || self.buffer == "\n" { 146 | return Ok(()); 147 | } 148 | 149 | // determine what the line is 150 | match self.buffer.chars().peekable().peek().unwrap() { 151 | // comments 152 | ';' | '#' | '*' | '|' => { 153 | // ignore 154 | return Ok(()); 155 | } 156 | 157 | '-' => { 158 | // option_directive 159 | } 160 | 161 | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { 162 | // Starts with date/number. 163 | let _ = self.xact_directive(); 164 | } 165 | 166 | ' ' | '\t' => { 167 | todo!("complete") 168 | } 169 | 170 | // The rest 171 | _ => { 172 | // 4.7.2 command directives 173 | 174 | if self.general_directive() { 175 | return Ok(()); 176 | } 177 | 178 | let c = 'P'; 179 | match c { 180 | // ACDNPY 181 | 'P' => { 182 | // a pricing xact 183 | self.price_xact_directive(); 184 | } 185 | 186 | c => { 187 | log::warn!("not handled: {:?}", c); 188 | todo!("handle other directives"); 189 | } 190 | } 191 | // TODO: todo!("the rest") 192 | } 193 | } 194 | 195 | Ok(()) 196 | } 197 | 198 | /// textual.cc 199 | /// bool instance_t::general_directive(char *line) 200 | fn general_directive(&mut self) -> bool { 201 | // todo: skip if (*p == '@' || *p == '!') 202 | 203 | // split directive and argument 204 | let mut iter = self.buffer.split_whitespace(); 205 | let Some(directive) = iter.next() else { 206 | panic!("no directive?") 207 | }; 208 | let argument = iter.next(); 209 | 210 | // todo: check arguments for directives that require one 211 | // match directive { 212 | // "comment" | "end" | "python" | "test" | "year" | "Y" => { 213 | // // 214 | // // Year can also be specified with one letter? 215 | // () 216 | // } 217 | // _ => { 218 | // panic!("The directive {:?} requires an argument!", directive); 219 | // } 220 | // } 221 | 222 | match directive.chars().peekable().peek().unwrap() { 223 | 'a' => { 224 | todo!("a"); 225 | } 226 | 227 | // bcde 228 | 'i' => match directive { 229 | "include" => { 230 | let own_argument = argument.unwrap().to_owned(); 231 | self.include_directive(&own_argument); 232 | return true; 233 | } 234 | "import" => { 235 | todo!("import directive") 236 | } 237 | _ => (), 238 | }, 239 | 240 | // ptvy 241 | _ => { 242 | todo!("handle") 243 | } 244 | } 245 | 246 | // lookup(DIRECTIVE, self.buffer) 247 | 248 | false 249 | } 250 | 251 | fn price_xact_directive(&mut self) { 252 | // pass on to the commodity pool 253 | self.journal 254 | .commodity_pool 255 | .parse_price_directive(&self.buffer); 256 | } 257 | 258 | fn create_xact(&mut self) -> *const Xact { 259 | let tokens = scanner::tokenize_xact_header(&self.buffer); 260 | let xact = Xact::create(tokens[0], tokens[1], tokens[2], tokens[3]); 261 | 262 | // Add xact to the journal 263 | let new_ref = self.journal.add_xact(xact); 264 | 265 | new_ref 266 | } 267 | 268 | fn xact_directive(&mut self) -> Result<(), Error> { 269 | let xact_ptr = self.create_xact() as *mut Xact; 270 | 271 | // Read the Xact contents (Posts, Comments, etc.) 272 | // Read until separator (empty line). 273 | loop { 274 | self.buffer.clear(); // empty the buffer before reading 275 | match self.reader.read_line(&mut self.buffer) { 276 | Err(e) => { 277 | println!("Error: {:?}", e); 278 | break; 279 | } 280 | Ok(0) => { 281 | // end of file 282 | log::debug!("0-length buffer"); 283 | break; 284 | } 285 | Ok(_) => { 286 | if self.buffer.is_empty() { 287 | // panic!("Unexpected whitespace at the beginning of line!") 288 | todo!("Check what happens here") 289 | } 290 | 291 | // parse line 292 | match self.buffer.chars().peekable().peek() { 293 | Some(' ') => { 294 | // valid line, starts with space. 295 | let input = self.buffer.trim_start(); 296 | 297 | // if the line is blank after trimming, exit (end the transaction). 298 | if input.is_empty() { 299 | break; 300 | } 301 | 302 | // Process the Xact content line. Could be a Comment or a Post. 303 | match input.chars().peekable().peek() { 304 | Some(';') => { 305 | self.parse_trailing_note(xact_ptr); 306 | } 307 | _ => { 308 | parse_post(input, xact_ptr, &mut self.journal)?; 309 | } 310 | } 311 | } 312 | Some('\r') | Some('\n') => { 313 | // empty line "\r\n". Exit. 314 | break; 315 | } 316 | _ => { 317 | panic!("should not happen") 318 | } 319 | } 320 | } 321 | } 322 | 323 | // empty the buffer before exiting. 324 | self.buffer.clear(); 325 | } 326 | 327 | // "finalize" transaction 328 | crate::xact::finalize(xact_ptr, &mut self.journal); 329 | 330 | Ok(()) 331 | } 332 | 333 | /// textual.cc 334 | /// void instance_t::include_directive(char *line) 335 | fn include_directive(&mut self, argument: &str) { 336 | let mut filename: PathBuf; 337 | 338 | // if (line[0] != '/' && line[0] != '\\' && line[0] != '~') 339 | if argument.starts_with('/') || argument.starts_with('\\') || argument.starts_with('~') { 340 | filename = PathBuf::from_str(argument).unwrap(); 341 | } else { 342 | // relative path 343 | /* 344 | filename = PathBuf::from_str(argument).unwrap(); 345 | // get the parent path? 346 | match filename.parent() { 347 | Some(parent) => { 348 | // absolute path 349 | filename = parent.to_path_buf(); 350 | }, 351 | None => { 352 | // no parent path, use current directory 353 | filename = env::current_dir().unwrap(); 354 | }, 355 | } 356 | */ 357 | filename = env::current_dir().unwrap(); 358 | filename.push(argument); 359 | } 360 | 361 | // TODO: resolve glob, i.e *.ledger 362 | 363 | // let mut file_found = false; 364 | let parent_path = filename.parent().unwrap(); 365 | if parent_path.exists() { 366 | if filename.is_file() { 367 | // let base = filename.file_name(); 368 | 369 | // read file. 370 | parse_file(filename.to_str().unwrap(), self.journal); 371 | } 372 | } 373 | 374 | // if !file_found { 375 | // panic!("Include file not found"); 376 | // } 377 | } 378 | 379 | /// Parses the trailing note from the buffer. 380 | /// xact_index = The index of the current transaction, being parsed. 381 | /// The note is added either to the transaction or the last post, based on it's position. 382 | /// 383 | fn parse_trailing_note(&mut self, xact_ptr: *mut Xact) { 384 | // This is a trailing note, and possibly a metadata info tag 385 | // It is added to the previous element (xact/post). 386 | 387 | let note = self.buffer.trim_start(); 388 | // The note starts with the comment character `;`. 389 | let note = note[1..].trim(); 390 | if note.is_empty() { 391 | return; 392 | } 393 | 394 | // let xact = self.journal.xacts.get_mut(xact_index).unwrap(); 395 | let xact: &mut Xact; 396 | unsafe { 397 | xact = &mut *xact_ptr; 398 | } 399 | if xact.posts.is_empty() { 400 | // The first comment. Add to the xact. 401 | // let xact_mut = self.journal.xacts.get_mut(xact_index).unwrap(); 402 | xact.add_note(note); 403 | } else { 404 | // Post comment. Add to the previous posting. 405 | // let last_post_index = xact.posts.last().unwrap(); 406 | let last_post = xact.posts.last_mut().unwrap(); 407 | // let post = self.journal.get_post_mut(*last_post_index); 408 | last_post.add_note(note); 409 | } 410 | } 411 | } 412 | 413 | /// Parses Post from the buffer, adds it to the Journal and links 414 | /// to Xact, Account, etc. 415 | fn parse_post(input: &str, xact_ptr: *const Xact, journal: &mut Journal) -> Result<(), Error> { 416 | let tokens = scanner::scan_post(input); 417 | 418 | // TODO: Make this more like Ledger now that we have pointers. 419 | 420 | // Create Account, add to collection 421 | let account_ptr = journal.register_account(tokens.account).unwrap(); 422 | 423 | // create amount 424 | let amount_opt = parse_amount_parts(tokens.quantity, tokens.symbol, journal); 425 | 426 | // parse and add annotations. 427 | { 428 | let annotation = Annotation::parse( 429 | tokens.price_date, 430 | tokens.price_quantity, 431 | tokens.price_commodity, 432 | journal, 433 | )?; 434 | 435 | // TODO: if the cost price is total (not per unit) 436 | // details.price /= amount 437 | 438 | // store annotation 439 | journal 440 | .commodity_pool 441 | .find_or_create(tokens.symbol, Some(annotation)); 442 | } 443 | 444 | // handle cost (2nd amount) 445 | let cost_option = parse_cost(&tokens, &amount_opt, journal); 446 | 447 | // note 448 | // TODO: parse note 449 | let note = None; 450 | 451 | // Create Post, link Xact, Account, Commodity 452 | let post_ref: &Post; 453 | { 454 | let post: Post; 455 | post = Post::new(account_ptr, xact_ptr, amount_opt, cost_option, note); 456 | 457 | // add Post to Xact. 458 | // let xact = journal.xacts.get_mut(xact_ptr).unwrap(); 459 | let xact: &mut Xact; 460 | unsafe { 461 | xact = &mut *(xact_ptr.cast_mut()); 462 | } 463 | // xact.post_indices.push(post_index); 464 | post_ref = xact.add_post(post); 465 | } 466 | 467 | // add Post to Account.posts 468 | { 469 | //let account = journal.accounts.get_mut(account_ptr).unwrap(); 470 | let account = journal.get_account_mut(account_ptr); 471 | account.posts.push(post_ref); 472 | } 473 | 474 | Ok(()) 475 | } 476 | 477 | fn parse_cost( 478 | tokens: &PostTokens, 479 | amount: &Option, 480 | journal: &mut Journal, 481 | ) -> Option { 482 | if tokens.cost_quantity.is_empty() || amount.is_none() { 483 | return None; 484 | } 485 | 486 | // parse cost (per-unit vs total) 487 | let cost_result = parse_amount_parts(tokens.cost_quantity, tokens.cost_symbol, journal); 488 | if cost_result.is_none() { 489 | return None; 490 | } 491 | let mut cost = cost_result.unwrap(); 492 | 493 | if tokens.is_per_unit { 494 | // per-unit cost 495 | let mut cost_val = cost; 496 | cost_val *= amount.unwrap(); 497 | cost = cost_val; 498 | } 499 | // Total cost is already the end-value. 500 | 501 | Some(cost) 502 | } 503 | 504 | #[cfg(test)] 505 | mod tests { 506 | use std::{io::Cursor, todo}; 507 | 508 | use super::Parser; 509 | use crate::journal::Journal; 510 | 511 | /// Enable this test again when the functionality is complete 512 | #[test] 513 | fn test_general_directive() { 514 | let source = Cursor::new("include tests/basic.ledger"); 515 | let mut journal = Journal::new(); 516 | 517 | let mut parser = Parser::new(source, &mut journal); 518 | 519 | parser.parse(); 520 | //parser.general_directive(); 521 | 522 | todo!("assert") 523 | } 524 | 525 | /// A transaction record, after which comes a line with spaces only. 526 | /// This should be parseable. 527 | #[test] 528 | fn test_xact_with_space_after() { 529 | let src = r#"; 530 | 2023-05-05 Payee 531 | Expenses 25 EUR 532 | Assets 533 | 534 | "#; 535 | let source = Cursor::new(src); 536 | let mut journal = Journal::new(); 537 | let mut parser = Parser::new(source, &mut journal); 538 | 539 | // Act 540 | parser.parse(); 541 | 542 | // Assert 543 | assert_eq!(3, journal.master.flatten_account_tree().len()); 544 | } 545 | } 546 | 547 | #[cfg(test)] 548 | mod full_tests { 549 | use std::io::Cursor; 550 | 551 | use crate::{journal::Journal, parser::read_into_journal}; 552 | 553 | #[test] 554 | fn test_minimal_parsing() { 555 | let input = r#"; Minimal transaction 556 | 2023-04-10 Supermarket 557 | Expenses 20 558 | Assets 559 | "#; 560 | let cursor = Cursor::new(input); 561 | let mut journal = Journal::new(); 562 | 563 | // Act 564 | super::read_into_journal(cursor, &mut journal); 565 | 566 | // Assert 567 | assert_eq!(1, journal.xacts.len()); 568 | 569 | let xact = journal.xacts.first().unwrap(); 570 | assert_eq!("Supermarket", xact.payee); 571 | assert_eq!(2, xact.posts.len()); 572 | 573 | let post1 = &xact.posts[0]; 574 | assert_eq!("Expenses", journal.get_account(post1.account).name); 575 | assert_eq!("20", post1.amount.as_ref().unwrap().quantity.to_string()); 576 | assert_eq!(None, post1.amount.as_ref().unwrap().get_commodity()); 577 | 578 | let post2 = &xact.posts[1]; 579 | assert_eq!("Assets", journal.get_account(post2.account).name); 580 | } 581 | 582 | #[test] 583 | fn test_multiple_currencies_one_xact() { 584 | let input = r#"; 585 | 2023-05-05 Payee 586 | Assets:Cash EUR -25 EUR 587 | Assets:Cash USD 30 USD 588 | "#; 589 | let cursor = Cursor::new(input); 590 | let mut journal = Journal::new(); 591 | 592 | // Act 593 | read_into_journal(cursor, &mut journal); 594 | 595 | // Assert 596 | assert_eq!(2, journal.commodity_pool.commodities.len()); 597 | } 598 | } 599 | 600 | #[cfg(test)] 601 | mod parser_tests { 602 | use std::{assert_eq, io::Cursor}; 603 | 604 | use crate::{ 605 | journal::Journal, 606 | parser::{self, read_into_journal}, 607 | }; 608 | 609 | #[test] 610 | fn test_minimal_parser() { 611 | let input = r#"; Minimal transaction 612 | 2023-04-10 Supermarket 613 | Expenses 20 614 | Assets 615 | "#; 616 | let cursor = Cursor::new(input); 617 | let mut journal = Journal::new(); 618 | 619 | // Act 620 | parser::read_into_journal(cursor, &mut journal); 621 | 622 | // Assert 623 | 624 | assert_eq!(1, journal.xacts.len()); 625 | 626 | let xact = journal.xacts.first().unwrap(); 627 | assert_eq!("Supermarket", xact.payee); 628 | assert_eq!(2, xact.posts.len()); 629 | 630 | // let exp_account = journal.get 631 | let post1 = &xact.posts[0]; 632 | assert_eq!("Expenses", journal.get_account(post1.account).name); 633 | assert_eq!("20", post1.amount.as_ref().unwrap().quantity.to_string()); 634 | assert_eq!(None, post1.amount.as_ref().unwrap().get_commodity()); 635 | 636 | // let post_2 = xact.posts.iter().nth(1).unwrap(); 637 | let post2 = &xact.posts[1]; 638 | assert_eq!("Assets", journal.get_account(post2.account).name); 639 | } 640 | 641 | #[test] 642 | fn test_parse_standard_xact() { 643 | let input = r#"; Standard transaction 644 | 2023-04-10 Supermarket 645 | Expenses 20 EUR 646 | Assets 647 | "#; 648 | let cursor = Cursor::new(input); 649 | let mut journal = Journal::new(); 650 | 651 | super::read_into_journal(cursor, &mut journal); 652 | 653 | // Assert 654 | // Xact 655 | assert_eq!(1, journal.xacts.len()); 656 | 657 | if let Some(xact) = journal.xacts.first() { 658 | // assert!(xact.is_some()); 659 | 660 | assert_eq!("Supermarket", xact.payee); 661 | 662 | // Posts 663 | let posts = &xact.posts; 664 | assert_eq!(2, posts.len()); 665 | 666 | let acc1 = journal.get_account(posts[0].account); 667 | assert_eq!("Expenses", acc1.name); 668 | let acc2 = journal.get_account(posts[1].account); 669 | assert_eq!("Assets", acc2.name); 670 | } else { 671 | assert!(false); 672 | } 673 | } 674 | 675 | #[test_log::test] 676 | fn test_parse_trade_xact() { 677 | // Arrange 678 | let input = r#"; Standard transaction 679 | 2023-04-10 Supermarket 680 | Assets:Investment 20 VEUR @ 10 EUR 681 | Assets 682 | "#; 683 | let cursor = Cursor::new(input); 684 | let mut journal = Journal::new(); 685 | 686 | // Act 687 | read_into_journal(cursor, &mut journal); 688 | 689 | // Assert 690 | 691 | let xact = journal.xacts.first().unwrap(); 692 | assert_eq!("Supermarket", xact.payee); 693 | let posts = &xact.posts; 694 | assert_eq!(2, posts.len()); 695 | 696 | // post 1 697 | let p1 = &posts[0]; 698 | let account = journal.get_account(p1.account); 699 | assert_eq!("Investment", account.name); 700 | let parent = journal.get_account(account.parent); 701 | assert_eq!("Assets", parent.name); 702 | // amount 703 | let Some(a1) = &p1.amount else { panic!() }; 704 | assert_eq!("20", a1.quantity.to_string()); 705 | let comm1 = a1.get_commodity().unwrap(); 706 | assert_eq!("VEUR", comm1.symbol); 707 | let Some(ref cost1) = p1.cost else { panic!() }; 708 | // cost 709 | assert_eq!(200, cost1.quantity.into()); 710 | assert_eq!("EUR", cost1.get_commodity().unwrap().symbol); 711 | 712 | // post 2 713 | let p2 = &posts[1]; 714 | assert_eq!("Assets", journal.get_account(p2.account).name); 715 | // amount 716 | let Some(a2) = &p2.amount else { panic!() }; 717 | assert_eq!("-200", a2.quantity.to_string()); 718 | let comm2 = a2.get_commodity().unwrap(); 719 | assert_eq!("EUR", comm2.symbol); 720 | 721 | assert!(p2.cost.is_none()); 722 | } 723 | 724 | #[test] 725 | fn test_parsing_account_tree() { 726 | let input = r#" 727 | 2023-05-23 Payee 728 | Assets:Eur -20 EUR 729 | Assets:USD 30 USD 730 | Trading:Eur 20 EUR 731 | Trading:Usd -30 USD 732 | "#; 733 | 734 | let cursor = Cursor::new(input); 735 | let mut journal = Journal::new(); 736 | 737 | // Act 738 | read_into_journal(cursor, &mut journal); 739 | 740 | // Assert 741 | let xact = &journal.xacts[0]; 742 | assert_eq!(1, journal.xacts.len()); 743 | assert_eq!(4, xact.posts.len()); 744 | assert_eq!(7, journal.master.flatten_account_tree().len()); 745 | assert_eq!(2, journal.commodity_pool.commodities.len()); 746 | } 747 | } 748 | 749 | #[cfg(test)] 750 | mod posting_parsing_tests { 751 | use std::io::Cursor; 752 | 753 | use super::Parser; 754 | use crate::{amount::Quantity, journal::Journal, parse_file, parser::parse_datetime}; 755 | 756 | #[test] 757 | fn test_parsing_buy_lot() { 758 | let file_path = "tests/lot.ledger"; 759 | let mut j = Journal::new(); 760 | 761 | // Act 762 | parse_file(file_path, &mut j); 763 | 764 | // Assert 765 | assert_eq!(1, j.xacts.len()); 766 | assert_eq!(4, j.master.flatten_account_tree().len()); 767 | assert_eq!(2, j.commodity_pool.commodities.len()); 768 | // price 769 | assert_eq!(2, j.commodity_pool.commodity_history.node_count()); 770 | assert_eq!(1, j.commodity_pool.commodity_history.edge_count()); 771 | // Check price: 10 VEUR @ 12.75 EUR 772 | let price = j 773 | .commodity_pool 774 | .commodity_history 775 | .edge_weight(0.into()) 776 | .unwrap(); 777 | let expected_date = parse_datetime("2023-05-01").unwrap(); 778 | // let existing_key = price.keys().nth(0).unwrap(); 779 | assert!(price.contains_key(&expected_date)); 780 | let value = price.get(&expected_date).unwrap(); 781 | assert_eq!(Quantity::from(12.75), *value); 782 | } 783 | 784 | #[test] 785 | fn test_buy_lot_cost() { 786 | let file_path = "tests/lot.ledger"; 787 | let mut j = Journal::new(); 788 | 789 | // Act 790 | parse_file(file_path, &mut j); 791 | 792 | // Assert the price of "10 VEUR @ 12.75 EUR" must to be 127.50 EUR 793 | let xact = j.xacts.get(0).unwrap(); 794 | let post = &xact.posts[0]; 795 | let cost = post.cost.unwrap(); 796 | assert_eq!(cost.quantity, 127.5.into()); 797 | 798 | let eur = j.commodity_pool.find("EUR").unwrap(); 799 | assert_eq!(cost.commodity, eur); 800 | } 801 | 802 | #[test] 803 | fn test_parsing_trailing_xact_comment() { 804 | let input = r#"2023-03-02 Payee 805 | ; this is xact comment 806 | Expenses 20 EUR 807 | Assets 808 | "#; 809 | let journal = &mut Journal::new(); 810 | let mut parser = Parser::new(Cursor::new(input), journal); 811 | 812 | parser.parse(); 813 | 814 | // Assert 815 | assert!(journal.xacts[0].note.is_some()); 816 | assert_eq!( 817 | Some("this is xact comment".to_string()), 818 | journal.xacts[0].note 819 | ); 820 | } 821 | 822 | #[test] 823 | fn test_parsing_trailing_post_comment() { 824 | let input = r#"2023-03-02 Payee 825 | Expenses 20 EUR 826 | ; this is post comment 827 | Assets 828 | "#; 829 | let journal = &mut Journal::new(); 830 | let mut parser = Parser::new(Cursor::new(input), journal); 831 | 832 | parser.parse(); 833 | 834 | // Assert 835 | let xact = &journal.xacts[0]; 836 | assert!(xact.posts[0].note.is_some()); 837 | assert!(xact.posts[1].note.is_none()); 838 | assert_eq!(Some("this is post comment".to_string()), xact.posts[0].note); 839 | } 840 | } 841 | 842 | #[cfg(test)] 843 | mod amount_parsing_tests { 844 | use super::Amount; 845 | use crate::{amount::Quantity, journal::Journal, parser::parse_post, xact::Xact}; 846 | 847 | fn setup() -> Journal { 848 | let mut journal = Journal::new(); 849 | let xact = Xact::create("2023-05-02", "", "Supermarket", ""); 850 | journal.add_xact(xact); 851 | 852 | journal 853 | } 854 | 855 | #[test] 856 | fn test_positive_no_commodity() { 857 | let expected = Amount::new(20.into(), None); 858 | let actual = Amount::new(20.into(), None); 859 | 860 | assert_eq!(expected, actual); 861 | } 862 | 863 | #[test] 864 | fn test_negative_no_commodity() { 865 | let actual = Amount::new((-20).into(), None); 866 | let expected = Amount::new((-20).into(), None); 867 | 868 | assert_eq!(expected, actual); 869 | } 870 | 871 | #[test] 872 | fn test_pos_w_commodity_separated() { 873 | const SYMBOL: &str = "EUR"; 874 | let mut journal = setup(); 875 | let eur_ptr = journal.commodity_pool.create(SYMBOL, None); 876 | let xact_ptr = &journal.xacts[0] as *const Xact; 877 | let expected = Amount::new(20.into(), Some(eur_ptr)); 878 | 879 | // Act 880 | 881 | let _ = parse_post(" Assets 20 EUR", xact_ptr, &mut journal); 882 | 883 | // Assert 884 | let xact = &journal.xacts[0]; 885 | let post = xact.posts.first().unwrap(); 886 | let amount = &post.amount.unwrap(); 887 | 888 | assert_eq!(&expected.commodity, &amount.commodity); 889 | assert_eq!(expected, *amount); 890 | 891 | // commodity 892 | let c = amount.get_commodity().unwrap(); 893 | assert_eq!("EUR", c.symbol); 894 | } 895 | 896 | #[test] 897 | fn test_neg_commodity_separated() { 898 | const SYMBOL: &str = "EUR"; 899 | let mut journal = setup(); 900 | let eur_ptr = journal.commodity_pool.create(SYMBOL, None); 901 | let expected = Amount::new((-20).into(), Some(eur_ptr)); 902 | let xact_ptr = &journal.xacts[0] as *const Xact; 903 | 904 | // Act 905 | let _ = parse_post(" Assets -20 EUR", xact_ptr, &mut journal); 906 | 907 | // Assert 908 | let xact = &journal.xacts[0]; 909 | let post = xact.posts.first().unwrap(); 910 | let Some(amt) = &post.amount else { panic!() }; 911 | assert_eq!(&expected, amt); 912 | 913 | let commodity = amt.get_commodity().unwrap(); 914 | assert_eq!("EUR", commodity.symbol); 915 | } 916 | 917 | #[test] 918 | fn test_full_w_commodity_separated() { 919 | // Arrange 920 | let mut journal = setup(); 921 | let xact_ptr = &journal.xacts[0] as *const Xact; 922 | 923 | // Act 924 | let _ = parse_post(" Assets -20000.00 EUR", xact_ptr, &mut journal); 925 | let xact = &journal.xacts[0]; 926 | let post = xact.posts.first().unwrap(); 927 | let Some(ref amount) = post.amount else { 928 | panic!() 929 | }; 930 | 931 | // Assert 932 | assert_eq!("-20000.00", amount.quantity.to_string()); 933 | assert_eq!("EUR", amount.get_commodity().unwrap().symbol); 934 | } 935 | 936 | #[test] 937 | fn test_full_commodity_first() { 938 | // Arrange 939 | let mut journal = setup(); 940 | let xact_ptr = &journal.xacts[0] as *const Xact; 941 | 942 | // Act 943 | let _ = parse_post(" Assets A$-20000.00", xact_ptr, &mut journal); 944 | let xact = &journal.xacts[0]; 945 | let post = xact.posts.first().unwrap(); 946 | let Some(ref amount) = post.amount else { 947 | panic!() 948 | }; 949 | 950 | // Assert 951 | assert_eq!("-20000.00", amount.quantity.to_string()); 952 | assert_eq!("A$", amount.get_commodity().unwrap().symbol); 953 | } 954 | 955 | #[test] 956 | fn test_quantity_separators() { 957 | let input = "-1000000.00"; 958 | let expected = Quantity::from(-1_000_000); 959 | 960 | let amount = Amount::new(Quantity::from_str(input).unwrap(), None); 961 | 962 | let actual = amount.quantity; 963 | 964 | assert_eq!(expected, actual); 965 | } 966 | 967 | #[test] 968 | fn test_addition() { 969 | //let c1 = Commodity::new("EUR"); 970 | let left = Amount::new(10.into(), None); 971 | // let c2 = Commodity::new("EUR"); 972 | let right = Amount::new(15.into(), None); 973 | 974 | let actual = left + right; 975 | 976 | assert_eq!(Quantity::from(25), actual.quantity); 977 | // assert!(actual.commodity.is_some()); 978 | // assert_eq!("EUR", actual.commodity.unwrap().symbol); 979 | } 980 | 981 | #[test] 982 | fn test_add_assign() { 983 | // let c1 = Commodity::new("EUR"); 984 | let mut actual = Amount::new(21.into(), None); 985 | // let c2 = Commodity::new("EUR"); 986 | let other = Amount::new(13.into(), None); 987 | 988 | // actual += addition; 989 | actual.add(&other); 990 | 991 | assert_eq!(Quantity::from(34), actual.quantity); 992 | } 993 | 994 | #[test] 995 | fn test_copy_from_no_commodity() { 996 | let other = Amount::new(10.into(), None); 997 | let actual = Amount::copy_from(&other); 998 | 999 | assert_eq!(Quantity::from(10), actual.quantity); 1000 | // assert_eq!(None, actual.commodity); 1001 | } 1002 | } 1003 | --------------------------------------------------------------------------------