├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------