├── .gitattributes ├── .github └── workflows │ ├── gh_pages.yml │ ├── main.yml │ ├── project.yml │ └── tags.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── README.md ├── docs ├── book.toml └── src │ ├── SUMMARY.md │ ├── differences_with_ledger.md │ ├── journal-file.md │ ├── readme.md │ ├── register-report.md │ └── repl-mode.md ├── examples └── grammar.rs ├── scripts ├── bench.sh ├── brew.sh ├── compare.sh └── publish_tag.sh ├── src ├── app.rs ├── commands.rs ├── commands │ ├── accounts.rs │ ├── balance.rs │ ├── commodities.rs │ ├── payees.rs │ ├── prices.rs │ ├── register.rs │ ├── roi.rs │ └── statistics.rs ├── dinero.rs ├── error.rs ├── filter.rs ├── grammar │ └── grammar.pest ├── list.rs ├── mod.rs ├── models.rs ├── models │ ├── account.rs │ ├── balance.rs │ ├── comment.rs │ ├── currency.rs │ ├── money.rs │ ├── payee.rs │ ├── price.rs │ └── transaction.rs ├── parser.rs └── parser │ ├── include.rs │ ├── tokenizers │ ├── account.rs │ ├── commodity.rs │ ├── mod.rs │ ├── payee.rs │ ├── price.rs │ ├── tag.rs │ └── transaction.rs │ ├── utils.rs │ └── value_expr.rs └── tests ├── common.rs ├── example_files ├── automated.ledger ├── automated_fail.ledger ├── collapse_demo.ledger ├── demo.ledger ├── demo_bad.ledger ├── empty_ledgerrc ├── example_bad_ledgerrc ├── example_bad_ledgerrc2 ├── example_ledgerrc ├── exchange.ledger ├── hledger_roi.ledger ├── include.ledger ├── prices.ledger ├── quotes │ ├── bitcoin.dat │ └── sp500.dat ├── reg_exchange.ledger ├── roi_fail_currencies.ledger ├── tags.ledger └── virtual_postings.ledger ├── test_accounts.rs ├── test_balances.rs ├── test_commands.rs ├── test_currency_formats.rs ├── test_exchange.rs ├── test_expressions.rs ├── test_failures.rs ├── test_include.rs ├── test_prices.rs ├── test_repl.rs └── test_tags.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | *.dat filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/workflows/gh_pages.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | # - Check that the package passes the tests correctly in all Rust versions 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | update-documentation: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install stable toolchain 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: stable 20 | - name: Setup mdBook 21 | uses: peaceiris/actions-mdbook@v1 22 | with: 23 | mdbook-version: 'latest' 24 | - run: cd docs && mdbook build 25 | 26 | - name: Deploy 27 | uses: peaceiris/actions-gh-pages@v3 28 | with: 29 | github_token: ${{ secrets.GITHUB_TOKEN }} 30 | publish_dir: ./docs/book 31 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Pass tests 2 | # - Check that the package passes the tests correctly in all Rust versions 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | test-coverage: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Install stable toolchain 16 | uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | - name: Run cargo-tarpaulin 20 | uses: actions-rs/tarpaulin@v0.1 21 | with: 22 | args: '--ignore-tests --exclude-files examples/* src/main.rs --out Xml --run-types AllTargets' 23 | - name: Upload to codecov.io 24 | uses: codecov/codecov-action@v1 25 | # with: 26 | # token: ${{secrets.CODECOV_TOKEN}} 27 | test-check: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | rust: [beta, nightly] 32 | 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Install rust toolchain 36 | uses: actions-rs/toolchain@v1 # https://github.com/marketplace/actions/rust-toolchain 37 | with: 38 | toolchain: ${{ matrix.rust }} 39 | override: true 40 | - name: Build 41 | run: cargo build 42 | - name: Run tests 43 | run: cargo test 44 | -------------------------------------------------------------------------------- /.github/workflows/project.yml: -------------------------------------------------------------------------------- 1 | name: Auto assign issues to project 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | pull_request: 7 | types: [opened] 8 | env: 9 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 10 | 11 | jobs: 12 | assign_one_project: 13 | runs-on: ubuntu-latest 14 | name: Assign to project 15 | steps: 16 | - name: Assign NEW issues and NEW pull requests to the repo project 17 | uses: srggrs/assign-one-project-github-action@1.2.1 18 | if: github.event.action == 'opened' 19 | with: 20 | project: 'https://github.com/frosklis/dinero-rs/projects/1' 21 | -------------------------------------------------------------------------------- /.github/workflows/tags.yml: -------------------------------------------------------------------------------- 1 | name: Publish and deploy 2 | # - Check that the package passes the tests correctly in all Rust versions 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | publish-crate: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install stable toolchain 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | toolchain: stable 20 | - name: Publish to crates.io 21 | env: 22 | CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} 23 | run: cargo publish --token ${CARGO_TOKEN} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .idea 4 | .vscode 5 | personal.ledger 6 | docs/book 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Changelog file for dinero-rs project, a command line application for managing finances. 3 | ## [0.33.4] - 2022-01-02 4 | ## Fixed 5 | - Handle conversion error in `balance` command 6 | 7 | ## [0.33.3] - 2021-12-30 8 | ## Fixed 9 | - Handle conversion error in `roi` command 10 | 11 | ## [0.33.2] - 2021-12-29 12 | ## Fixed 13 | - Weird error when parsing one commodity directive 14 | - Better help texts (just so slightly) 15 | ## Changed 16 | - Nicer error messages (without Rust trace) when there is a missing file. 17 | ## [0.33.1] - 2021-09-26 18 | 19 | ## Added 20 | - [Convert option](https://github.com/frosklis/dinero-rs/issues/147) for ```balance``` command 21 | 22 | ## Changed 23 | - Nicer error messages (without Rust trace) when there is a missing file. 24 | ## Fixed 25 | - [Rounding](https://github.com/frosklis/dinero-rs/issues/142) 26 | - Dates from the future are shown in green 27 | - [Transaction cleared status is now correctly processed](https://github.com/frosklis/dinero-rs/issues/146) 28 | 29 | ## [0.32.3] - 2021-08-24 30 | - The last one was a bad release 31 | ## [0.32.2] - 2021-08-24 32 | ### Fixed 33 | - Now [parameters can be overriden](https://github.com/frosklis/dinero-rs/issues/138) 34 | 35 | ## [0.32.1] - 2021-08-24 36 | ### Changed 37 | - continuous integration pipeline 38 | ## [0.32.0] - 2021-08-24 39 | ### Added 40 | - Implemented ```date-format``` 41 | - Added ```--calendar``` to the ```roi``` command, showing a [calendar view of TWR](https://github.com/frosklis/dinero-rs/issues/115). 42 | - Added ```--no-summary``` flag to the ```roi``` command, to suppress the summary after the table 43 | - Implemented [```--related``` flag](https://github.com/frosklis/dinero-rs/issues/102) 44 | ### Fixed 45 | - [```args-only```](https://github.com/frosklis/dinero-rs/issues/120) 46 | 47 | ## [0.31.0] - 2021-08-22 48 | ### Added 49 | - [The ```roi``` command](https://github.com/frosklis/dinero-rs/issues/115) is good enough 50 | 51 | ### Fixed 52 | - [Currencies are shown consistently in a report](https://github.com/frosklis/dinero-rs/issues/103) 53 | - Read quantities like ```-$0.25```, [bug](https://github.com/frosklis/dinero-rs/issues/126) 54 | 55 | ## [0.30.0] - 2021-08-18 56 | ## Added 57 | - Show more info when loading the repl 58 | - Ability to [reload the journal](https://github.com/frosklis/dinero-rs/issues/116) 59 | ## Fixed 60 | - [```Some payees were None```](https://github.com/frosklis/dinero-rs/issues/121) 61 | 62 | ## [0.29.1] - 2021-08-17 63 | ## Changed 64 | - small improvements on REPL interface 65 | - improved test coverage 66 | ## [0.29.0] - 2021-08-16 67 | ### Added 68 | - ```exchange``` option (```-X```) for register reports 69 | - REPL interface, which is faster than the CLI once everything's loaded 70 | ### Changed 71 | - Some internal tests now use the ```--init-file``` flag to make sure the environment is properly replicated. 72 | - Updated dependency from ```assert_cmd to 2.0``` 73 | 74 | ## [0.28.1] - 2021-08-10 75 | ### Fixed 76 | - The previous crate was created badly. 77 | 78 | ## [0.28.0] - 2021-08-09 79 | ### Added 80 | - ```--collapse``` flag to collapse postings with the same currency and account 81 | ## [0.27.0] - 2021-08-04 82 | ### Fixed 83 | - Negative quantities starting with zero now show the negative sign. 84 | ## [0.26.0] - 2021-08-02 85 | ### Added 86 | - ```--args-only``` flag to ignore init files 87 | - ```precision``` property in the ```commodity``` directive 88 | ### Changed 89 | - Check whether dependencies are updated or not with deps.rs service 90 | ### Fixed 91 | - [```--strict``` and ```--pedantic``` working properly](https://github.com/frosklis/dinero-rs/issues/104) 92 | ## [0.25.0] - 2021-03-31 93 | ### Added 94 | - nicer error reporting 95 | - slightly better documentation 96 | - [```stats``` command](https://github.com/frosklis/dinero-rs/issues/96) that shows statistics about your ledger file 97 | ### Fixed 98 | - No need to [add a space before ```=``` in balance assertions](https://github.com/frosklis/dinero-rs/issues/40) 99 | - Correct parsing of transaction codes 100 | ## [0.24.0] - 2021-03-29 101 | ### Added 102 | - ```strict``` and ```pedantic``` options 103 | ### Changed 104 | - Collaborators will be able to use codecov as well 105 | ## [0.23.0] - 2021-03-24 106 | ### Added 107 | - Accounts now have a ```country``` property 108 | - Documentation is now available at github. 109 | ### Changed 110 | - Accounts no longer support ```isin``` property. They do support ```iban```, which is what should have always been. 111 | - Migrated the CI pipeline to Github Actions because I had trouble with Travis (build matrices) 112 | 113 | ## [0.22.0] - 2021-03-21 114 | ### Added 115 | - Slightly better handling of currency formats 116 | ### Changed 117 | - Better CI pipeline 118 | 119 | ## [0.21.0] - 2021-03-20 120 | ### Added 121 | - Infer currency format from the journal file 122 | - ```isin``` is a valid property for commodities 123 | ### Changed 124 | - Continuous integration pipeline is now better. No more problems like what happened between releases 0.18 and 0.20. 125 | ### Fixed 126 | - Commodities get parsed properly, always removing quotes 127 | ## [0.20.0] - 2021-03-15 128 | ### Fixed 129 | - Version numbers back on track 130 | ## [0.19.0] - 2021-03-15 131 | - Same as 0.18.1 due to a mistake 132 | ## [0.18.1] - 2021-03-15 133 | ### Fixed 134 | - Don't panic on end of input 135 | ## [0.18.0] - 2021-03-14 136 | ### Added 137 | - Support for specifying payees via posting comments. 138 | - Added support for dates in posting comments 139 | - Added support for specifying currency formats 140 | ### Changed 141 | - Date comparisons are done at the posting level rather than the transaction level 142 | ## [0.17.0] - 2021-03-12 143 | ### Changed 144 | - Now the whole file is processed using a formal grammar 145 | 146 | ### Fixed 147 | - Now this can be done ```any(abs(amount) == 2)```, which failed previously 148 | - Much faster CI builds 149 | - Proper caching of regexes, [about 25% speed improvement](https://github.com/frosklis/dinero-rs/issues/40) 150 | 151 | ## [0.16.0] - 2021-03-04 152 | ### Added 153 | - Virtual postings show correctly like this ```(account)``` 154 | ### Fixed 155 | - Now you can add tags [through automated transactions](https://github.com/frosklis/dinero-rs/issues/49) 156 | ## [0.15.0] - 2021-02-28 157 | ### Fixed 158 | - Correct conversion of currencies. There were [certain cases that did not work properly](https://github.com/frosklis/dinero-rs/issues/37) 159 | ### Added 160 | - complete transaction grammar 161 | ## [0.14.0] - 2021-02-27 162 | ### Fixed 163 | - speed bump, from 7 seconds to 4 seconds in my personal ledger (still room to improve) 164 | - ability to add tags from automated transactions 165 | ## [0.13.1] - 2021-02-27 166 | ### Fixed 167 | - Fixed issue when there is no specified payee 168 | ## [0.13.0] - 2021-02-27 169 | ### Added 170 | - Improved documentation 171 | - Support for [hledger syntax for payees](https://github.com/frosklis/dinero-rs/issues/37) 172 | ### Fixed 173 | - keep tags from transactions 174 | - match automated transactions only once per transaction, like ```ledger``` does 175 | - enable comments in price ```p``` directives 176 | ## [0.12.0] - 2021-02-24 177 | ### Added 178 | - support for (some of the) automated transaction syntax, what Claudio uses in his personal ledger 179 | ### Fixed 180 | - speed bump (from 44 seconds to 7 seconds) in a big personal ledger 181 | 182 | ## [0.11.1] - 2021-02-22 183 | ### Fixed 184 | - Fixed bug in balance report 185 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dinero-rs" 3 | version = "0.34.0-dev" 4 | authors = ["Claudio Noguera "] 5 | edition = "2018" 6 | readme = "README.md" 7 | description = "A command line ledger tool" 8 | keywords = ["ledger", "plaintext-accounting"] 9 | license = "MIT" 10 | homepage = "https://github.com/frosklis/dinero-rs" 11 | repository = "https://github.com/frosklis/dinero-rs" 12 | # rust-version = "1.38" 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | [lib] 15 | name = "dinero" 16 | path = "src/mod.rs" 17 | 18 | 19 | [[bin]] 20 | name = "dinero" 21 | test = false 22 | bench = false 23 | path = "src/dinero.rs" 24 | 25 | [dependencies] 26 | num = "0.4.0" 27 | glob = "0.3.0" 28 | colored = "2.0.0" 29 | chrono = "0.4.19" 30 | regex = "1" 31 | lazy_static = "1.4.0" 32 | structopt = "0.3.21" 33 | shellexpand = "2.1.0" 34 | # Two timer depends on pidgin, but with the upgrade to 0.4.1, it breaks 35 | # So I don't let it use pidgin 0.4.1, it has to be 0.4.0 36 | # Corrected in 0.4.2 37 | # pidgin = "=0.4.0" 38 | two_timer = "2.2.0" 39 | terminal_size = "0.1.16" 40 | pest = "2.0" 41 | pest_derive = "2.0" 42 | rustyline = "8.2.0" 43 | shlex = "1.0.0" 44 | prettytable-rs = "0.8.0" 45 | 46 | [dev-dependencies] 47 | assert_cmd = "2.0.0" 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status](https://github.com/frosklis/dinero-rs/actions/workflows/main.yml/badge.svg)](https://github.com/frosklis/dinero-rs/actions/workflows/main.yml) 2 | [![codecov](https://codecov.io/gh/frosklis/dinero-rs/branch/master/graph/badge.svg?token=QC4LG2ZMZJ)](https://codecov.io/gh/frosklis/dinero-rs) 3 | [![crates.io](https://img.shields.io/crates/v/dinero-rs)](https://crates.io/crates/dinero-rs) 4 | ![Crates.io](https://img.shields.io/crates/l/dinero-rs) 5 | [![dependency status](https://deps.rs/repo/github/frosklis/dinero-rs/status.svg)](https://deps.rs/repo/github/frosklis/dinero-rs) 6 | 7 | Dinero (spanish for money) is a command line tool that can deal with ledger files, as defined by John Wiegley's wonderful [ledger-cli](https://www.ledger-cli.org/). 8 | 9 | # Quickstart 10 | 11 | ## Install 12 | 13 | If Rust and cargo are available in your system, the easiest way to get dinero-rs is by installing the crate: 14 | ```sh 15 | cargo install dinero-rs 16 | ``` 17 | - [ ] Installation for Windows 18 | - [ ] Installation for Mac 19 | - [ ] Installation for Linux 20 | 21 | ## First steps 22 | 23 | Dinero uses double entry accounting. Store your journal files in ```ledger``` files. The main item is a transaction, which in its basic form looks something like this: 24 | 25 | ```ledger 26 | ; This is a comment 27 | ; A date followed by a description identifies the beginning of a transaction 28 | 2021-02-01 Buy fruit 29 | Expenses:Groceries 7.92 EUR 30 | Assets:Checking account ; you can leave this blank, dinero balances the transactions for you 31 | ``` 32 | 33 | After that, you can issue all the commands you want and combine them with options to have complete control over your finances! 34 | 35 | The most basic ones are: 36 | ```sh 37 | # Get a balance report: How much is there in every account 38 | dinero bal -f myledger.ledger 39 | 40 | # Get a list of transactions 41 | dinero reg -f myledger.ledger 42 | ``` 43 | 44 | # Features 45 | 46 | Currently supported are: 47 | - Balance reports 48 | - Register reports 49 | - Account and payees reports 50 | - Automated transactions 51 | - Multicurrency transactions 52 | - Currency conversion 53 | 54 | Report filtering by account name and by date. 55 | 56 | # Motivation 57 | I use ledger-cli extensively for my personal finances. My goal is to be able to run all the commands I use the most with my own tool while at the same time learning Rust. 58 | 59 | Run ```dinero --help``` for a list of available commands and options. 60 | 61 | If you use this software and want to say thanks, [feel free to buy me a coffee](https://www.buymeacoffee.com/7CLlJGE). 62 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Claudio Noguera"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "dinero documentation" 7 | description = "Documentation for the command line tool dinero" 8 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | - [The readme file](./readme.md) 3 | - [The journal file](./journal-file.md) 4 | - [Differences with ledger](./differences_with_ledger.md) 5 | - [The register report](./register-report.md) 6 | - [Interactive mode](./repl-mode.md) 7 | -------------------------------------------------------------------------------- /docs/src/differences_with_ledger.md: -------------------------------------------------------------------------------- 1 | # Differences with ledger-cli 2 | 3 | Although dinero is completely inspired by [```ledger-cli```](https://ledger-cli.org) and implements a subset of its features, it has been written form scratch, it is not a port. 4 | 5 | Some behaviors are intentionally different. Other things are just bugs: if you find one, feel free to add an issue in the [development repository](https://github.com/frosklis/dinero-rs). 6 | 7 | 8 | ```dinero``` is developed in Rust, while ```ledger``` is developed in C. This is completely transparent to the end user, it does at least theoretically provide some advantages for developers, with Rust being a newer language with the same speed as C but more memory safety. Again, this at least theory 9 | 10 | The next table presents a summary of differences, with the most important ones being commented later. 11 | 12 | What | ledger | dinero 13 | -----|--------|------- 14 | Programming language | C | Rust 15 | Feature set | a lot of options for each command | just some options for each command 16 | Transaction sorting for balance assertion | within file? | global 17 | Speed | extremely fast | not quite as fast (yet) 18 | End with newline | files must end with a blank line | no need to do that 19 | Regular expressions | assume ignore case | not always (it is well known when) 20 | Unicode | not everywhere | € is a valid currency 21 | 22 | 23 | 24 | ## Balance assertions 25 | 26 | Balance assertions have the same syntax in both languages, but the way they are handled is different. 27 | 28 | In ```ledger``` it is very difficult (for me) to add balance assertions to all the transactions, in particular when you have several ledger files linked together. The balance assertions are processed more or less as they appear in the files, which depends in the order you read the files with the ```include``` directive. 29 | 30 | In ```dinero``` every transaction is read, then they are sorted by date (without altering the original order in ties) and finally tha balance is checked. 31 | 32 | The practical consequence for me (Claudio) in particular is that rather than doing this: 33 | ```ledger 34 | include past/201701.ledger 35 | include past/201702.ledger 36 | ; ... 37 | include past/202103.ledger 38 | ``` 39 | 40 | I can do this instead: 41 | ```ledger 42 | include past/*.ledger 43 | ``` 44 | 45 | This results in a much shorter file, easier on the eyes (I like my master.ledger file to be complete yet simple). ```ledger``` does not guarantee the order in which the files are read and that affects balance assertions. ```dinero``` doesn't guarantee it either, but the extra ordering step means it doesn't matter (though arguably it makes it somewhat slower) 46 | 47 | -------------------------------------------------------------------------------- /docs/src/journal-file.md: -------------------------------------------------------------------------------- 1 | # The journal file(s) 2 | 3 | *Work in progress* 4 | 5 | Dinero derives its reports from a *journal* file ([or files](#include_directive)). The most important feature of this file format is its readibility. Unlike other computer-friendly formats such as comma separated values or a binary database, journal files actually make sense to a human. 6 | 7 | Dinero follows the principles of [double entry accounting](https://en.wikipedia.org/wiki/Double-entry_bookkeeping), where the main information is the *transaction*. 8 | 9 | A transaction contains two or more *postings*, which are actual movements in an *account*, which is another important concept. In bookkeeping, money always comes from and goes to an account. 10 | 11 | ## Developers 12 | 13 | The full syntax accepted by ```dinero```can be found in the [grammar specification](https://github.com/frosklis/dinero-rs/blob/master/src/grammar/grammar.pest). It is a formal grammar. -------------------------------------------------------------------------------- /docs/src/readme.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/src/register-report.md: -------------------------------------------------------------------------------- 1 | # The register report 2 | 3 | *Work in progress* 4 | 5 | The register report shows a list of postings. It can be invoked with ```dinero register``` or the shorter ```dinero reg``` 6 | 7 | # Options 8 | ## --collapse 9 | 10 | The collapse only shows one posting per account and transaction. For example: 11 | 12 | ``` 13 | 2021-09-01 * A lot of fees 14 | Expenses:Travel 200 EUR 15 | Expenses:Fees 1 EUR 16 | Expenses:Fees 3 EUR 17 | Assets:Checking Account 18 | ``` 19 | 20 | ```dinero reg --collapse``` will print out: 21 | 22 | ``` 23 | 2021-09-01 A lot of fees Expenses:Travel 200 EUR 200 EUR 24 | Expenses:Fees 4 EUR 204 EUR 25 | Assets:Checking Account -204 EUR 0 EUR 26 | ``` 27 | 28 | -------------------------------------------------------------------------------- /docs/src/repl-mode.md: -------------------------------------------------------------------------------- 1 | # REPL mode 2 | 3 | Since ```0.29.0``` ```dinero-rs``` comes with a REPL mode (read-eval-print-loop) or interactive mode: 4 | 5 | ```dinero -f myjournal.ledger```` 6 | 7 | Once inside the REPL mode, the ledger is parsed and cached so that any subsequent operations are faster than their regular CLI counterparts. 8 | 9 | ## Working inside the interactive mode 10 | 11 | The commands behave just like in the normal mode but: 12 | - they are faster 13 | - the ```dinero``` executable is elided, you can write either ```dinero reg``` or ```reg``` 14 | 15 | ## Special commands 16 | 17 | To exit the REPL type ```exit``` or ```quit```. 18 | 19 | ```reload``` loads the journal again, which is useful if it has been changed externally. 20 | -------------------------------------------------------------------------------- /examples/grammar.rs: -------------------------------------------------------------------------------- 1 | extern crate pest; 2 | #[macro_use] 3 | extern crate pest_derive; 4 | use pest::Parser; 5 | 6 | use std::env; 7 | use std::fs::read_to_string; 8 | use std::path::PathBuf; 9 | 10 | #[derive(Parser)] 11 | #[grammar = "grammar/grammar.pest"] 12 | pub struct GrammarParser; 13 | 14 | fn main() { 15 | let file = env::args().nth(1); 16 | let path = PathBuf::from(file.unwrap()); 17 | 18 | let content = read_to_string(path).unwrap(); 19 | 20 | match GrammarParser::parse(Rule::journal, content.as_str()) { 21 | Ok(mut parsed) => { 22 | let elements = parsed.next().unwrap().into_inner(); 23 | for element in elements { 24 | println!("{:?}: {}", element.as_rule(), element.as_str()); 25 | } 26 | } 27 | Err(e) => eprintln!("{:?}", e), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /scripts/bench.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "This benchmark needs ledgerrc to be properly set up for it to be meaningful" 3 | 4 | hyperfine -L command dinero,ledger '{command} bal' 5 | hyperfine -L command dinero,ledger '{command} bal stockplan -X eur' 6 | hyperfine -L command dinero,ledger '{command} bal degiro -X eur' 7 | hyperfine -L command dinero,ledger '{command} bal vactivo -X eur' 8 | -------------------------------------------------------------------------------- /scripts/brew.sh: -------------------------------------------------------------------------------- 1 | # This script outputs th brew formula necessary to install dinero 2 | sha_mac_x86_64=$(shasum -a 256 dinero-mac-x86_64.tar.gz | cut -f 1 -d " ") 3 | sha_mac_aarch64=$(shasum -a 256 dinero-mac-aarch64.tar.gz | cut -f 1 -d " ") 4 | 5 | cat << EOF 6 | class Dinero < Formula 7 | version "0.20.0" 8 | desc "Command line tool for managing ledger files written in Rust" 9 | homepage "https://github.com/frosklis/dinero-rs" 10 | 11 | if OS.mac? 12 | if RUBY_PLATFORM.match(/x86_64/) 13 | puts "Detected x86_64" 14 | url "https://github.com/frosklis/dinero-rs/releases/latest/download/dinero-mac-x86_64.tar.gz" 15 | sha256 "${sha_mac_x86_64}" 16 | elsif RUBY_PLATFORM.match(/aarch64/) 17 | puts "Detected aarch64" 18 | url "https://github.com/frosklis/dinero-rs/releases/latest/download/dinero-mac-aarch64.tar.gz" 19 | sha256 "${sha_mac_aarch64}" 20 | end 21 | end 22 | 23 | def install 24 | if OS.mac? 25 | bin.install "dinero" 26 | else 27 | puts "Sorry. Only know how to install on a Mac" 28 | end 29 | end 30 | end 31 | EOF 32 | 33 | -------------------------------------------------------------------------------- /scripts/compare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | 5 | cd $DIR 6 | 7 | ledger commodities | sort > commodities_ledger.txt & 8 | dinero commodities | sort > commodities_dinero.txt & 9 | 10 | ledger payees | sort > payees_ledger.txt & 11 | dinero payees | sort > payees_dinero.txt & 12 | 13 | ledger bal stockplan -X eur > bal_stockplan_ledger.txt & 14 | dinero bal stockplan -X eur > bal_stockplan_dinero.txt & 15 | 16 | ledger bal ^activo -X eur > bal_activo_ledger.txt & 17 | dinero bal ^activo -X eur > bal_activo_dinero.txt & 18 | 19 | ledger bal ^vactivo -X eur > bal_vactivo_ledger.txt & 20 | dinero bal ^vactivo -X eur > bal_vactivo_dinero.txt & 21 | 22 | -------------------------------------------------------------------------------- /scripts/publish_tag.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | function bump_version() { 3 | local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)' 4 | major=`echo $1 | sed -e "s#$RE#\1#"` 5 | minor=`echo $1 | sed -e "s#$RE#\2#"` 6 | release=`echo $1 | sed -e "s#$RE#\3#"` 7 | # patch=`echo $1 | sed -e "s#$RE#\4#"` 8 | 9 | release=0 10 | minor=$((minor+1)) 11 | 12 | echo "$major.$minor.$release" 13 | } 14 | 15 | previous=$(git tag | sort -t "." -k1,1n -k2,2n -k3,3n | tail -n 1) 16 | version=$(grep -E "version = \"([0-9]+\.[0-9]+.[0-9]+(-.*)?)\"" Cargo.toml | grep -Eo -m 1 "[0-9]+\.[0-9]+.[0-9]+") 17 | bumped=$(bump_version ${version}) 18 | 19 | echo Tagging version $version. Previous version was $previous. 20 | 21 | # Publish create the tag 22 | message=$({ echo "${version}\n" & git --no-pager log ${previous}..HEAD --oneline ; } | cat ) 23 | 24 | git tag -a $version -m "$message" 25 | git push origin $version 26 | 27 | # 28 | # Update tag for development 29 | # 30 | message="Bump from ${version} to ${bumped}-dev" 31 | commit_message="[ci skip] ${message}" 32 | 33 | # Update Cargo.toml 34 | line_number=$(grep -En "version = \"([0-9]+\.[0-9]+.[0-9]+)\"" Cargo.toml | grep -Eo -m 1 "[0-9]+" | head -n 1) 35 | 36 | sed -i "${line_number}s/.*/version = \"${bumped}-dev\"/" Cargo.toml 37 | 38 | # Update Changelog 39 | sed -i "3i## [${bumped}] - xxx" CHANGELOG.md 40 | 41 | echo ${commit_message} 42 | 43 | # Publishing tag 44 | git commit -a -m "${commit_message}" 45 | git push 46 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | pub mod accounts; 2 | pub mod balance; 3 | pub mod commodities; 4 | pub mod payees; 5 | pub mod prices; 6 | pub mod register; 7 | pub mod roi; 8 | pub mod statistics; 9 | -------------------------------------------------------------------------------- /src/commands/accounts.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::error::Error; 3 | 4 | use crate::models::{Account, HasName, Ledger}; 5 | use crate::CommonOpts; 6 | use std::ops::Deref; 7 | 8 | pub fn execute(options: &CommonOpts, maybe_ledger: Option) -> Result<(), Box> { 9 | let ledger = match maybe_ledger { 10 | Some(ledger) => ledger, 11 | None => Ledger::try_from(options)?, 12 | }; 13 | let mut accounts = ledger 14 | .accounts 15 | .iter() 16 | .map(|x| x.1.deref().to_owned()) 17 | .collect::>(); 18 | accounts.sort_by(|a, b| a.get_name().cmp(b.get_name())); 19 | for acc in accounts { 20 | println!("{}", acc); 21 | } 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/balance.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::convert::TryFrom; 3 | 4 | use colored::Colorize; 5 | use num::ToPrimitive; 6 | 7 | use crate::error::ReportError::CurrencyConversionError; 8 | use crate::models::{conversion, Account, Balance, Currency, HasName, Ledger, Money}; 9 | use crate::parser::value_expr::build_root_node_from_expression; 10 | use crate::{filter, CommonOpts}; 11 | use chrono::Utc; 12 | use num::rational::BigRational; 13 | use std::ops::Deref; 14 | use std::rc::Rc; 15 | 16 | /// Balance report 17 | pub fn execute( 18 | options: &CommonOpts, 19 | maybe_ledger: Option, 20 | flat: bool, 21 | show_total: bool, 22 | ) -> Result<(), Box> { 23 | assert!( 24 | !(options.convert.is_some() && options.exchange.is_some()), 25 | "Incompatible arguments --convert and --exchange" 26 | ); 27 | let ledger = match maybe_ledger { 28 | Some(ledger) => ledger, 29 | None => Ledger::try_from(options)?, 30 | }; 31 | 32 | let depth = options.depth; 33 | let mut balances: HashMap, Balance> = HashMap::new(); 34 | 35 | // Build a cache of abstract value trees, it takes time to parse expressions, so better do it only once 36 | let mut regexes = HashMap::new(); 37 | let query = filter::preprocess_query(&options.query, &options.related); 38 | let node = if query.len() > 2 { 39 | Some(build_root_node_from_expression( 40 | query.as_str(), 41 | &mut regexes, 42 | )) 43 | } else { 44 | None 45 | }; 46 | 47 | for t in ledger.transactions.iter() { 48 | for p in t.postings.borrow().iter() { 49 | if !filter::filter(options, &node, t, p, &ledger.commodities)? { 50 | continue; 51 | } 52 | let mut cur_bal = balances 53 | .get(p.account.deref()) 54 | .unwrap_or(&Balance::new()) 55 | .to_owned(); 56 | cur_bal = cur_bal + Balance::from(p.amount.as_ref().unwrap().clone()); 57 | balances.insert(p.account.clone(), cur_bal.to_owned()); 58 | } 59 | } 60 | 61 | // For printing this out, take into account whether it is a flat report or not 62 | // if it is not, the parent balances have to be updated 63 | let mut vec_balances: Vec<(&str, Balance)> = vec![]; 64 | let mut temp: Vec<(String, Balance)>; 65 | let mut accounts = HashSet::new(); 66 | let mut new_balances = HashMap::new(); 67 | let mut vec: Vec; 68 | if !flat { 69 | for (acc, bal) in balances.iter() { 70 | let mut pattern = "".to_string(); 71 | for part in acc.get_name().split(':') { 72 | if !pattern.is_empty() { 73 | pattern.push(':'); 74 | } 75 | pattern.push_str(part); 76 | accounts.insert(pattern.clone()); 77 | } 78 | new_balances.insert(acc.get_name(), bal.clone()); 79 | } 80 | 81 | // Sort by depth 82 | vec = accounts.iter().cloned().collect(); 83 | vec.sort_by_key(|a| a.matches(':').count()); 84 | 85 | for account in vec.iter() { 86 | let mut prefix = account.clone(); 87 | prefix.push(':'); // It is important to add this see [issue #8](https://github.com/frosklis/dinero-rs/issues/8) 88 | let balance = new_balances 89 | .iter() 90 | .filter(|x| (x.0 == account) | x.0.starts_with(&prefix)) 91 | .fold(Balance::new(), |acc, new| acc + new.1.clone()); 92 | new_balances.insert(account.as_str(), balance); 93 | } 94 | vec_balances = new_balances 95 | .iter() 96 | .filter(|x| !x.1.is_zero()) 97 | .map(|x| (*x.0, x.1.clone())) 98 | .collect() 99 | } else { 100 | match depth { 101 | Some(depth) => { 102 | temp = balances 103 | .iter() 104 | .filter(|x| !x.1.is_zero()) 105 | .map(|x| { 106 | ( 107 | x.0.get_name() 108 | .split(':') 109 | .collect::>() 110 | .iter() 111 | .map(|x| x.to_string()) 112 | .take(depth) 113 | .collect::>() 114 | .join(":"), 115 | x.1.clone(), 116 | ) 117 | }) 118 | .collect::>(); 119 | temp.sort_by(|a, b| a.0.cmp(&b.0)); 120 | let mut account = String::new(); 121 | for (acc, value) in temp.iter() { 122 | if *acc != account { 123 | vec_balances.push((acc.as_str(), value.clone())); 124 | } else { 125 | let n = vec_balances.len(); 126 | vec_balances[n - 1] = 127 | (acc.as_str(), vec_balances[n - 1].clone().1 + value.clone()); 128 | } 129 | 130 | account = acc.to_string(); 131 | } 132 | } 133 | None => { 134 | vec_balances = balances 135 | .iter() 136 | .filter(|x| !x.1.is_zero()) 137 | .map(|x| (x.0.get_name(), x.1.clone())) 138 | .collect() 139 | } 140 | } 141 | } 142 | 143 | // Print the balances by account 144 | let mut multipliers = HashMap::new(); 145 | if let Some(currency_string) = &options.convert { 146 | let date = if let Some(date) = &options.end { 147 | *date 148 | } else { 149 | Utc::now().naive_local().date() 150 | }; 151 | if let Ok(currency) = ledger.commodities.get(currency_string) { 152 | multipliers = conversion(currency.clone(), date, &ledger.prices); 153 | } 154 | } 155 | if let Some(currency_string) = &options.exchange { 156 | let date = if let Some(date) = &options.end { 157 | *date 158 | } else { 159 | Utc::now().naive_local().date() 160 | }; 161 | if let Ok(currency) = ledger.commodities.get(currency_string) { 162 | multipliers = conversion(currency.clone(), date, &ledger.prices); 163 | let mut updated_balances = Vec::new(); 164 | for (acc, balance) in vec_balances.iter() { 165 | updated_balances.push((*acc, convert_balance(balance, &multipliers, currency)?)); 166 | } 167 | vec_balances = updated_balances; 168 | } 169 | } 170 | 171 | vec_balances.sort_by(|a, b| a.0.cmp(b.0)); 172 | let num_bal = vec_balances.len(); 173 | let mut index = 0; 174 | let mut showed_balances = 0; 175 | while index < num_bal { 176 | let (account, bal) = &vec_balances[index]; 177 | if let Some(depth) = depth { 178 | if account.split(':').count() > depth { 179 | index += 1; 180 | continue; 181 | } 182 | } 183 | if bal.is_zero() { 184 | index += 1; 185 | continue; 186 | } 187 | showed_balances += 1; 188 | 189 | let mut first = true; 190 | for (_, money) in bal.balance.iter() { 191 | if !first { 192 | println!(); 193 | } 194 | first = false; 195 | match money.is_negative() { 196 | true => print!("{:>20}", format!("{}", money).red()), 197 | false => print!("{:>20}", format!("{}", money)), 198 | } 199 | 200 | if let Some(currency_string) = &options.convert { 201 | let date = if let Some(date) = &options.end { 202 | *date 203 | } else { 204 | Utc::now().naive_local().date() 205 | }; 206 | if let Ok(currency) = ledger.commodities.get(currency_string) { 207 | multipliers = conversion(currency.clone(), date, &ledger.prices); 208 | 209 | let other_money = 210 | convert_balance(&(money.clone() + Money::Zero), &multipliers, currency)? 211 | .to_money()?; 212 | 213 | match money.is_negative() { 214 | true => print!("{:>20}", format!("{}", other_money).red()), 215 | false => print!("{:>20}", format!("{}", other_money)), 216 | } 217 | } 218 | } 219 | } 220 | if first { 221 | // This means the balance was empty 222 | print!("{:>20}", "0"); 223 | } 224 | if flat { 225 | println!(" {}", account.blue()); 226 | } else { 227 | let mut n = account.split(':').count(); 228 | for _ in 0..n { 229 | print!(" "); 230 | } 231 | // start by getting the account name 232 | let mut text = account.split(':').last().unwrap().to_string(); 233 | // This is where it gets tricky, we need to collapse while we can 234 | let mut collapse = true; 235 | loop { 236 | if (index + 1) >= num_bal { 237 | break; 238 | } 239 | if vec_balances[index + 1].0.split(':').count() != (n + 1) { 240 | break; 241 | } 242 | //for j in (index + 2)..num_bal { 243 | for (name, _) in vec_balances.iter().take(num_bal).skip(index + 2) { 244 | // let name = vec_balances[j].0; 245 | if !name.starts_with(account) { 246 | break; 247 | } 248 | let this_depth = name.split(':').count(); 249 | if this_depth == n + 1 { 250 | collapse = false; 251 | break; 252 | } 253 | } 254 | if collapse { 255 | text.push(':'); 256 | text.push_str(vec_balances[index + 1].0.split(':').last().unwrap()); 257 | n += 1; 258 | index += 1; 259 | } else { 260 | break; 261 | } 262 | } 263 | println!("{}", text.blue()); 264 | } 265 | index += 1; 266 | } 267 | 268 | // Print the total 269 | if show_total & (showed_balances > 1) { 270 | // Calculate it 271 | let mut total_balance = balances 272 | .iter() 273 | .fold(Balance::new(), |acc, x| acc + x.1.to_owned()); 274 | print!("--------------------"); 275 | if !multipliers.is_empty() & options.exchange.is_some() { 276 | total_balance = convert_balance( 277 | &total_balance, 278 | &multipliers, 279 | ledger 280 | .commodities 281 | .get(options.exchange.as_ref().unwrap().as_str()) 282 | .unwrap(), 283 | )?; 284 | } 285 | if total_balance.is_zero() { 286 | print!("\n{:>20}", "0"); 287 | } else { 288 | for (currency, money) in total_balance.balance.iter() { 289 | match &options.convert { 290 | Some(_) => match multipliers.get(currency.as_ref().unwrap()) { 291 | Some(mult) => { 292 | let amount = money.get_amount() * mult; 293 | 294 | match money.is_negative() { 295 | true => print!( 296 | "\n{:>20}{:>20}{:>20}", 297 | format!("{}", money).red(), 298 | mult.to_f64().unwrap(), 299 | amount.to_f64().unwrap() 300 | ), 301 | false => print!( 302 | "\n{:>20}{:>20}{:>20}", 303 | format!("{}", money), 304 | mult.to_f64().unwrap(), 305 | amount.to_f64().unwrap() 306 | ), 307 | } 308 | } 309 | None => { 310 | return Err(Box::new(CurrencyConversionError( 311 | money.get_commodity().unwrap().as_ref().clone(), 312 | currency.as_ref().unwrap().as_ref().clone(), 313 | ))) 314 | } 315 | }, 316 | None => match money.is_negative() { 317 | true => print!("\n{:>20}", format!("{}", money).red()), 318 | false => print!("\n{:>20}", format!("{}", money)), 319 | }, 320 | } 321 | } 322 | } 323 | println!(); 324 | } 325 | 326 | // We're done :) 327 | Ok(()) 328 | } 329 | 330 | pub(crate) fn convert_balance( 331 | balance: &Balance, 332 | multipliers: &HashMap, BigRational>, 333 | currency: &Currency, 334 | ) -> Result> { 335 | let mut new_balance = Balance::new(); 336 | for (curr, money) in balance.iter() { 337 | if let Some(mult) = multipliers.get(curr.clone().unwrap().as_ref()) { 338 | new_balance = new_balance 339 | + Money::Money { 340 | amount: money.get_amount() * mult.clone(), 341 | currency: Rc::new(currency.clone()), 342 | } 343 | .into() 344 | } else { 345 | // new_balance = new_balance + money.clone().into(); 346 | return Err(Box::new(CurrencyConversionError( 347 | money.get_commodity().unwrap().as_ref().clone(), 348 | currency.clone(), 349 | ))); 350 | } 351 | } 352 | Ok(new_balance) 353 | } 354 | -------------------------------------------------------------------------------- /src/commands/commodities.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::error::Error; 3 | 4 | use crate::models::Ledger; 5 | use crate::{ 6 | models::{Currency, HasName}, 7 | CommonOpts, 8 | }; 9 | use std::ops::Deref; 10 | 11 | pub fn execute(options: &CommonOpts, maybe_ledger: Option) -> Result<(), Box> { 12 | let ledger = match maybe_ledger { 13 | Some(ledger) => ledger, 14 | None => Ledger::try_from(options)?, 15 | }; 16 | let mut commodities = ledger 17 | .commodities 18 | .iter() 19 | .map(|x| x.1.deref().to_owned()) 20 | .collect::>(); 21 | commodities.sort_by(|a, b| a.get_name().cmp(b.get_name())); 22 | for cur in commodities { 23 | println!("{}", cur); 24 | } 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/payees.rs: -------------------------------------------------------------------------------- 1 | use crate::models::Ledger; 2 | use crate::{ 3 | models::{HasName, Payee}, 4 | CommonOpts, 5 | }; 6 | use std::convert::TryFrom; 7 | use std::error::Error; 8 | use std::ops::Deref; 9 | 10 | pub fn execute(options: &CommonOpts, maybe_ledger: Option) -> Result<(), Box> { 11 | let ledger = match maybe_ledger { 12 | Some(ledger) => ledger, 13 | None => Ledger::try_from(options)?, 14 | }; 15 | let mut payees = ledger 16 | .payees 17 | .iter() 18 | .map(|x| x.1.deref().to_owned()) 19 | .collect::>(); 20 | payees.sort_by(|a, b| a.get_name().cmp(b.get_name())); 21 | for payee in payees.iter() { 22 | println!("{}", payee); 23 | } 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/prices.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::error::Error; 3 | use std::ops::Deref; 4 | 5 | use crate::models::Ledger; 6 | use crate::CommonOpts; 7 | 8 | pub fn execute(options: &CommonOpts, maybe_ledger: Option) -> Result<(), Box> { 9 | let ledger = match maybe_ledger { 10 | Some(ledger) => ledger, 11 | None => Ledger::try_from(options)?, 12 | }; 13 | for price in ledger.prices.deref() { 14 | println!("{}", price); 15 | } 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/register.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{conversion, Cleared, HasName, Ledger, Posting, PostingType}; 2 | use crate::models::{Balance, Money}; 3 | use crate::parser::value_expr::build_root_node_from_expression; 4 | use crate::{filter, CommonOpts}; 5 | use chrono::Utc; 6 | use colored::Colorize; 7 | use std::collections::HashMap; 8 | use std::convert::TryFrom; 9 | use std::rc::Rc; 10 | use terminal_size::{terminal_size, Width}; 11 | 12 | /// Register report 13 | pub fn execute( 14 | options: &CommonOpts, 15 | maybe_ledger: Option, 16 | ) -> Result<(), Box> { 17 | // Get options from options 18 | let _no_balance_check: bool = options.no_balance_check; 19 | // Now work 20 | let ledger = match maybe_ledger { 21 | Some(ledger) => ledger, 22 | None => Ledger::try_from(options)?, 23 | }; 24 | 25 | let mut balance = Balance::new(); 26 | let today = Utc::now().naive_local().date(); 27 | let size = terminal_size(); 28 | let mut width: usize = 80; 29 | if let Some((Width(w), _)) = size { 30 | width = w as usize; 31 | } 32 | let w_date: usize = 11; 33 | let mut w_amount: usize = 21; 34 | let mut w_balance: usize = 21; 35 | let w_description: usize = 42; 36 | let w_account: usize = if w_date + w_description + w_amount + w_balance >= width { 37 | w_amount = 17; 38 | w_balance = 17; 39 | 34 40 | } else { 41 | width - w_date - w_description - w_amount - w_balance 42 | }; 43 | 44 | // Build a cache of abstract value trees, it takes time to parse expressions, so better do it only once 45 | let mut regexes = HashMap::new(); 46 | let query = filter::preprocess_query(&options.query, &options.related); 47 | let node = if query.len() > 2 { 48 | Some(build_root_node_from_expression( 49 | query.as_str(), 50 | &mut regexes, 51 | )) 52 | } else { 53 | None 54 | }; 55 | 56 | for t in ledger.transactions.iter() { 57 | let mut counter = 0; 58 | let mut postings_vec = t 59 | .postings 60 | .borrow() 61 | .iter() 62 | .filter(|p| { 63 | filter::filter(options, &node, t, p.to_owned(), &ledger.commodities).unwrap() 64 | }) 65 | .cloned() 66 | .collect::>(); 67 | 68 | // If the exchange option is active, change the amount of every posting to the desired currency. The balance will follow. 69 | if let Some(currency_string) = &options.exchange { 70 | if let Ok(currency) = ledger.commodities.get(currency_string) { 71 | // for index in 0..postings_vec.len() { 72 | for p in postings_vec.iter_mut() { 73 | // let p = &postings_vec[index]; 74 | let multipliers = conversion(currency.clone(), p.date, &ledger.prices); 75 | if let Some(mult) = multipliers 76 | .get(p.amount.as_ref().unwrap().get_commodity().unwrap().as_ref()) 77 | { 78 | let new_amount = Money::Money { 79 | amount: p.amount.as_ref().unwrap().get_amount() * mult.clone(), 80 | currency: Rc::new(currency.as_ref().clone()), 81 | }; 82 | let mut new_posting = p.clone(); 83 | new_posting.set_amount(new_amount); 84 | // postings_vec[index] = new_posting; 85 | *p = new_posting; 86 | } 87 | } 88 | } 89 | } 90 | 91 | if options.collapse && (!postings_vec.is_empty()) { 92 | // Sort ... 93 | postings_vec.sort_by(|a, b| { 94 | (&format!( 95 | "{}{}", 96 | a.account.get_name(), 97 | a.amount 98 | .as_ref() 99 | .unwrap() 100 | .get_commodity() 101 | .unwrap() 102 | .get_name() 103 | )) 104 | .partial_cmp(&format!( 105 | "{}{}", 106 | b.account.get_name(), 107 | b.amount 108 | .as_ref() 109 | .unwrap() 110 | .get_commodity() 111 | .unwrap() 112 | .get_name() 113 | )) 114 | .unwrap() 115 | }); 116 | 117 | // ... and collapse 118 | let mut collapsed = vec![postings_vec[0].clone()]; 119 | let mut ind = 0; 120 | for p in postings_vec.iter().skip(1) { 121 | if (p.account == collapsed[ind].account) 122 | & (p.amount.as_ref().unwrap().get_commodity() 123 | == collapsed[ind].amount.as_ref().unwrap().get_commodity()) 124 | { 125 | let mut new_posting = p.clone(); 126 | new_posting.set_amount( 127 | (new_posting.amount.clone().unwrap() 128 | + collapsed[ind].amount.clone().unwrap()) 129 | .to_money() 130 | .unwrap(), 131 | ); 132 | collapsed[ind] = new_posting; 133 | } else { 134 | ind += 1; 135 | collapsed.push(p.clone()) 136 | } 137 | } 138 | postings_vec = collapsed; 139 | } 140 | for p in postings_vec.iter() { 141 | counter += 1; 142 | if counter == 1 { 143 | let mut date_str = 144 | format!("{}", t.date.unwrap().format(&options.date_format)).normal(); 145 | if t.date.unwrap() > today { 146 | date_str = date_str.green(); 147 | } 148 | 149 | let mut payee_str = match t.get_payee(&ledger.payees) { 150 | Some(payee) => clip(&format!("{} ", payee), w_description), 151 | None => clip(&format!("{} ", ""), w_description), 152 | } 153 | .normal(); 154 | 155 | if t.cleared == Cleared::NotCleared { 156 | payee_str = payee_str.bold(); 157 | } 158 | 159 | print!( 160 | "{:w1$}{:width$}", 161 | date_str, 162 | payee_str, 163 | w1 = w_date, 164 | width = w_description 165 | ); 166 | } 167 | if counter > 1 { 168 | print!("{:width$}", "", width = w_description + 11); 169 | } 170 | balance = balance + Balance::from(p.amount.as_ref().unwrap().clone()); 171 | if balance.is_zero() { 172 | balance = Balance::from(Money::Zero); 173 | } 174 | match p.kind { 175 | PostingType::Real => print!( 176 | "{:width$}", 177 | format!("{}", p.account).blue(), 178 | width = w_account 179 | ), 180 | PostingType::Virtual => print!( 181 | "{:width$}", 182 | format!("({})", p.account).blue(), 183 | width = w_account 184 | ), 185 | PostingType::VirtualMustBalance => print!( 186 | "{:width$}", 187 | format!("[{}]", p.account).blue(), 188 | width = w_account 189 | ), 190 | } 191 | 192 | match p.amount.as_ref().unwrap().is_negative() { 193 | false => print!( 194 | "{:>width$}", 195 | format!("{}", p.amount.as_ref().unwrap()), 196 | width = w_amount 197 | ), 198 | true => print!( 199 | "{:>width$}", 200 | format!("{}", p.amount.as_ref().unwrap()).red(), 201 | width = w_amount 202 | ), 203 | } 204 | let mut more_than_one_line: bool = false; 205 | for (_, money) in balance.iter() { 206 | if more_than_one_line { 207 | print!( 208 | "{:width$}", 209 | "", 210 | width = w_date + w_description + w_account + w_amount 211 | ); 212 | } 213 | more_than_one_line = true; 214 | match money.is_positive() { 215 | true => println!("{:>width$}", format!("{}", money), width = w_balance), 216 | false => println!("{:>width$}", format!("{}", money).red(), width = w_balance), 217 | } 218 | } 219 | } 220 | } 221 | 222 | // We're done :) 223 | Ok(()) 224 | } 225 | 226 | fn clip(string: &str, width: usize) -> String { 227 | if string.len() < width - 3 { 228 | string.to_string() 229 | } else { 230 | let mut ret = String::new(); 231 | for (i, c) in string.chars().enumerate() { 232 | if i >= width - 3 { 233 | break; 234 | } 235 | ret.push(c); 236 | } 237 | 238 | format!("{}..", ret) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/commands/statistics.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use crate::models::Ledger; 4 | use crate::CommonOpts; 5 | 6 | /// Statistics command 7 | /// 8 | /// Prints summary statistics from the ledger 9 | pub fn execute( 10 | options: &CommonOpts, 11 | maybe_ledger: Option, 12 | ) -> Result<(), Box> { 13 | let ledger = match maybe_ledger { 14 | Some(ledger) => ledger, 15 | None => Ledger::try_from(options)?, 16 | }; 17 | 18 | // Number of transactions 19 | let mut num_postings = 0; 20 | for t in ledger.transactions.iter() { 21 | num_postings += t.postings.borrow().iter().count(); 22 | } 23 | 24 | let num_files = ledger.files.len(); 25 | if num_files > 0 { 26 | println!("Number of files processed: {}", num_files); 27 | for file in ledger.files.iter() { 28 | let path_str = file.clone().into_os_string().into_string().unwrap(); 29 | 30 | println!("\t{}", &path_str); 31 | } 32 | } 33 | 34 | let first_transaction_date = &ledger.transactions.get(0).unwrap().date.unwrap(); 35 | let last_transaction_date = &ledger 36 | .transactions 37 | .iter() 38 | .rev() 39 | .next() 40 | .unwrap() 41 | .date 42 | .unwrap(); 43 | let num_days = 1 + last_transaction_date 44 | .signed_duration_since(*first_transaction_date) 45 | .num_days(); 46 | // Print the stats 47 | println!("{} postings", num_postings); 48 | println!("{} transactions", &ledger.transactions.len()); 49 | 50 | println!( 51 | "First transaction: {}", 52 | first_transaction_date.format(&options.date_format) 53 | ); 54 | println!( 55 | "Last transaction: {}", 56 | last_transaction_date.format(&options.date_format) 57 | ); 58 | println!("{} days between first and last transaction", num_days); 59 | println!( 60 | "{:.2} transactions per day (average)", 61 | (ledger.transactions.len() as f64) / (num_days as f64) 62 | ); 63 | println!( 64 | "{:.2} postings per day (average)", 65 | (num_postings as f64) / (num_days as f64) 66 | ); 67 | 68 | println!("{} price entries", &ledger.prices.len()); 69 | println!("{} different accounts", &ledger.accounts.len()); 70 | println!("{} different payees", &ledger.payees.len()); 71 | println!("{} different commodities", &ledger.commodities.len()); 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/dinero.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | fn main() { 4 | match dinero::run_app(env::args().collect()) { 5 | Ok(_) => std::process::exit(0), 6 | Err(_) => std::process::exit(1), 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use colored::{ColoredString, Colorize}; 2 | use std::error::Error; 3 | use std::fmt; 4 | use std::fmt::{Display, Formatter}; 5 | use std::path::PathBuf; 6 | 7 | use crate::models::{Balance, Currency}; 8 | 9 | #[derive(Debug)] 10 | pub struct EmptyLedgerFileError; 11 | impl Error for EmptyLedgerFileError {} 12 | impl Display for EmptyLedgerFileError { 13 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 14 | write!(f, "The journal file does not have any information") 15 | } 16 | } 17 | 18 | #[derive(Debug)] 19 | pub enum MissingFileError { 20 | ConfigFileDoesNotExistError(PathBuf), 21 | JournalFileDoesNotExistError(PathBuf), 22 | } 23 | impl Error for MissingFileError {} 24 | impl Display for MissingFileError { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 26 | let (title, file) = match self { 27 | MissingFileError::ConfigFileDoesNotExistError(x) => { 28 | ("Configuration", x.to_str().unwrap()) 29 | } 30 | MissingFileError::JournalFileDoesNotExistError(x) => ("Journal", x.to_str().unwrap()), 31 | }; 32 | write!(f, "{} file does not exist: {}", title, file.red().bold()) 33 | } 34 | } 35 | 36 | #[derive(Debug)] 37 | pub struct TimeParseError; 38 | impl Error for TimeParseError {} 39 | impl Display for TimeParseError { 40 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 41 | write!(f, "Couldn't parse time.") 42 | } 43 | } 44 | #[derive(Debug)] 45 | pub enum ReportError { 46 | CurrencyConversionError(Currency, Currency), 47 | } 48 | impl Error for ReportError {} 49 | impl Display for ReportError { 50 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 51 | match self { 52 | ReportError::CurrencyConversionError(from, to) => { 53 | write!(f, "Can't convert from {} to {}", from, to) 54 | } 55 | } 56 | } 57 | } 58 | #[derive(Debug)] 59 | pub enum LedgerError { 60 | AliasNotInList(String), 61 | TooManyEmptyPostings(usize), 62 | } 63 | impl Error for LedgerError {} 64 | impl Display for LedgerError { 65 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 66 | match self { 67 | LedgerError::AliasNotInList(x) => write!(f, "Alias not found: {}", x), 68 | LedgerError::TooManyEmptyPostings(x) => { 69 | write!(f, "{} {}", "Too many empty postings:".red(), x) 70 | } 71 | } 72 | } 73 | } 74 | #[derive(Debug)] 75 | pub enum BalanceError { 76 | TransactionIsNotBalanced, 77 | TooManyCurrencies(Balance), 78 | } 79 | impl Error for BalanceError {} 80 | 81 | impl Display for BalanceError { 82 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 83 | match self { 84 | BalanceError::TransactionIsNotBalanced => { 85 | write!(f, "{}", "Transaction is not balanced".red()) 86 | } 87 | 88 | BalanceError::TooManyCurrencies(bal) => write!( 89 | f, 90 | "Too many currencies, probably a price is missing: {}", 91 | bal.iter() 92 | .map(|x| x.1.to_string()) 93 | .collect::>() 94 | .join(", ") 95 | ), 96 | } 97 | } 98 | } 99 | #[derive(Debug)] 100 | pub struct GenericError { 101 | pub message: Vec, 102 | } 103 | 104 | impl Error for GenericError {} 105 | 106 | impl Display for GenericError { 107 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 108 | write!(f, "{}", ColoredStrings(&self.message)) 109 | } 110 | } 111 | 112 | impl From for GenericError { 113 | fn from(error: LedgerError) -> Self { 114 | eprintln!("{:?}", error); 115 | // TODO prettier error conversion 116 | GenericError { message: vec![] } 117 | } 118 | } 119 | 120 | // https://medium.com/apolitical-engineering/how-do-you-impl-display-for-vec-b8dbb21d814f 121 | struct ColoredStrings<'a>(pub &'a Vec); 122 | 123 | impl<'a> fmt::Display for ColoredStrings<'a> { 124 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 125 | self.0.iter().fold(Ok(()), |result, partial| { 126 | result.and_then(|_| write!(f, "{}", partial)) 127 | }) 128 | } 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | use colored::Colorize; 134 | use structopt::StructOpt; 135 | 136 | use super::{EmptyLedgerFileError, LedgerError}; 137 | use crate::{parser::Tokenizer, CommonOpts}; 138 | 139 | #[test] 140 | fn error_empty_posting_last() { 141 | let mut tokenizer = Tokenizer::from( 142 | "2021-06-05 Flight 143 | Assets:Checking 144 | Expenses:Comissions 145 | Expenses:Travel 200 EUR" 146 | .to_string(), 147 | ); 148 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 149 | let parsed = tokenizer.tokenize(&options); 150 | let ledger = parsed.to_ledger(&options); 151 | assert!(ledger.is_err()); 152 | let mut output: String = String::new(); 153 | if let Err(err) = ledger { 154 | let ledger_error = err.downcast_ref::().unwrap(); 155 | match ledger_error { 156 | LedgerError::TooManyEmptyPostings(x) => assert_eq!(*x, 2), 157 | other => { 158 | dbg!(other); 159 | panic!("Too many empty postings"); 160 | } 161 | } 162 | output = err.to_string(); 163 | } 164 | assert_eq!(output, format!("{} 2", "Too many empty postings:".red())); 165 | } 166 | 167 | #[test] 168 | fn empty_file() { 169 | let an_error = EmptyLedgerFileError {}; 170 | 171 | assert_eq!( 172 | format!("{}", an_error), 173 | "The journal file does not have any information" 174 | ); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/filter.rs: -------------------------------------------------------------------------------- 1 | use crate::error::GenericError; 2 | use crate::models::{Currency, Posting, PostingType, Transaction}; 3 | use crate::parser::value_expr::{eval, EvalResult, Node}; 4 | use crate::{CommonOpts, List}; 5 | use colored::Colorize; 6 | use regex::Regex; 7 | use std::collections::HashMap; 8 | 9 | /// Filters a posting based on the options 10 | pub fn filter( 11 | options: &CommonOpts, 12 | node: &Option, 13 | transaction: &Transaction, 14 | posting: &Posting, 15 | commodities: &List, 16 | ) -> Result> { 17 | // Get what's needed 18 | let real = options.real; 19 | 20 | // Check for real postings 21 | if real { 22 | if let PostingType::Real = posting.kind { 23 | } else { 24 | return Ok(false); 25 | } 26 | } 27 | 28 | // Check for dates at the posting level 29 | if let Some(date) = options.end { 30 | if posting.date >= date { 31 | return Ok(false); 32 | } 33 | } 34 | if let Some(date) = options.begin { 35 | if posting.date < date { 36 | return Ok(false); 37 | } 38 | } 39 | match node { 40 | Some(x) => filter_expression(x, posting, transaction, commodities, &mut HashMap::new()), 41 | None => Ok(true), 42 | } 43 | } 44 | 45 | pub fn filter_expression( 46 | predicate: &Node, 47 | posting: &Posting, 48 | transaction: &Transaction, 49 | commodities: &List, 50 | regexes: &mut HashMap, 51 | ) -> Result> { 52 | let result = eval(predicate, posting, transaction, commodities, regexes); 53 | match result { 54 | EvalResult::Boolean(b) => Ok(b), 55 | _ => Err(Box::new(GenericError { 56 | message: vec![ 57 | format!("{:?}", predicate).red().bold(), 58 | "should return a boolean".normal(), 59 | ], 60 | })), 61 | } 62 | } 63 | 64 | /// Create search expression from Strings 65 | /// 66 | /// The command line arguments provide syntactic sugar which save time when querying the journal. 67 | /// This expands it to an actual query 68 | /// 69 | /// # Examples 70 | /// ```rust 71 | /// # use dinero::filter::preprocess_query; 72 | /// let params:Vec = vec!["@payee", "savings" , "and", "checking", "and", "expr", "/aeiou/"].iter().map(|x| x.to_string()).collect(); 73 | /// let processed = preprocess_query(¶ms, &false); 74 | /// assert_eq!(processed, "((payee =~ /(?i)payee/) or (account =~ /(?i)savings/) and (account =~ /(?i)checking/) and (/aeiou/))") 75 | /// ``` 76 | pub fn preprocess_query(query: &[String], related: &bool) -> String { 77 | let mut expression = String::new(); 78 | let mut and = false; 79 | let mut first = true; 80 | let mut expr = false; 81 | for raw_term in query.iter() { 82 | let term = raw_term.trim(); 83 | if term.is_empty() { 84 | continue; 85 | } 86 | if term == "and" { 87 | and = true; 88 | continue; 89 | } else if term == "or" { 90 | and = false; 91 | continue; 92 | } else if term == "expr" { 93 | expr = true; 94 | continue; 95 | } 96 | let join_term = if !first { 97 | if and { 98 | " and (" 99 | } else { 100 | " or (" 101 | } 102 | } else { 103 | "(" 104 | }; 105 | expression.push_str(join_term); 106 | if expr { 107 | expression.push_str(term); 108 | } else if let Some(c) = term.chars().next() { 109 | match c { 110 | '@' => { 111 | expression.push_str("payee =~ /(?i)"); // case insensitive 112 | expression.push_str(&term.to_string()[1..]); 113 | expression.push('/'); 114 | } 115 | '%' => { 116 | expression.push_str("has_tag(/(?i)"); // case insensitive 117 | expression.push_str(&term.to_string()[1..]); 118 | expression.push_str("/)") 119 | } 120 | '/' => { 121 | expression.push_str("account =~ "); // case insensitive 122 | expression.push_str(term); 123 | } 124 | _ => { 125 | expression.push_str("account =~ /(?i)"); // case insensitive 126 | expression.push_str(term); 127 | expression.push('/'); 128 | } 129 | } 130 | } 131 | expression.push(')'); 132 | and = false; 133 | expr = false; 134 | first = false; 135 | } 136 | 137 | if *related { 138 | format!("(any({}) and not({}))", expression, expression) 139 | } else { 140 | format!("({})", expression) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/grammar/grammar.pest: -------------------------------------------------------------------------------- 1 | // 2 | // Grammar specification for ledger files as interpreted by dinero rs 3 | // Test in https:://pest.rs 4 | // 5 | 6 | journal = { SOI ~ (directive | blank_line | transaction | automated_transaction | journal_comment )* ~ ws* ~EOI} 7 | blank_line = {ws* ~ NEWLINE } 8 | directives = {directive* ~ EOI} 9 | journal_comment = {(";" | "!" | "#") ~ (!end ~ ANY)* ~ end} 10 | 11 | // Directives 12 | directive = { include | price 13 | | tag_dir 14 | | account_dir 15 | | commodity 16 | | payee_dir 17 | } 18 | 19 | include = { "include" ~ws+ ~ glob ~ws*~end} 20 | glob = { string | (!end ~ ANY)* } 21 | price = {"P" ~ ws* ~ date ~ (ws ~ time)? ~ ws+ ~ commodity_in_directive ~ ws* ~number ~ ws* ~ commodity_in_directive ~ws* ~ comment? ~ end} 22 | commodity = { "commodity" ~ ws+ ~ commodity_spec ~ ws* ~ comment? ~ end ~ 23 | (sep ~ 24 | ( 25 | comment 26 | | (commodity_property ) 27 | | (flag ~ ws* ~ comment?) 28 | )? 29 | ~end)* 30 | } 31 | commodity_spec = { string | (!";" ~!end ~ ANY)* } 32 | commodity_in_directive = { string | unquoted } 33 | payee_dir = { "payee" ~ ws+ ~ payee ~ ws* ~ comment? ~end ~ 34 | (sep ~ 35 | ( 36 | comment 37 | | (payee_property) 38 | )? 39 | ~end)* 40 | } 41 | tag_dir = { "tag" ~ ws+ ~ tag ~ ws* ~ comment? ~end ~ 42 | (sep ~ 43 | ( 44 | comment 45 | | (tag_property) 46 | )? 47 | ~end)* 48 | } 49 | account_dir = { "account" ~ ws+ ~ account ~ ws* ~ comment? ~ end ~ 50 | (sep ~ 51 | ( 52 | comment 53 | | (account_property ) 54 | | (flag ~ ws* ~ comment?) 55 | )? 56 | ~end)* 57 | } 58 | commodity_property = { (alias | note | format | isin ) ~ ws+ ~ property_value } 59 | payee_property = { (alias | note) ~ ws+ ~ property_value } 60 | account_property = { (alias | payee_subdirective | check | assert | note | iban | country ) ~ ws+ ~ property_value } 61 | tag_property = { (check | assert) ~ ws+ ~ property_value } 62 | property_value = { string | (!end ~ ANY )*} 63 | alias = { "alias" } 64 | note = { "note" } 65 | format = { "format" } 66 | check = { "check" } 67 | assert = { "assert" } 68 | isin = { "isin" } 69 | iban = { "iban" } 70 | country = { "country" } 71 | payee_subdirective = {"payee"} 72 | flag = { default } 73 | default = {"default"} 74 | // A transaction 75 | transaction_head = { 76 | transaction_date ~ // date 77 | ("=" ~ effective_date)? ~ // effective_date 78 | (ws+ ~ status)? ~ // status 79 | ws* ~ code? // code 80 | ~ ws* ~ description // description 81 | ~ ws* ~ (("|"|"@") ~ ws* ~payee)? // payee 82 | ~ws* ~ comment? } 83 | automated_transaction_head = { 84 | "=" ~ ws* ~ automated_description // description 85 | ~ws* ~ comment? } // comment 86 | transaction = {transaction_head 87 | ~ NEWLINE 88 | ~ (sep ~ comment ~ end)* 89 | ~ posting+ } 90 | automated_transaction = {automated_transaction_head 91 | ~ NEWLINE 92 | ~ (sep ~ comment ~ end)* 93 | ~ (posting|automated_posting)* } 94 | transaction_date = {date ~ (ws ~ time)?} 95 | effective_date = {date ~ (ws ~ time)?} 96 | code = { "(" ~ (!")" ~ ANY)* ~ ")" } 97 | status = { "*"| "!" } 98 | quote = _{"\"" | "'"} 99 | payee = { string | (!"|" ~ !";" ~!end ~ ANY)* } 100 | tag = { string | (!"|" ~ !";" ~!end ~ ANY)* } 101 | description = { string | (!"|" ~ !";" ~!end ~ ANY)* } 102 | automated_description = { string | (!";" ~!end ~ ANY)* } 103 | comment = {";" ~ ws* ~ comment_content~ ws*} 104 | comment_content = {(!end ~ ANY)*} 105 | 106 | posting = { sep ~ status? ~ 107 | posting_kind ~ 108 | ((sep ~ ws* ~ (amount ~ ws*) ~ (cost ~ ws*)?)? ~ 109 | ws* ~balance? ~ ws* ~ comment? ~ end ) 110 | ~ (sep ~ comment ~ end)* 111 | } 112 | automated_posting = { sep ~ status? ~ 113 | posting_kind ~ 114 | (sep ~ ws* ~ (value_expr | number) ~ ws* ~ comment? ~ end ) 115 | ~ (sep ~ comment ~ end)* 116 | } 117 | amount = {money} 118 | cost = { ("@@" | "@") ~ ws* ~ money } 119 | balance = {"=" ~ ws* ~ money ~ ws*} 120 | posting_kind = { virtual_no_balance | virtual_balance | real} 121 | real = { account } 122 | virtual_no_balance = { "(" ~ account ~ ")" } 123 | virtual_balance = { "[" ~ account ~ "]" } 124 | account = { string | 125 | ((unquoted ~ ( (" - "|" = " ~ " & "|" ") ~ unquoted)*)) ~ (":" ~ (unquoted ~ ( (" - "|" = " ~ " & "|" ") ~ unquoted)*))* } 126 | // Dates 127 | date = { year ~ date_sep ~ month ~ date_sep ~ day } 128 | time = { hour ~ ":" ~ minute ~ (":" ~ second) } 129 | datetime = { date ~ (ws ~ time)? } 130 | 131 | date_sep = { "." | "/" | "-"} 132 | year = { "-"? ~ bigint} 133 | month = {("1" ~ ("0" | "1" | "2")) | ("0"? ~ ASCII_NONZERO_DIGIT)} 134 | day = {("3" ~ ("0" | "1")) | 135 | (("1" | "2") ~ ASCII_DIGIT ) | 136 | ("0"? ~ ASCII_NONZERO_DIGIT) } 137 | hour = { (("0"|"1") ~ ASCII_DIGIT) | ("2" ~ ("0" | "1" | "2" | "3")) } 138 | minute = { ("0"|"1"|"2"|"3"|"4"|"5") ~ ASCII_DIGIT } 139 | second = { ("0"|"1"|"2"|"3"|"4"|"5") ~ ASCII_DIGIT ~ ("." ~ bigint)? } 140 | 141 | 142 | // Grammar specification for value expressions 143 | 144 | // A value expression is an expression between parenthesis 145 | value_expr = {"(" ~ ws* ~ expr ~ ws* ~ ")"} 146 | 147 | // Then the expression builds up in terms of increasing preference 148 | expr = { or_expr } 149 | or_expr = { and_expr ~ ws* ~ ( or ~ ws* ~ and_expr ) * } 150 | and_expr = { comparison_expr ~ ws* ~ ( and ~ ws* ~ comparison_expr ) * } 151 | comparison_expr = { additive_expr ~ ws* ~ ( comparison ~ ws* ~ additive_expr ) * } 152 | additive_expr = { multiplicative_expr ~ ws* ~ ( add ~ ws* ~ multiplicative_expr ) * } 153 | multiplicative_expr = { primary ~ ws* ~ ( mult ~ ws* ~ primary )* } 154 | primary = { 155 | ("(" ~ ws* ~ expr ~ ws* ~ ")") | 156 | (unary ~ ws* ~ expr) | 157 | term | 158 | (function ~ ws* ~ "(" ~ ws* ~ expr ~ ws* ~ ("," ~ ws* ~ expr ~ ws*)* ~ ")") 159 | } 160 | 161 | term = _{ variable | money | number | regex | string } 162 | money = { (number ~ ws* ~ currency) | ("-"? ~ currency ~ ws* ~ number) | ("0" ~ &(ws | sep | end ))} 163 | currency = { string | unquoted_no_number } 164 | regex = { "/" ~ (!"/" ~ ANY)* ~ "/"} 165 | string = { 166 | ("\"" ~ (("\\\"") | (!"\"" ~ ANY))* ~ "\"") | 167 | ("'" ~ (("\\'") | (!"'" ~ ANY))* ~ "'") 168 | } 169 | reserved = _{ "\n" | "\t" | "+" | "*" | "/" | "\\" | "|" | "%" | "<" | ">" | ":" | "?" | "(" | ")" | ";" | "[" | "]" } 170 | unquoted = { !reserved ~ !"=" ~ !"-" ~ !"&" ~ 171 | (!reserved ~ !SEPARATOR ~ ANY)+ } 172 | currency_parts = _{ !reserved ~ !"=" ~ !"-" ~ !"&" ~ 173 | (!reserved ~ !SEPARATOR ~ !ASCII_DIGIT ~ !"-" ~ !"=" ~ ANY)+ } 174 | unquoted_no_number = {currency_parts ~ ("-" ~ currency_parts)*} 175 | variable = { 176 | "account" | 177 | "payee" | 178 | "date" | 179 | "note" | 180 | "amount" | 181 | "total_amount" | 182 | "cost" | 183 | "value" | 184 | "gain" | 185 | "depth" | 186 | "posting_number" | 187 | "posting_count" | 188 | "cleared" | 189 | "real" | 190 | "not_automated" | 191 | "running_total" | 192 | "note" | 193 | // Abbreviations go later 194 | "T" | "N" | "O" | "Z" | "R" | "X" | 195 | "n" | "l" | "g" | "v" | "b" 196 | } 197 | 198 | 199 | // helpers 200 | number = { "-"? ~ bigint ~ ("." ~ bigint)? } 201 | bigint = _{ ASCII_DIGIT+ } 202 | ws = _{ " " | "\t" } 203 | sep = _{("\t" | " \t" | " ") ~ SEPARATOR* } 204 | end = _{ EOI | NEWLINE | blank_line} 205 | 206 | 207 | add = { "+" | "-" } 208 | mult = { "*" | "/" } 209 | and = {"&" | "and"} 210 | or = {"|" | "or" } 211 | unary = { "-" | "!" | "not" } 212 | function = { "abs" | "has_tag" | "to_date" | "any" | "tag" } 213 | comparison = { eq | ne | ge | gt | le | lt } 214 | eq = { "=~" | "=="} 215 | ne = { "!=" } 216 | gt = { ">" } 217 | ge = { ">=" } 218 | le = { "<=" } 219 | lt = { "<" } 220 | 221 | 222 | // Currency format, for the format directive 223 | decimal_part = { decimal_point ~ ASCII_DIGIT+} 224 | decimal_point = {!ASCII_DIGIT ~ ANY} 225 | number_separator = {!ASCII_DIGIT ~ ANY} 226 | integer_part = { ASCII_DIGIT+ ~ (number_separator ~ASCII_DIGIT+)*} 227 | currency_format_positive = { 228 | (integer_part ~ space? ~ currency_string) | 229 | (currency_string ~ space? ~ "-"? ~ integer_part ) 230 | } 231 | currency_format = { 232 | ("(" ~ currency_format_positive ~ ")") | 233 | ("-" ~ currency_format_positive) | 234 | ("-" ~ integer_part ~ space? ~ currency_string) | 235 | (currency_string ~ space? ~ integer_part ~ "-") 236 | | currency_format_positive 237 | } 238 | space = {" "} 239 | currency_string = { ( string | (!ASCII_DIGIT ~ !space ~ ANY)*) } -------------------------------------------------------------------------------- /src/list.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::collections::hash_map::{Iter, Values}; 3 | use std::collections::HashMap; 4 | use std::fmt::Debug; 5 | use std::hash::Hash; 6 | use std::rc::Rc; 7 | 8 | use crate::error::LedgerError; 9 | use crate::models::{FromDirective, HasAliases, HasName}; 10 | 11 | /// A generic container with some search capabilities 12 | /// 13 | /// This structure is used to hold master elements of the ledger than can be aliases such as 14 | /// commodities or accounts 15 | /// 16 | /// It provides methods for: 17 | /// - Adding new elements to the list 18 | /// - Adding new aliases to existing elements 19 | /// - Retrieving elements 20 | /// - Retrieving elements with a regular expression 21 | #[derive(Debug, Clone)] 22 | pub struct List { 23 | aliases: HashMap, 24 | list: HashMap>, 25 | matches: HashMap>, 26 | } 27 | impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> Default for List { 28 | fn default() -> Self { 29 | Self::new() 30 | } 31 | } 32 | impl<'a, T: Eq + Hash + HasName + Clone + FromDirective + HasAliases + Debug> List { 33 | pub fn new() -> Self { 34 | let aliases: HashMap = HashMap::new(); 35 | let list: HashMap> = HashMap::new(); 36 | let matches: HashMap> = HashMap::new(); 37 | List { 38 | aliases, 39 | list, 40 | matches, 41 | } 42 | } 43 | 44 | /// Inserts an ```element``` in the list 45 | pub fn insert(&mut self, element: T) { 46 | let found = self.list.get(&element.get_name().to_lowercase()); 47 | match found { 48 | Some(_) => eprintln!("Duplicate element: {:?}", element), // do nothing 49 | None => { 50 | // Change the name which will be used as key to lowercase 51 | let name = element.get_name().to_string().to_lowercase(); 52 | for alias in element.get_aliases().iter() { 53 | self.aliases.insert(alias.to_lowercase(), name.clone()); 54 | } 55 | self.list.insert(name, Rc::new(element)); 56 | } 57 | } 58 | } 59 | /// Removes an ```element``` in the list 60 | pub fn remove(&mut self, element: &T) { 61 | let found = self.list.get(&element.get_name().to_lowercase()); 62 | match found { 63 | Some(x) => { 64 | for alias in x.get_aliases() { 65 | self.aliases.remove(&alias.to_lowercase()); 66 | } 67 | self.list.remove(&element.get_name().to_lowercase()); 68 | } 69 | None => { 70 | for alias in element.get_aliases() { 71 | let value = self.aliases.remove(&alias.to_lowercase()); 72 | if let Some(x) = value { 73 | self.list.remove(&x); 74 | } 75 | self.list.remove(&alias.to_lowercase()); 76 | } 77 | } 78 | } 79 | } 80 | /// Add an alias 81 | pub fn add_alias(&mut self, alias: String, for_element: &'a T) { 82 | let element = self.aliases.get(&alias.to_lowercase()); 83 | match element { 84 | Some(x) => panic!( 85 | "Repeated alias {} for {} and {}", 86 | alias, 87 | for_element.get_name(), 88 | x 89 | ), 90 | None => { 91 | self.aliases 92 | .insert(alias.to_lowercase(), for_element.get_name().to_lowercase()); 93 | } 94 | } 95 | } 96 | 97 | pub fn get(&self, index: &str) -> Result<&Rc, LedgerError> { 98 | match self.list.get(&index.to_lowercase()) { 99 | None => match self.aliases.get(&index.to_lowercase()) { 100 | None => Err(LedgerError::AliasNotInList(format!( 101 | "{} {:?} not found", 102 | std::any::type_name::(), 103 | index 104 | ))), 105 | Some(x) => Ok(self.list.get(x).unwrap()), 106 | }, 107 | Some(x) => Ok(x), 108 | } 109 | } 110 | /// Gets an element from the regex 111 | pub fn get_regex(&mut self, regex: Regex) -> Option<&Rc> { 112 | let alias = self.matches.get(regex.as_str()); 113 | match alias { 114 | Some(x) => match x { 115 | Some(alias) => Some(self.get(alias).unwrap()), 116 | None => None, 117 | }, 118 | None => { 119 | // cache miss 120 | for (_alias, value) in self.list.iter() { 121 | if regex.is_match(value.get_name()) { 122 | self.matches 123 | .insert(regex.as_str().to_string(), Some(_alias.clone())); 124 | return Some(value); 125 | } 126 | } 127 | for (alias, value) in self.aliases.iter() { 128 | if regex.is_match(alias) { 129 | self.matches 130 | .insert(regex.as_str().to_string(), Some(value.clone())); 131 | return self.list.get(value); 132 | } 133 | } 134 | self.matches.insert(regex.as_str().to_string(), None); 135 | None 136 | } 137 | } 138 | // // Try the list 139 | 140 | // None 141 | } 142 | 143 | pub fn iter(&self) -> Iter<'_, String, Rc> { 144 | self.list.iter() 145 | } 146 | pub fn values(&self) -> Values<'_, String, Rc> { 147 | self.list.values() 148 | } 149 | pub fn len(&self) -> usize { 150 | self.list.len() 151 | } 152 | pub fn is_empty(&self) -> bool { 153 | self.list.is_empty() 154 | } 155 | pub fn len_alias(&self) -> usize { 156 | self.aliases.len() + self.len() 157 | } 158 | } 159 | 160 | impl List { 161 | pub fn append(&mut self, other: &List) { 162 | for (key, value) in other.list.iter() { 163 | if value.is_from_directive() { 164 | self.list.insert(key.clone(), value.clone()); 165 | for alias in value.get_aliases().iter() { 166 | self.aliases.insert(alias.to_lowercase(), key.clone()); 167 | } 168 | } else if self.get(key).is_err() { 169 | self.list.insert(key.clone(), value.clone()); 170 | } 171 | } 172 | } 173 | } 174 | 175 | #[cfg(test)] 176 | mod tests { 177 | use super::*; 178 | use crate::models::Payee; 179 | use regex::Regex; 180 | #[test] 181 | fn list() { 182 | let name = "ACME Inc."; 183 | let payee = Payee::from(name); 184 | let mut list: List = List::new(); 185 | list.insert(payee.clone()); 186 | 187 | // Get ACME from the list, using a regex 188 | let pattern = Regex::new("ACME").unwrap(); 189 | let retrieved = list.get_regex(pattern); 190 | 191 | assert!(retrieved.is_some()); 192 | assert_eq!(retrieved.unwrap().get_name(), "ACME Inc."); 193 | assert_eq!(list.len_alias(), 1); 194 | 195 | // Now add and alias 196 | list.add_alias("ACME is awesome".to_string(), &payee); 197 | assert_eq!(list.len_alias(), 2); 198 | 199 | // Retrieve an element that is not in the list 200 | assert!(list.get_regex(Regex::new("Warner").unwrap()).is_none()); 201 | assert!(list.get("Warner").is_err()); 202 | assert!(list.get_regex(Regex::new("awesome").unwrap()).is_some()); 203 | } 204 | #[test] 205 | #[should_panic] 206 | fn list_repeated_alias() { 207 | let mut list: List = List::new(); 208 | list.insert(Payee::from("ACME")); 209 | for _ in 0..2 { 210 | let retrieved = list.get("ACME").unwrap().clone(); 211 | list.add_alias("ACME, Inc.".to_string(), &retrieved) 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/mod.rs: -------------------------------------------------------------------------------- 1 | //! Dinero (spanish for money) is a command line tool that can deal with ledger files. It is inspired but not a port of John Wiegley's wonderful [ledger-cli](https://www.ledger-cli.org/). 2 | //! 3 | //! Note that the crate name is ```dinero-rs``` but the executable is ```dinero``` 4 | //! 5 | //! # Getting started 6 | //! 7 | //! ## The input files 8 | //! 9 | //! ```dinero``` understands ledger files, which are human-readable journal files. 10 | //! A journal is composed of *directives* that tell ```dinero``` about miscellaneous 11 | //! things like accounts, payees or commodities and *transactions*, which are each of the journal entries. A transaction looks like this: 12 | //! 13 | //! ```ledger 14 | //! 2021-02-28 * Flights to Rome | Alitalia 15 | //! Expenses:Travel 153.17 EUR 16 | //! Assets:Checking account -153.17 EUR 17 | //! ``` 18 | //! 19 | //! A transaction (if it doesn't include virtual postings) always has to be balanced, meaning the total amount of a transaction has to be zero. ```dinero``` knows this, so elliding some information is allowed, like so: 20 | //! ```ledger 21 | //! 2021-02-28 * Flights to Rome | Alitalia 22 | //! Expenses:Travel 153.17 EUR 23 | //! Assets:Checking account 24 | //! ``` 25 | //! Or you can even do multi-currency, the conversion will be implicitely done. It supports unicode too, so this is valid as well: 26 | //! 27 | //! ```ledger 28 | //! 2021-02-28 * Flights to Rome | Alitalia 29 | //! Expenses:Travel 153.17 € 30 | //! Assets:Checking account $-180 31 | //! ``` 32 | //! 33 | //! ## The commands 34 | //! 35 | //! Given an input file, reports are extracted with commands, like so: 36 | //! 37 | //! ```zsh 38 | //! # A balance report 39 | //! dinero bal -f my_journal.ledger 40 | //! 41 | //! # A balance report in euros 42 | //! dinero bal -f my_journal.ledger -X € 43 | //! 44 | //! # Print all the registers 45 | //! dinero reg -f my_journal.ledger 46 | //! ``` 47 | 48 | extern crate pest; 49 | #[macro_use] 50 | extern crate pest_derive; 51 | 52 | mod app; 53 | pub mod commands; 54 | mod error; 55 | pub mod filter; 56 | mod list; 57 | pub mod models; 58 | pub mod parser; 59 | 60 | pub use app::{run_app, CommonOpts}; 61 | pub use list::List; 62 | #[macro_use] 63 | extern crate prettytable; 64 | -------------------------------------------------------------------------------- /src/models/account.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{FromDirective, HasAliases, HasName, Origin}; 2 | use regex::Regex; 3 | use std::cell::RefCell; 4 | use std::collections::hash_map::RandomState; 5 | use std::collections::{HashMap, HashSet}; 6 | use std::fmt; 7 | use std::fmt::{Display, Formatter}; 8 | use std::hash::{Hash, Hasher}; 9 | 10 | /// An account 11 | #[derive(Debug, Clone)] 12 | pub struct Account { 13 | name: String, 14 | pub(crate) origin: Origin, 15 | pub(crate) note: Option, 16 | pub(crate) iban: Option, 17 | pub(crate) country: Option, 18 | pub(crate) aliases: HashSet, 19 | pub(crate) check: Vec, 20 | pub(crate) assert: Vec, 21 | pub(crate) payee: Vec, 22 | pub(crate) default: bool, 23 | matches: RefCell>, 24 | } 25 | 26 | impl Account { 27 | pub fn from_directive(name: String) -> Account { 28 | Account { 29 | name, 30 | origin: Origin::FromDirective, 31 | note: None, 32 | iban: None, 33 | country: None, 34 | aliases: HashSet::new(), 35 | check: vec![], 36 | assert: vec![], 37 | payee: vec![], 38 | default: false, 39 | matches: RefCell::new(HashMap::new()), 40 | } 41 | } 42 | pub fn is_default(&self) -> bool { 43 | self.default 44 | } 45 | pub fn payees(&self) -> &Vec { 46 | &self.payee 47 | } 48 | 49 | /// Depth of the account, useful for filters and other 50 | pub fn depth(&self) -> usize { 51 | self.name.chars().filter(|c| *c == ':').count() + 1 52 | } 53 | 54 | pub fn is_match(&self, regex: Regex) -> bool { 55 | let mut list = self.matches.borrow_mut(); 56 | match list.get(regex.as_str()) { 57 | Some(x) => *x, 58 | 59 | None => { 60 | let value = regex.is_match(self.get_name()); 61 | list.insert(regex.as_str().to_string(), value); 62 | value 63 | } 64 | } 65 | } 66 | } 67 | impl Display for Account { 68 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 69 | write!(f, "{}", self.name) 70 | } 71 | } 72 | 73 | impl PartialEq for Account { 74 | fn eq(&self, other: &Self) -> bool { 75 | self.name == other.name 76 | } 77 | } 78 | 79 | impl Eq for Account {} 80 | 81 | impl HasAliases for Account { 82 | fn get_aliases(&self) -> &HashSet { 83 | &self.aliases 84 | } 85 | } 86 | 87 | impl Hash for Account { 88 | fn hash(&self, state: &mut H) { 89 | self.name.hash(state); 90 | } 91 | } 92 | 93 | impl From<&str> for Account { 94 | fn from(name: &str) -> Self { 95 | Account { 96 | name: String::from(name), 97 | origin: Origin::Other, 98 | note: None, 99 | iban: None, 100 | country: None, 101 | aliases: Default::default(), 102 | check: vec![], 103 | assert: vec![], 104 | payee: vec![], 105 | default: false, 106 | matches: RefCell::new(HashMap::new()), 107 | } 108 | } 109 | } 110 | 111 | impl FromDirective for Account { 112 | fn is_from_directive(&self) -> bool { 113 | matches!(self.origin, Origin::FromDirective) 114 | } 115 | } 116 | 117 | impl HasName for Account { 118 | fn get_name(&self) -> &str { 119 | self.name.as_str() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/models/balance.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BalanceError; 2 | use crate::models::{Currency, Money}; 3 | use std::collections::hash_map::Iter; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | use std::fmt; 7 | use std::fmt::{Display, Formatter}; 8 | use std::ops::{Add, Neg, Sub}; 9 | use std::rc::Rc; 10 | 11 | /// Balance is money with several currencies, for example 100 USD and 50 EUR 12 | #[derive(Debug, Clone)] 13 | pub struct Balance { 14 | pub balance: HashMap>, Money>, 15 | } 16 | impl Default for Balance { 17 | fn default() -> Self { 18 | Self::new() 19 | } 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct TooManyCurrenciesError { 24 | pub num_currencies: usize, 25 | } 26 | impl Error for TooManyCurrenciesError {} 27 | impl Display for TooManyCurrenciesError { 28 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 29 | write!( 30 | f, 31 | "{} currencies found, expecting only one.", 32 | self.num_currencies 33 | ) 34 | } 35 | } 36 | 37 | impl Balance { 38 | pub fn new() -> Balance { 39 | Balance { 40 | balance: HashMap::new(), 41 | } 42 | } 43 | 44 | /// Automatic conversion from balance to regular money 45 | /// it can only be done if the balance has only one currency 46 | pub fn to_money(&self) -> Result { 47 | let vec = self 48 | .balance 49 | .values() 50 | .filter(|x| !x.is_zero()) 51 | .collect::>(); 52 | match vec.len() { 53 | 0 => Ok(Money::Zero), 54 | 1 => Ok(vec[0].clone()), 55 | _ => Err(BalanceError::TooManyCurrencies(self.clone())), 56 | } 57 | } 58 | pub fn is_zero(&self) -> bool { 59 | match self.balance.is_empty() { 60 | true => true, 61 | false => { 62 | for (_, money) in self.balance.iter() { 63 | if !money.is_zero() { 64 | return false; 65 | } 66 | } 67 | true 68 | } 69 | } 70 | } 71 | 72 | /// Whether a balance can be zero 73 | /// To be true, there must be positive and negative amounts 74 | pub fn can_be_zero(&self) -> bool { 75 | if self.is_zero() { 76 | return true; 77 | } 78 | let mut positive = false; 79 | let mut negative = false; 80 | for (_, m) in self.balance.iter() { 81 | positive |= m.is_positive(); 82 | negative |= m.is_negative(); 83 | if positive & negative { 84 | return true; 85 | } 86 | } 87 | false 88 | } 89 | pub fn len(&self) -> usize { 90 | self.balance.len() 91 | } 92 | pub fn is_empty(&self) -> bool { 93 | self.balance.is_empty() 94 | } 95 | pub fn iter(&self) -> Iter<'_, Option>, Money> { 96 | self.balance.iter() 97 | } 98 | } 99 | 100 | impl Display for Balance { 101 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 102 | let mut string = String::new(); 103 | for (_, v) in self.balance.iter() { 104 | string.push_str(format!("{}", v).as_str()); 105 | } 106 | write!(f, "{}", string) 107 | } 108 | } 109 | 110 | impl<'a> Neg for Balance { 111 | type Output = Balance; 112 | 113 | fn neg(self) -> Self::Output { 114 | let mut balance: HashMap>, Money> = HashMap::new(); 115 | for (k, v) in self.balance { 116 | balance.insert(k, -v); 117 | } 118 | Balance { balance } 119 | } 120 | } 121 | 122 | impl Add for Balance { 123 | type Output = Balance; 124 | 125 | fn add(self, rhs: Self) -> Self::Output { 126 | let mut total: HashMap>, Money> = HashMap::new(); 127 | let balances = vec![self, rhs]; 128 | for bal in balances.iter() { 129 | for (cur, money) in bal.balance.iter() { 130 | match total.to_owned().get(cur) { 131 | None => total.insert(cur.clone(), money.clone()), 132 | Some(total_money) => match total_money { 133 | Money::Zero => total.insert(cur.clone(), money.clone()), 134 | Money::Money { 135 | amount: already, .. 136 | } => match money { 137 | Money::Zero => None, 138 | Money::Money { amount, .. } => total.insert( 139 | cur.clone(), 140 | Money::from(( 141 | cur.as_ref().unwrap().clone(), 142 | amount + already.to_owned(), 143 | )), 144 | ), 145 | }, 146 | }, 147 | }; 148 | } 149 | } 150 | 151 | Balance { 152 | balance: total.into_iter().filter(|(_, v)| !v.is_zero()).collect(), 153 | } 154 | } 155 | } 156 | 157 | impl Sub for Balance { 158 | type Output = Balance; 159 | 160 | fn sub(self, rhs: Self) -> Self::Output { 161 | let negative = -rhs; 162 | self + negative 163 | } 164 | } 165 | 166 | // Converter 167 | impl From for Balance { 168 | fn from(money: Money) -> Self { 169 | let mut balance: HashMap>, Money> = HashMap::new(); 170 | match money { 171 | Money::Zero => balance.insert(None, Money::Zero), 172 | Money::Money { ref currency, .. } => { 173 | balance.insert(Some(currency.clone()), money.clone()) 174 | } 175 | }; 176 | Balance { balance } 177 | } 178 | } 179 | 180 | impl PartialEq for Balance { 181 | fn eq(&self, other: &Self) -> bool { 182 | for (k, v) in self.balance.iter() { 183 | let other_money = other.balance.get(k); 184 | match other_money { 185 | None => { 186 | if !v.is_zero() { 187 | return false; 188 | } 189 | } 190 | Some(money) => { 191 | if money != v { 192 | return false; 193 | } 194 | } 195 | } 196 | } 197 | true 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/models/comment.rs: -------------------------------------------------------------------------------- 1 | use crate::models::Tag; 2 | use crate::parser::utils::parse_str_as_date; 3 | use chrono::NaiveDate; 4 | use lazy_static::lazy_static; 5 | use regex::Regex; 6 | use std::cell::RefCell; 7 | 8 | use super::HasName; 9 | #[derive(Debug, Clone)] 10 | pub struct Comment { 11 | pub comment: String, 12 | calculated_tags: RefCell, 13 | tags: RefCell>, 14 | calculated_payee: RefCell, 15 | payee: RefCell>, 16 | } 17 | 18 | impl From for Comment { 19 | fn from(comment: String) -> Self { 20 | Comment { 21 | comment, 22 | calculated_tags: RefCell::new(false), 23 | tags: RefCell::new(vec![]), 24 | calculated_payee: RefCell::new(false), 25 | payee: RefCell::new(None), 26 | } 27 | } 28 | } 29 | 30 | impl From<&str> for Comment { 31 | fn from(comment: &str) -> Self { 32 | Comment::from(comment.to_string()) 33 | } 34 | } 35 | 36 | impl Comment { 37 | pub fn get_tags(&self) -> Vec { 38 | lazy_static! { 39 | // the tags 40 | static ref RE_FLAGS: Regex = Regex::new(r"(:.+:) *$").unwrap(); 41 | // the value 42 | static ref RE_VALUE: Regex = Regex::new(" *(.*): *(.*) *$").unwrap(); 43 | } 44 | let calculated_tags = *self.calculated_tags.borrow_mut(); 45 | let tags = { 46 | if !calculated_tags { 47 | self.calculated_tags.replace(true); 48 | self.tags 49 | .borrow_mut() 50 | .append(&mut match RE_FLAGS.is_match(&self.comment) { 51 | true => { 52 | let value = RE_FLAGS 53 | .captures(&self.comment) 54 | .unwrap() 55 | .iter() 56 | .nth(1) 57 | .unwrap() 58 | .unwrap() 59 | .as_str(); 60 | let mut tags: Vec = value 61 | .split(':') 62 | .map(|x| Tag { 63 | name: x.into(), 64 | check: vec![], 65 | assert: vec![], 66 | value: None, 67 | }) 68 | .collect(); 69 | tags.pop(); 70 | tags.remove(0); 71 | tags 72 | } 73 | false => match RE_VALUE.is_match(&self.comment) { 74 | true => { 75 | let re_captures = RE_VALUE.captures(&self.comment).unwrap(); 76 | let mut captures = re_captures.iter(); 77 | captures.next(); 78 | let name: String = 79 | captures.next().unwrap().unwrap().as_str().to_string(); 80 | if name.contains(':') { 81 | vec![] 82 | } else { 83 | vec![Tag { 84 | name, 85 | check: vec![], 86 | assert: vec![], 87 | value: Some( 88 | captures 89 | .next() 90 | .unwrap() 91 | .unwrap() 92 | .as_str() 93 | .trim() 94 | .to_string(), 95 | ), 96 | }] 97 | } 98 | } 99 | false => vec![], 100 | }, 101 | }); 102 | } 103 | self.tags.borrow().clone() 104 | }; 105 | tags 106 | } 107 | 108 | pub fn get_payee_str(&self) -> Option { 109 | let calculated_payee = *self.calculated_payee.borrow_mut(); 110 | if calculated_payee { 111 | return self.payee.borrow().clone(); 112 | } 113 | self.calculated_payee.replace(true); 114 | for tag in self.get_tags().iter() { 115 | if tag.value.is_none() | (tag.get_name().to_lowercase() != "payee") { 116 | continue; 117 | } 118 | self.payee.replace(tag.value.clone()); 119 | return tag.value.clone(); 120 | } 121 | None 122 | } 123 | 124 | /// Gets the date of a comment 125 | /// 126 | /// This function is not cached, as in practice it is called only once 127 | pub fn get_date(&self) -> Option { 128 | lazy_static! { 129 | // the value 130 | static ref RE_VALUE: Regex = Regex::new(r" *\[=(\d{4}.\d{2}.\d{2})\] *$").unwrap(); 131 | } 132 | match RE_VALUE.is_match(&self.comment) { 133 | true => Some(parse_str_as_date( 134 | self.comment.as_str().trim().split_at(2).1, 135 | )), 136 | false => None, 137 | } 138 | } 139 | } 140 | 141 | #[cfg(test)] 142 | mod tests { 143 | use super::*; 144 | use crate::models::HasName; 145 | #[test] 146 | fn multi_tag() { 147 | let comment = Comment::from(":tag_1:tag_2:tag_3:"); 148 | let tags = comment.get_tags(); 149 | assert_eq!(tags.len(), 3, "There should be three tags"); 150 | assert_eq!(tags[0].get_name(), "tag_1"); 151 | assert_eq!(tags[1].get_name(), "tag_2"); 152 | assert_eq!(tags[2].get_name(), "tag_3"); 153 | } 154 | #[test] 155 | fn no_tag() { 156 | let comment = Comment::from(":tag_1:tag_2:tag_3: this is not valid"); 157 | let tags = comment.get_tags(); 158 | assert_eq!(tags.len(), 0, "There should no tags"); 159 | } 160 | #[test] 161 | fn not_a_tag() { 162 | let comment = Comment::from("not a tag whatsoever"); 163 | let tags = comment.get_tags(); 164 | assert_eq!(tags.len(), 0, "There should no tags"); 165 | } 166 | #[test] 167 | fn tag_value() { 168 | let comment = Comment::from("tag: value"); 169 | let tags = comment.get_tags(); 170 | assert_eq!(tags.len(), 1, "There should be one tag"); 171 | let tag = tags[0].clone(); 172 | assert_eq!(tag.get_name(), "tag"); 173 | assert_eq!(tag.value.unwrap(), "value".to_string()); 174 | } 175 | #[test] 176 | fn tag_value_spaces() { 177 | let comment = Comment::from("tag: value with spaces"); 178 | let tags = comment.get_tags(); 179 | assert_eq!(tags.len(), 1, "There should be one tag"); 180 | let tag = tags[0].clone(); 181 | assert_eq!(tag.get_name(), "tag"); 182 | assert_eq!(tag.value.unwrap(), "value with spaces".to_string()); 183 | } 184 | #[test] 185 | fn date_in_comment() { 186 | let comment = Comment::from(" [=2021/03/02] "); 187 | let date = comment.get_date().unwrap(); 188 | assert_eq!(date, NaiveDate::from_ymd(2021, 3, 2)); 189 | } 190 | #[test] 191 | fn payee_in_comment() { 192 | let comment = Comment::from(" payee: claudio "); 193 | let payee = comment.get_payee_str().unwrap(); 194 | assert_eq!(payee, "claudio".to_string()); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/models/currency.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::collections::HashSet; 3 | use std::fmt; 4 | use std::fmt::{Display, Formatter}; 5 | use std::hash::{Hash, Hasher}; 6 | 7 | use super::super::parser::{GrammarParser, Rule}; 8 | use crate::models::{FromDirective, HasAliases, HasName, Origin}; 9 | use pest::Parser; 10 | use std::cmp::Ordering; 11 | /// Currency representation 12 | /// 13 | /// A currency (or commodity) has a name and a list of aliases, we have to make sure that when two commodities are 14 | /// created, they are the same, like so: 15 | /// 16 | /// # Examples 17 | /// ```rust 18 | /// use dinero::models::{Currency}; 19 | /// use dinero::List; 20 | /// 21 | /// let usd1 = Currency::from("usd"); 22 | /// let usd2 = Currency::from("usd"); 23 | /// assert_eq!(usd1, usd2); 24 | /// 25 | /// let eur1 = Currency::from("eur"); 26 | /// assert_ne!(eur1, usd1); 27 | /// 28 | /// let mut eur2 = Currency::from("eur"); 29 | /// assert_eq!(eur1, eur2); 30 | /// 31 | /// let mut currencies = List::::new(); 32 | /// currencies.insert(eur1); 33 | /// currencies.insert(eur2); 34 | /// currencies.insert(usd1); 35 | /// currencies.insert(usd2); 36 | /// assert_eq!(currencies.len_alias(), 2, "Alias len should be 2"); 37 | /// let eur = Currency::from("eur"); 38 | /// currencies.add_alias("euro".to_string(), &eur); 39 | /// assert_eq!(currencies.len_alias(), 3, "Alias len should be 3"); 40 | /// currencies.add_alias('€'.to_string(), &eur); 41 | /// assert_eq!(currencies.len(), 2, "List len should be 2"); 42 | /// assert_eq!(currencies.len_alias(), 4, "Alias len should be 4"); 43 | /// assert_eq!(currencies.get("eur").unwrap().as_ref(), &eur); 44 | /// assert_eq!(currencies.get("€").unwrap().as_ref(), &eur); 45 | /// 46 | /// 47 | /// assert_eq!(currencies.get("eur").unwrap(), currencies.get("€").unwrap(), "EUR and € should be the same"); 48 | /// 49 | /// ``` 50 | #[derive(Debug, Clone)] 51 | pub struct Currency { 52 | name: String, 53 | origin: Origin, 54 | note: Option, 55 | aliases: HashSet, 56 | pub(crate) format: Option, 57 | default: bool, 58 | pub(crate) display_format: RefCell, 59 | } 60 | 61 | /// Definition of how to display a currency 62 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 63 | pub struct CurrencyDisplayFormat { 64 | pub symbol_placement: CurrencySymbolPlacement, 65 | pub negative_amount_display: NegativeAmountDisplay, 66 | pub decimal_separator: Separator, 67 | pub digit_grouping: DigitGrouping, 68 | pub thousands_separator: Option, 69 | pub precision: usize, 70 | pub max_decimals: Option, 71 | } 72 | 73 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 74 | pub enum CurrencySymbolPlacement { 75 | BeforeAmount, 76 | AfterAmount, 77 | } 78 | 79 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 80 | pub enum NegativeAmountDisplay { 81 | BeforeSymbolAndNumber, // UK -£127.54 or Spain -127,54 € 82 | BeforeNumberBehindCurrency, // Denmark kr-127,54 83 | AfterNumber, // Netherlands € 127,54- 84 | Parentheses, // US ($127.54) 85 | } 86 | 87 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 88 | pub enum DigitGrouping { 89 | Thousands, 90 | Indian, 91 | None, 92 | } 93 | 94 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 95 | pub enum Separator { 96 | Dot, 97 | Comma, 98 | Space, 99 | Other(char), 100 | } 101 | 102 | impl Currency { 103 | pub fn from_directive(name: String) -> Self { 104 | Currency { 105 | name, 106 | origin: Origin::FromDirective, 107 | note: None, 108 | aliases: HashSet::new(), 109 | format: None, 110 | default: false, 111 | display_format: RefCell::new(DEFAULT_DISPLAY_FORMAT), 112 | } 113 | } 114 | 115 | pub fn set_note(&mut self, note: String) { 116 | self.note = Some(note); 117 | } 118 | pub fn set_default(&mut self) { 119 | self.default = true; 120 | } 121 | pub fn set_aliases(&mut self, aliases: HashSet) { 122 | self.aliases = aliases; 123 | } 124 | pub fn set_precision(&self, precision: usize) { 125 | self.display_format.borrow_mut().set_precision(precision); 126 | } 127 | pub fn get_precision(&self) -> usize { 128 | self.display_format.borrow().precision 129 | } 130 | pub fn update_precision(&self, precision: usize) { 131 | self.display_format.borrow_mut().update_precision(precision); 132 | } 133 | pub fn set_format(&self, format: &CurrencyDisplayFormat) { 134 | let mut current_format = self.display_format.borrow_mut(); 135 | current_format.symbol_placement = format.symbol_placement; 136 | current_format.negative_amount_display = format.negative_amount_display; 137 | current_format.decimal_separator = format.decimal_separator; 138 | current_format.digit_grouping = format.digit_grouping; 139 | current_format.thousands_separator = format.thousands_separator; 140 | current_format.precision = format.precision; 141 | current_format.max_decimals = format.max_decimals; 142 | // dbg!(format); 143 | } 144 | } 145 | impl CurrencyDisplayFormat { 146 | pub fn get_decimal_separator_str(&self) -> char { 147 | match self.decimal_separator { 148 | Separator::Dot => '.', 149 | Separator::Comma => ',', 150 | Separator::Space => '\u{202f}', 151 | Separator::Other(x) => x, 152 | } 153 | } 154 | pub fn set_decimal_separator(&mut self, separator: char) { 155 | self.decimal_separator = match separator { 156 | '.' => Separator::Dot, 157 | ',' => Separator::Comma, 158 | x => Separator::Other(x), 159 | }; 160 | } 161 | pub fn get_thousands_separator_str(&self) -> Option { 162 | match self.thousands_separator { 163 | Some(Separator::Dot) => Some('.'), 164 | Some(Separator::Comma) => Some(','), 165 | Some(Separator::Space) => Some('\u{202f}'), 166 | Some(Separator::Other(x)) => Some(x), 167 | None => None, 168 | } 169 | } 170 | pub fn set_thousands_separator(&mut self, separator: char) { 171 | self.thousands_separator = match separator { 172 | '.' => Some(Separator::Dot), 173 | ',' => Some(Separator::Comma), 174 | '\u{202f}' => Some(Separator::Space), 175 | x => Some(Separator::Other(x)), 176 | }; 177 | } 178 | pub fn get_digit_grouping(&self) -> DigitGrouping { 179 | self.digit_grouping 180 | } 181 | pub fn set_digit_grouping(&mut self, grouping: DigitGrouping) { 182 | self.digit_grouping = grouping 183 | } 184 | pub fn update_precision(&mut self, precision: usize) { 185 | if precision > self.precision { 186 | self.precision = precision; 187 | } 188 | } 189 | pub fn set_precision(&mut self, precision: usize) { 190 | self.max_decimals = Some(precision); 191 | } 192 | pub fn get_precision(&self) -> usize { 193 | match self.max_decimals { 194 | Some(precision) => precision, 195 | None => self.precision, 196 | } 197 | } 198 | } 199 | 200 | impl From<&str> for CurrencyDisplayFormat { 201 | /// Sets the format of the currency representation 202 | fn from(format: &str) -> Self { 203 | // dbg!(&format); 204 | let mut display_format = DEFAULT_DISPLAY_FORMAT; 205 | let mut parsed = GrammarParser::parse(Rule::currency_format, format) 206 | .unwrap() 207 | .next() 208 | .unwrap() 209 | .into_inner(); 210 | 211 | let mut first = parsed.next().unwrap(); 212 | let integer_format; 213 | 214 | if first.as_rule() == Rule::currency_format_positive { 215 | display_format.negative_amount_display = NegativeAmountDisplay::BeforeSymbolAndNumber; 216 | if first.as_str().starts_with('(') { 217 | display_format.negative_amount_display = NegativeAmountDisplay::Parentheses; 218 | } 219 | parsed = first.into_inner(); 220 | first = parsed.next().unwrap(); 221 | } 222 | match first.as_rule() { 223 | Rule::integer_part => { 224 | integer_format = Some(first); 225 | let rule = parsed.next().unwrap(); 226 | if rule.as_rule() == Rule::space { 227 | parsed.next(); 228 | } 229 | parsed.next(); 230 | } 231 | Rule::currency_string => { 232 | let mut rule = parsed.next().unwrap(); 233 | if rule.as_rule() == Rule::space { 234 | rule = parsed.next().unwrap(); 235 | } 236 | integer_format = Some(rule); 237 | display_format.symbol_placement = CurrencySymbolPlacement::BeforeAmount; 238 | display_format.negative_amount_display = 239 | NegativeAmountDisplay::BeforeSymbolAndNumber; 240 | } 241 | other => { 242 | panic!("Other: {:?}", other); 243 | } 244 | } 245 | 246 | // Get thousands separator and type of separation 247 | match integer_format { 248 | Some(x) => { 249 | let mut separators = vec![]; 250 | let num_str = x.as_str(); 251 | for sep in x.into_inner() { 252 | separators.push((sep.as_str().chars().next().unwrap(), sep.as_span().start())); 253 | } 254 | let len = separators.len(); 255 | display_format.thousands_separator = None; 256 | if len == 0 { 257 | display_format.digit_grouping = DigitGrouping::None; 258 | } else { 259 | display_format.set_decimal_separator(separators[len - 1].0); 260 | // Get the precision 261 | display_format.max_decimals = 262 | Some(num_str.split(separators[len - 1].0).last().unwrap().len()); 263 | } 264 | if len > 1 { 265 | display_format.set_thousands_separator(separators[len - 2].0); 266 | } 267 | if len > 2 { 268 | let n = separators[len - 2].1 - separators[len - 3].1; 269 | match n { 270 | 2 => display_format.digit_grouping = DigitGrouping::Indian, 271 | 3 => display_format.digit_grouping = DigitGrouping::Thousands, 272 | _ => eprintln!("Wrong number format: {}", &format), 273 | } 274 | } 275 | } 276 | None => display_format.digit_grouping = DigitGrouping::None, 277 | } 278 | display_format 279 | } 280 | } 281 | 282 | impl Display for Currency { 283 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 284 | write!(f, "{}", self.name) 285 | } 286 | } 287 | 288 | impl HasName for Currency { 289 | fn get_name(&self) -> &str { 290 | self.name.as_str() 291 | } 292 | } 293 | impl HasAliases for Currency { 294 | fn get_aliases(&self) -> &HashSet { 295 | &self.aliases 296 | } 297 | } 298 | impl<'a> From<&'a str> for Currency { 299 | fn from(name: &'a str) -> Self { 300 | let mut cur = Currency::from_directive(name.to_string()); 301 | cur.origin = Origin::Other; 302 | cur 303 | } 304 | } 305 | 306 | impl FromDirective for Currency { 307 | fn is_from_directive(&self) -> bool { 308 | matches!(self.origin, Origin::FromDirective) 309 | } 310 | } 311 | 312 | impl PartialEq for Currency { 313 | fn eq(&self, other: &Self) -> bool { 314 | self.name == other.name 315 | } 316 | } 317 | impl Eq for Currency {} 318 | 319 | impl Hash for Currency { 320 | fn hash(&self, state: &mut H) { 321 | self.name.hash(state); 322 | } 323 | } 324 | 325 | impl Ord for Currency { 326 | fn cmp(&self, other: &Self) -> Ordering { 327 | self.name.cmp(&other.name) 328 | } 329 | } 330 | impl PartialOrd for Currency { 331 | fn partial_cmp(&self, other: &Self) -> Option { 332 | self.name.partial_cmp(&other.name) 333 | } 334 | } 335 | 336 | const DEFAULT_DISPLAY_FORMAT: CurrencyDisplayFormat = CurrencyDisplayFormat { 337 | symbol_placement: CurrencySymbolPlacement::AfterAmount, 338 | negative_amount_display: NegativeAmountDisplay::BeforeSymbolAndNumber, 339 | decimal_separator: Separator::Dot, 340 | digit_grouping: DigitGrouping::Thousands, 341 | thousands_separator: Some(Separator::Comma), 342 | precision: 0, 343 | max_decimals: None, 344 | }; 345 | 346 | #[cfg(test)] 347 | mod tests { 348 | use super::*; 349 | #[test] 350 | fn format_1() { 351 | let format_str = "-1.234,00 €"; 352 | let format = CurrencyDisplayFormat::from(format_str); 353 | 354 | assert_eq!(format.get_precision(), 2); 355 | assert_eq!(format.get_thousands_separator_str(), Some('.')); 356 | assert_eq!(format.get_decimal_separator_str(), ','); 357 | assert_eq!(format.get_digit_grouping(), DigitGrouping::Thousands); 358 | assert_eq!( 359 | format.symbol_placement, 360 | CurrencySymbolPlacement::AfterAmount 361 | ); 362 | assert_eq!( 363 | format.negative_amount_display, 364 | NegativeAmountDisplay::BeforeSymbolAndNumber 365 | ); 366 | } 367 | #[test] 368 | fn format_2() { 369 | let format_str = "($1,234.00)"; 370 | let format = CurrencyDisplayFormat::from(format_str); 371 | 372 | assert_eq!(format.get_precision(), 2); 373 | assert_eq!(format.get_thousands_separator_str(), Some(',')); 374 | assert_eq!(format.get_decimal_separator_str(), '.'); 375 | assert_eq!(format.get_digit_grouping(), DigitGrouping::Thousands); 376 | assert_eq!( 377 | format.symbol_placement, 378 | CurrencySymbolPlacement::BeforeAmount 379 | ); 380 | assert_eq!( 381 | format.negative_amount_display, 382 | NegativeAmountDisplay::BeforeSymbolAndNumber 383 | ); 384 | } 385 | #[test] 386 | fn format_3() { 387 | let format_str = "-1234,00 €"; 388 | let format = CurrencyDisplayFormat::from(format_str); 389 | 390 | assert_eq!(format.get_precision(), 2); 391 | assert_eq!(format.get_thousands_separator_str(), None); 392 | assert_eq!(format.get_decimal_separator_str(), ','); 393 | // assert_eq!(format.get_digit_grouping(), DigitGrouping::Thousands); 394 | assert_eq!( 395 | format.symbol_placement, 396 | CurrencySymbolPlacement::AfterAmount 397 | ); 398 | assert_eq!( 399 | format.negative_amount_display, 400 | NegativeAmountDisplay::BeforeSymbolAndNumber 401 | ); 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/models/money.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::fmt::{Display, Formatter}; 3 | use std::ops::{Add, Div, Mul, Neg, Sub}; 4 | use std::rc::Rc; 5 | 6 | use num::rational::BigRational; 7 | use num::{self, ToPrimitive}; 8 | use num::{BigInt, Signed, Zero}; 9 | 10 | use crate::models::balance::Balance; 11 | use crate::models::currency::{CurrencySymbolPlacement, DigitGrouping, NegativeAmountDisplay}; 12 | use crate::models::{Currency, HasName}; 13 | use std::borrow::Borrow; 14 | use std::cmp::Ordering; 15 | 16 | /// Money representation: an amount and a currency 17 | /// 18 | /// It is important that calculations are not done with floats but with Rational numbers so that 19 | /// everything adds up correctly 20 | /// 21 | /// Money can be added, in which case it returns a balance, as it can have several currencies 22 | /// # Examples 23 | /// ```rust 24 | /// # use dinero::models::{Money, Balance, Currency, DigitGrouping}; 25 | /// # use num::rational::BigRational; 26 | /// # use std::rc::Rc; 27 | /// use num::BigInt; 28 | /// # 29 | /// let usd = Rc::new(Currency::from("usd")); 30 | /// let mut eur = Rc::new(Currency::from("eur")); 31 | /// 32 | /// let zero = Money::new(); 33 | /// let m1 = Money::from((eur.clone(), BigRational::from(BigInt::from(100)))); 34 | /// let m2 = Money::from((eur.clone(), BigRational::from(BigInt::from(200)))); 35 | /// # let m3 = Money::from((eur.clone(), BigRational::from(BigInt::from(300)))); 36 | /// let b1 = m1.clone() + m2.clone(); // 300 euros 37 | /// # assert_eq!(*b1.balance.get(&Some(eur.clone())).unwrap(), m3); 38 | /// 39 | /// // Multi-currency works as well 40 | /// let d1 = Money::from((usd.clone(), BigRational::from(BigInt::from(50)))); 41 | /// let b2 = d1.clone() + m1.clone(); // 100 euros and 50 usd 42 | /// assert_eq!(b2.balance.len(), 2); 43 | /// assert_eq!(*b2.balance.get(&Some(eur.clone())).unwrap(), m1); 44 | /// assert_eq!(*b2.balance.get(&Some(usd.clone())).unwrap(), d1); 45 | /// 46 | /// let b3 = b1 - Balance::from(m2.clone()) + Balance::from(Money::new()); 47 | /// assert_eq!(b3.to_money().unwrap(), m1); 48 | /// ``` 49 | #[derive(Clone, Debug)] 50 | pub enum Money { 51 | Zero, 52 | Money { 53 | amount: num::rational::BigRational, 54 | currency: Rc, 55 | }, 56 | } 57 | impl Default for Money { 58 | fn default() -> Self { 59 | Self::new() 60 | } 61 | } 62 | impl Money { 63 | pub fn new() -> Self { 64 | Money::Zero 65 | } 66 | pub fn is_zero(&self) -> bool { 67 | match self { 68 | Money::Zero => true, 69 | Money::Money { amount, .. } => amount.is_zero(), 70 | } 71 | } 72 | pub fn is_positive(&self) -> bool { 73 | match self { 74 | Money::Zero => true, 75 | Money::Money { amount, .. } => amount.is_positive(), 76 | } 77 | } 78 | pub fn is_negative(&self) -> bool { 79 | match self { 80 | Money::Zero => true, 81 | Money::Money { amount, .. } => amount.is_negative(), 82 | } 83 | } 84 | pub fn get_commodity(&self) -> Option> { 85 | match self { 86 | Money::Zero => None, 87 | Money::Money { currency, .. } => Some(currency.clone()), 88 | } 89 | } 90 | pub fn get_amount(&self) -> BigRational { 91 | match self { 92 | Money::Zero => BigRational::new(BigInt::from(0), BigInt::from(1)), 93 | Money::Money { amount, .. } => amount.clone(), 94 | } 95 | } 96 | pub fn abs(&self) -> Money { 97 | match self.is_negative() { 98 | true => -self.clone(), 99 | false => self.clone(), 100 | } 101 | } 102 | } 103 | impl Eq for Money {} 104 | 105 | impl PartialEq for Money { 106 | fn eq(&self, other: &Self) -> bool { 107 | match self { 108 | Money::Zero => match other { 109 | Money::Zero => true, 110 | Money::Money { amount, .. } => amount.is_zero(), 111 | }, 112 | Money::Money { 113 | amount: a1, 114 | currency: c1, 115 | } => match other { 116 | Money::Zero => a1.is_zero(), 117 | Money::Money { 118 | amount: a2, 119 | currency: c2, 120 | } => (a1 == a2) & (c1 == c2), 121 | }, 122 | } 123 | } 124 | } 125 | 126 | impl Ord for Money { 127 | fn cmp(&self, other: &Self) -> Ordering { 128 | match self.partial_cmp(other) { 129 | Some(c) => c, 130 | None => { 131 | let self_commodity = self.get_commodity().unwrap(); 132 | let other_commodity = other.get_commodity().unwrap(); 133 | panic!( 134 | "Can't compare different currencies. {} and {}.", 135 | self_commodity, other_commodity 136 | ); 137 | } 138 | } 139 | } 140 | } 141 | impl PartialOrd for Money { 142 | fn partial_cmp(&self, other: &Self) -> Option { 143 | let self_amount = self.get_amount(); 144 | let other_amount = other.get_amount(); 145 | match self.get_commodity() { 146 | None => self_amount.partial_cmp(other_amount.borrow()), 147 | Some(self_currency) => match other.get_commodity() { 148 | None => self_amount.partial_cmp(other_amount.borrow()), 149 | Some(other_currency) => { 150 | if self_currency == other_currency { 151 | self_amount.partial_cmp(other_amount.borrow()) 152 | } else { 153 | None 154 | } 155 | } 156 | }, 157 | } 158 | } 159 | } 160 | 161 | impl Mul for Money { 162 | type Output = Money; 163 | 164 | fn mul(self, rhs: BigRational) -> Self::Output { 165 | match self { 166 | Money::Zero => Money::new(), 167 | Money::Money { amount, currency } => Money::from((currency, amount * rhs)), 168 | } 169 | } 170 | } 171 | 172 | impl Div for Money { 173 | type Output = Money; 174 | 175 | fn div(self, rhs: BigRational) -> Self::Output { 176 | match self { 177 | Money::Zero => Money::new(), 178 | Money::Money { amount, currency } => Money::from((currency, amount / rhs)), 179 | } 180 | } 181 | } 182 | 183 | impl From<(Rc, BigRational)> for Money { 184 | fn from(cur_amount: (Rc, BigRational)) -> Self { 185 | let (currency, amount) = cur_amount; 186 | Money::Money { amount, currency } 187 | } 188 | } 189 | 190 | impl From<(Currency, BigRational)> for Money { 191 | fn from(cur_amount: (Currency, BigRational)) -> Self { 192 | let (currency, amount) = cur_amount; 193 | Money::Money { 194 | amount, 195 | currency: Rc::new(currency), 196 | } 197 | } 198 | } 199 | 200 | impl Add for Money { 201 | type Output = Balance; 202 | 203 | fn add(self, rhs: Self) -> Self::Output { 204 | let b1 = Balance::from(self); 205 | let b2 = Balance::from(rhs); 206 | b1 + b2 207 | } 208 | } 209 | impl Sub for Money { 210 | type Output = Balance; 211 | 212 | fn sub(self, rhs: Self) -> Self::Output { 213 | let b1 = Balance::from(self); 214 | let b2 = Balance::from(rhs); 215 | b1 - b2 216 | } 217 | } 218 | 219 | impl<'a> Neg for Money { 220 | type Output = Money; 221 | 222 | fn neg(self) -> Self::Output { 223 | match self { 224 | Money::Zero => Money::Zero, 225 | Money::Money { currency, amount } => Money::Money { 226 | currency, 227 | amount: -amount, 228 | }, 229 | } 230 | } 231 | } 232 | 233 | impl Display for Money { 234 | // [This is what Microsoft says about currency formatting](https://docs.microsoft.com/en-us/globalization/locale/currency-formatting) 235 | 236 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 237 | match self { 238 | Money::Zero => write!(f, "0"), 239 | Money::Money { amount, currency } => { 240 | // Suppose: -1.234.567,000358 EUR 241 | // Get the format 242 | let format = currency.display_format.borrow(); 243 | // Read number of decimals from format 244 | let decimals = match format.max_decimals { 245 | Some(d) => d, 246 | None => format.precision, 247 | }; 248 | 249 | let full_str = format!("{:.*}", decimals, amount.to_f64().unwrap()); 250 | 251 | // num = trunc + fract 252 | let integer_part: String = full_str 253 | .split('.') 254 | .next() 255 | .unwrap() 256 | .split('-') 257 | .last() 258 | .unwrap() 259 | .into(); // -1.234.567 260 | 261 | let decimal_part = amount.fract().to_f64().unwrap(); 262 | 263 | // decimal_str holds the decimal part without the dot (decimal separator) 264 | let mut decimal_str = if decimals == 0 { 265 | String::new() 266 | } else { 267 | format!("{:.*}", decimals, &decimal_part) 268 | .split('.') 269 | .last() 270 | .unwrap() 271 | .into() 272 | }; 273 | // now add the dot 274 | if decimals > 0 { 275 | decimal_str = format!("{}{}", format.get_decimal_separator_str(), decimal_str); 276 | } 277 | 278 | let integer_str = { 279 | match format.get_digit_grouping() { 280 | DigitGrouping::None => integer_part, // Do nothing 281 | grouping => { 282 | let mut group_size = 3; 283 | let mut counter = 0; 284 | let mut reversed = vec![]; 285 | match format.get_thousands_separator_str() { 286 | Some(character) => { 287 | let thousands_separator = character; 288 | for c in integer_part.chars().rev() { 289 | if c == '-' { 290 | continue; 291 | } 292 | 293 | if counter == group_size { 294 | reversed.push(thousands_separator); 295 | if grouping == DigitGrouping::Indian { 296 | group_size = 2; 297 | } 298 | counter = 0; 299 | } 300 | reversed.push(c); 301 | counter += 1; 302 | } 303 | reversed.iter().rev().collect() 304 | } 305 | None => integer_part, 306 | } 307 | } 308 | } 309 | }; 310 | 311 | let amount_str = format!("{}{}", integer_str, decimal_str); 312 | match format.symbol_placement { 313 | CurrencySymbolPlacement::BeforeAmount => { 314 | if amount.is_negative() { 315 | match format.negative_amount_display { 316 | NegativeAmountDisplay::BeforeSymbolAndNumber => { 317 | write!(f, "-{}{}", currency.get_name(), amount_str) 318 | } 319 | NegativeAmountDisplay::BeforeNumberBehindCurrency => { 320 | write!(f, "{}-{}", currency.get_name(), amount_str) 321 | } 322 | NegativeAmountDisplay::AfterNumber => { 323 | write!(f, "{}{}-", currency.get_name(), amount_str) 324 | } 325 | NegativeAmountDisplay::Parentheses => { 326 | write!(f, "({}{})", currency.get_name(), amount_str) 327 | } 328 | } 329 | } else { 330 | write!(f, "{}{}", currency.get_name(), amount_str) 331 | } 332 | } 333 | CurrencySymbolPlacement::AfterAmount => { 334 | if amount.is_negative() { 335 | match format.negative_amount_display { 336 | NegativeAmountDisplay::BeforeSymbolAndNumber 337 | | NegativeAmountDisplay::BeforeNumberBehindCurrency => { 338 | write!(f, "-{} {}", amount_str, currency.get_name()) 339 | } 340 | NegativeAmountDisplay::AfterNumber => { 341 | write!(f, "{}- {}", amount_str, currency.get_name()) 342 | } 343 | NegativeAmountDisplay::Parentheses => { 344 | write!(f, "({} {})", amount_str, currency.get_name()) 345 | } 346 | } 347 | } else { 348 | write!(f, "{} {}", amount_str, currency.get_name()) 349 | } 350 | } 351 | } 352 | } 353 | } 354 | } 355 | } 356 | 357 | #[cfg(test)] 358 | mod tests { 359 | use std::rc::Rc; 360 | 361 | use num::BigRational; 362 | 363 | use crate::models::{Currency, CurrencyDisplayFormat}; 364 | 365 | use super::Money; 366 | 367 | #[test] 368 | fn rounding() { 369 | let one_decimal_format = CurrencyDisplayFormat::from("-1234.5 EUR"); 370 | let no_decimal_format = CurrencyDisplayFormat::from("-1234 EUR"); 371 | let eur = Rc::new(Currency::from("EUR")); 372 | 373 | // Money amount 374 | let m1 = Money::from((eur.clone(), BigRational::from_float(-17.77).unwrap())); 375 | 376 | eur.set_format(&one_decimal_format); 377 | assert_eq!(format!("{}", &m1), "-17.8 EUR"); 378 | 379 | eur.set_format(&no_decimal_format); 380 | assert_eq!(format!("{}", &m1), "-18 EUR"); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /src/models/payee.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{FromDirective, HasAliases, HasName, Origin}; 2 | use regex::Regex; 3 | use std::cell::RefCell; 4 | use std::collections::hash_map::RandomState; 5 | use std::collections::{HashMap, HashSet}; 6 | use std::fmt; 7 | use std::fmt::{Display, Formatter}; 8 | use std::hash::{Hash, Hasher}; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct Payee { 12 | name: String, 13 | note: Option, 14 | alias: HashSet, 15 | alias_regex: Vec, 16 | origin: Origin, 17 | matches: RefCell>, 18 | } 19 | 20 | impl Payee { 21 | pub fn new( 22 | name: String, 23 | note: Option, 24 | alias: HashSet, 25 | alias_regex: Vec, 26 | origin: Origin, 27 | ) -> Payee { 28 | Payee { 29 | name, 30 | note, 31 | alias, 32 | alias_regex, 33 | origin, 34 | matches: RefCell::new(HashMap::new()), 35 | } 36 | } 37 | pub fn is_match(&self, regex: Regex) -> bool { 38 | let mut list = self.matches.borrow_mut(); 39 | match list.get(regex.as_str()) { 40 | Some(x) => *x, 41 | 42 | None => { 43 | let value = regex.is_match(self.get_name()); 44 | list.insert(regex.as_str().to_string(), value); 45 | value 46 | } 47 | } 48 | } 49 | pub fn get_aliases(&self) -> &Vec { 50 | &self.alias_regex 51 | } 52 | } 53 | impl Eq for Payee {} 54 | 55 | impl PartialEq for Payee { 56 | fn eq(&self, other: &Self) -> bool { 57 | self.name == other.name 58 | } 59 | } 60 | 61 | impl Display for Payee { 62 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 63 | write!(f, "{}", self.name) 64 | } 65 | } 66 | 67 | impl HasName for Payee { 68 | fn get_name(&self) -> &str { 69 | self.name.as_str() 70 | } 71 | } 72 | 73 | impl HasAliases for Payee { 74 | fn get_aliases(&self) -> &HashSet { 75 | &self.alias 76 | } 77 | } 78 | 79 | impl FromDirective for Payee { 80 | fn is_from_directive(&self) -> bool { 81 | matches!(self.origin, Origin::FromDirective) 82 | } 83 | } 84 | 85 | impl Hash for Payee { 86 | fn hash(&self, state: &mut H) { 87 | self.name.hash(state); 88 | } 89 | } 90 | 91 | impl From<&str> for Payee { 92 | fn from(name: &str) -> Self { 93 | Payee::new( 94 | String::from(name), 95 | None, 96 | Default::default(), 97 | Default::default(), 98 | Origin::FromTransaction, 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/models/price.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{Currency, HasName, Money}; 2 | use chrono::{Duration, NaiveDate}; 3 | use num::rational::BigRational; 4 | use num::BigInt; 5 | use std::cmp::Ordering; 6 | use std::collections::{HashMap, HashSet}; 7 | use std::fmt; 8 | use std::fmt::{Display, Formatter}; 9 | use std::rc::Rc; 10 | 11 | /// A price relates two commodities 12 | #[derive(Debug, Clone)] 13 | pub struct Price { 14 | date: NaiveDate, 15 | commodity: Rc, 16 | price: Money, 17 | } 18 | 19 | impl Price { 20 | pub fn new(date: NaiveDate, commodity: Rc, price: Money) -> Price { 21 | Price { 22 | date, 23 | commodity, 24 | price, 25 | } 26 | } 27 | pub fn get_price(&self) -> Money { 28 | self.price.clone() 29 | } 30 | } 31 | 32 | impl Display for Price { 33 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 34 | write!(f, "{} {} {}", self.date, self.commodity, self.get_price()) 35 | } 36 | } 37 | 38 | #[derive(Debug, Copy, Clone)] 39 | pub enum PriceType { 40 | Total, 41 | PerUnit, 42 | } 43 | 44 | /// Convert from one currency to every other currency 45 | /// 46 | /// This uses an implementation of the [Dijkstra algorithm](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) to find the shortest path from every 47 | /// currency to the desired one. 48 | /// 49 | /// The return value is a conversion factor to every other currency 50 | pub fn conversion( 51 | currency: Rc, 52 | date: NaiveDate, 53 | prices: &[Price], 54 | ) -> HashMap, BigRational> { 55 | // Build the graph 56 | let source = Node { 57 | currency: currency.clone(), 58 | date, 59 | }; 60 | let mut graph = Graph::from_prices(prices, source); 61 | let mut distances = HashMap::new(); 62 | let mut paths: HashMap, Vec>> = HashMap::new(); 63 | let mut queue = vec![]; 64 | 65 | // Initialize distances 66 | for node in graph.nodes.iter() { 67 | // println!("{} {} ", node.currency.get_name(), node.date); 68 | if node.currency == currency { 69 | distances.insert(node.clone(), Some(date - node.date)); 70 | // distances.insert(node.clone(), Some(date - date)); 71 | // println!("{}", date - node.date); 72 | } else { 73 | distances.insert(node.clone(), None); 74 | // println!("None"); 75 | } 76 | queue.push(node.clone()); 77 | } 78 | while !queue.is_empty() { 79 | // Sort largest to smallest 80 | queue.sort_by(|a, b| cmp(distances.get(b).unwrap(), distances.get(a).unwrap())); 81 | 82 | // Take the closest node 83 | let v = queue.pop().unwrap(); 84 | // This means there is no path to the node 85 | if distances.get(v.as_ref()).unwrap().is_none() { 86 | break; 87 | } 88 | 89 | // The path from the starting currency to the node 90 | let current_path = if let Some(path) = paths.get(v.as_ref()) { 91 | path.clone() 92 | } else { 93 | Vec::new() 94 | }; 95 | 96 | // Update the distances 97 | for (u, e) in graph.get_neighbours(v.as_ref()).iter() { 98 | // println!("Neighbour: {} {}", u.currency.get_name(), u.date); 99 | let alt = distances.get(v.as_ref()).unwrap().unwrap() + e.length(); 100 | let distance = distances.get(u.as_ref()).unwrap(); 101 | let mut update = distance.is_none(); 102 | if !update { 103 | update = alt < distance.unwrap(); 104 | } 105 | if !update { 106 | continue; 107 | } 108 | distances.insert(u.clone(), Some(alt)); 109 | let mut u_path = current_path.clone(); 110 | u_path.push(e.clone()); 111 | paths.insert(u.clone(), u_path); 112 | } 113 | } 114 | 115 | // Return not the paths but the multipliers 116 | let mut multipliers = HashMap::new(); 117 | let mut inserted = HashMap::new(); 118 | for (k, v) in paths.iter() { 119 | let mut mult = BigRational::new(BigInt::from(1), BigInt::from(1)); 120 | let mut currency = k.currency.clone(); 121 | match inserted.get(&k.currency) { 122 | Some(x) => { 123 | if *x > k.date { 124 | continue; 125 | } 126 | } 127 | None => { 128 | inserted.insert(currency.clone(), k.date); 129 | } 130 | } 131 | for edge in v.iter().rev() { 132 | match edge.as_ref().price.as_ref() { 133 | None => (), // do nothing, multiply by one and keep the same currency 134 | Some(price) => { 135 | if currency == edge.from.currency { 136 | mult *= price.get_price().get_amount(); 137 | currency = edge.to.currency.clone(); 138 | } else { 139 | mult /= price.get_price().get_amount(); 140 | currency = edge.from.currency.clone(); 141 | } 142 | } 143 | } 144 | } 145 | multipliers.insert(k.currency.clone(), mult); 146 | } 147 | multipliers.insert( 148 | currency.clone(), 149 | BigRational::new(BigInt::from(1), BigInt::from(1)), 150 | ); 151 | multipliers 152 | } 153 | 154 | #[derive(Debug, Eq, PartialEq, Hash, Clone)] 155 | pub struct Node { 156 | pub(crate) currency: Rc, 157 | date: NaiveDate, 158 | } 159 | 160 | #[derive(Debug, Clone)] 161 | pub struct Edge { 162 | price: Option, 163 | from: Rc, 164 | to: Rc, 165 | } 166 | 167 | impl Edge { 168 | fn length(&self) -> Duration { 169 | if self.from.date > self.to.date { 170 | self.from.date - self.to.date 171 | } else { 172 | self.to.date - self.from.date 173 | } 174 | } 175 | } 176 | 177 | #[derive(Debug, Clone)] 178 | struct NodeEdge { 179 | node: Rc, 180 | edge: Rc, 181 | } 182 | /// A graph 183 | #[derive(Debug, Clone)] 184 | struct Graph { 185 | nodes: Vec>, 186 | edges: Vec>, 187 | } 188 | 189 | impl Graph { 190 | /// Build the graph from prices 191 | /// every price is a potential connection between two currencies, 192 | /// so the prices are actually the edges of the graph 193 | fn from_prices(prices: &[Price], source: Node) -> Self { 194 | let mut nodes = HashMap::new(); 195 | let mut edges = Vec::new(); 196 | 197 | // keep the dates for which there is a price 198 | let mut currency_dates = HashSet::new(); 199 | currency_dates.insert((source.currency.clone(), source.date)); 200 | // Remove redundant prices and create the nodes 201 | let mut prices_nodup = HashMap::new(); 202 | for p in prices.iter() { 203 | // Do not use prices from the future 204 | if p.date >= source.date { 205 | continue; 206 | }; 207 | let commodities = 208 | if p.price.get_commodity().unwrap().get_name() < p.commodity.as_ref().get_name() { 209 | (p.price.get_commodity().unwrap(), p.commodity.clone()) 210 | } else { 211 | (p.commodity.clone(), p.price.get_commodity().unwrap()) 212 | }; 213 | match prices_nodup.get(&commodities) { 214 | None => { 215 | prices_nodup.insert(commodities.clone(), p.clone()); 216 | } 217 | Some(x) => { 218 | if x.date < p.date { 219 | prices_nodup.insert(commodities.clone(), p.clone()); 220 | } 221 | } 222 | } 223 | } 224 | for (_, p) in prices_nodup.iter() { 225 | let commodities = 226 | if p.price.get_commodity().unwrap().get_name() < p.commodity.as_ref().get_name() { 227 | (p.price.get_commodity().unwrap(), p.commodity.clone()) 228 | } else { 229 | (p.commodity.clone(), p.price.get_commodity().unwrap()) 230 | }; 231 | let c_vec = vec![commodities.0.clone(), commodities.1.clone()]; 232 | for c in c_vec { 233 | currency_dates.insert((c.clone(), p.date)); 234 | } 235 | } 236 | // Create the nodes 237 | for (c, d) in currency_dates.iter() { 238 | nodes.insert( 239 | (c.clone(), *d), 240 | Rc::new(Node { 241 | currency: c.clone(), 242 | date: *d, 243 | }), 244 | ); 245 | } 246 | // Edges from the prices 247 | for (_, p) in prices_nodup.iter() { 248 | let from = nodes.get(&(p.commodity.clone(), p.date)).unwrap().clone(); 249 | let to = nodes 250 | .get(&(p.price.get_commodity().unwrap(), p.date)) 251 | .unwrap() 252 | .clone(); 253 | edges.push(Rc::new(Edge { 254 | price: Some(p.clone()), 255 | from: from.clone(), 256 | to: to.clone(), 257 | })); 258 | } 259 | 260 | // println!("Nodes: {}", nodes.len()); 261 | // println!("Edges: {}", edges.len()); 262 | let vec_node: Vec> = nodes.iter().map(|x| x.1.clone()).collect(); 263 | let n = vec_node.len(); 264 | for i in 0..n { 265 | for j in i..n { 266 | if vec_node[i].currency == vec_node[j].currency { 267 | edges.push(Rc::new(Edge { 268 | price: None, 269 | from: vec_node[i].clone(), 270 | to: vec_node[j].clone(), 271 | })) 272 | } 273 | } 274 | } 275 | // println!("Edges: {}", edges.len()); 276 | 277 | Graph { 278 | nodes: nodes.iter().map(|x| x.1.clone()).collect(), 279 | edges, 280 | } 281 | } 282 | 283 | fn get_neighbours(&mut self, node: &Node) -> Vec<(Rc, Rc)> { 284 | let mut neighbours = Vec::new(); 285 | for edge in self.edges.iter() { 286 | if edge.from.as_ref() == node { 287 | neighbours.push((edge.to.clone(), edge.clone())); 288 | } else if edge.to.as_ref() == node { 289 | neighbours.push((edge.from.clone(), edge.clone())); 290 | } 291 | } 292 | neighbours 293 | } 294 | } 295 | 296 | fn cmp(this: &Option, other: &Option) -> Ordering { 297 | match this { 298 | None => match other { 299 | None => Ordering::Equal, 300 | Some(_) => Ordering::Greater, 301 | }, 302 | Some(s) => match other { 303 | None => Ordering::Less, 304 | Some(o) => s.cmp(o), 305 | }, 306 | } 307 | } 308 | 309 | #[cfg(test)] 310 | mod tests { 311 | use super::*; 312 | use crate::parser::Tokenizer; 313 | use crate::CommonOpts; 314 | use chrono::Utc; 315 | use std::convert::TryFrom; 316 | use std::path::PathBuf; 317 | use structopt::StructOpt; 318 | 319 | #[test] 320 | fn test_graph() { 321 | // Copy from balance command 322 | let path = PathBuf::from("tests/example_files/demo.ledger"); 323 | let mut tokenizer = Tokenizer::try_from(&path).unwrap(); 324 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 325 | let items = tokenizer.tokenize(&options); 326 | let ledger = items.to_ledger(&options).unwrap(); 327 | 328 | let currency = ledger.commodities.get("EUR").unwrap(); 329 | let multipliers = conversion( 330 | currency.clone(), 331 | Utc::now().naive_local().date(), 332 | &ledger.prices, 333 | ); 334 | assert_eq!(multipliers.len(), 8); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | //! Parser module 2 | //! 3 | //! The parser takes an input string (or file) and translates it into tokens with a tokenizer 4 | //! These tokens are one of: 5 | //! - Directive account 6 | //! - Directive payee 7 | //! - Directive commodity 8 | 9 | use std::collections::HashSet; 10 | use std::convert::TryFrom; 11 | use std::fs::read_to_string; 12 | use std::path::PathBuf; 13 | 14 | use crate::error::MissingFileError; 15 | use crate::models::{Account, Comment, Currency, HasName, Payee, Transaction}; 16 | use crate::parser::utils::count_decimals; 17 | use crate::{models, CommonOpts, List}; 18 | use pest::Parser; 19 | 20 | mod include; 21 | // pub mod tokenizers; 22 | pub mod tokenizers; 23 | pub(crate) mod utils; 24 | pub mod value_expr; 25 | 26 | use tokenizers::transaction; 27 | 28 | #[derive(Parser)] 29 | #[grammar = "grammar/grammar.pest"] 30 | pub struct GrammarParser; 31 | 32 | #[derive(Debug, Clone)] 33 | pub struct ParsedLedger { 34 | pub accounts: List, 35 | pub payees: List, 36 | pub commodities: List, 37 | pub transactions: Vec>, 38 | pub prices: Vec, 39 | pub comments: Vec, 40 | pub tags: Vec, 41 | pub files: Vec, 42 | } 43 | 44 | impl Default for ParsedLedger { 45 | fn default() -> Self { 46 | ParsedLedger::new() 47 | } 48 | } 49 | impl ParsedLedger { 50 | pub fn new() -> Self { 51 | ParsedLedger { 52 | accounts: List::::new(), 53 | payees: List::::new(), 54 | commodities: List::::new(), 55 | transactions: vec![], 56 | prices: vec![], 57 | comments: vec![], 58 | tags: vec![], 59 | files: vec![], 60 | } 61 | } 62 | pub fn append(&mut self, other: &mut ParsedLedger) { 63 | self.accounts.append(&other.accounts); 64 | self.payees.append(&other.payees); 65 | self.commodities.append(&other.commodities); 66 | self.transactions.append(&mut other.transactions); 67 | self.comments.append(&mut other.comments); 68 | self.transactions.append(&mut other.transactions); 69 | self.prices.append(&mut other.prices); 70 | self.files.append(&mut other.files); 71 | } 72 | 73 | pub fn len(&self) -> usize { 74 | self.accounts.len() 75 | + self.payees.len() 76 | + self.commodities.len() 77 | + self.transactions.len() 78 | + self.prices.len() 79 | + self.comments.len() 80 | + self.tags.len() 81 | } 82 | pub fn is_empty(&self) -> bool { 83 | self.len() == 0 84 | } 85 | } 86 | 87 | /// A struct for holding data about the string being parsed 88 | #[derive(Debug, Clone)] 89 | pub struct Tokenizer<'a> { 90 | file: Option<&'a PathBuf>, 91 | content: String, 92 | seen_files: HashSet<&'a PathBuf>, 93 | } 94 | 95 | impl<'a> TryFrom<&'a PathBuf> for Tokenizer<'a> { 96 | type Error = Box; 97 | fn try_from(file: &'a PathBuf) -> Result { 98 | match read_to_string(file) { 99 | Ok(content) => { 100 | let mut seen_files: HashSet<&PathBuf> = HashSet::new(); 101 | seen_files.insert(file); 102 | Ok(Tokenizer { 103 | file: Some(file), 104 | content, 105 | seen_files, 106 | }) 107 | } 108 | Err(err) => match err.kind() { 109 | std::io::ErrorKind::NotFound => Err(Box::new( 110 | MissingFileError::JournalFileDoesNotExistError(file.to_path_buf()), 111 | )), 112 | _ => Err(Box::new(err)), 113 | }, 114 | } 115 | } 116 | } 117 | 118 | impl<'a> From for Tokenizer<'a> { 119 | fn from(content: String) -> Self { 120 | Tokenizer { 121 | file: None, 122 | content, 123 | seen_files: HashSet::new(), 124 | } 125 | } 126 | } 127 | 128 | impl<'a> Tokenizer<'a> { 129 | pub fn tokenize(&'a mut self, options: &CommonOpts) -> ParsedLedger { 130 | self.tokenize_with_currencies(options, None) 131 | } 132 | /// Parses a string into a parsed ledger. It allows for recursion, 133 | /// i.e. the include keyword is properly handled 134 | pub fn tokenize_with_currencies( 135 | &'a mut self, 136 | options: &CommonOpts, 137 | defined_currencies: Option<&List>, 138 | ) -> ParsedLedger { 139 | let mut ledger: ParsedLedger = ParsedLedger::new(); 140 | if let Some(x) = defined_currencies { 141 | ledger.commodities.append(x); 142 | } 143 | if let Some(file) = self.file { 144 | ledger.files.push(file.clone()); 145 | } 146 | match GrammarParser::parse(Rule::journal, self.content.as_str()) { 147 | Ok(mut parsed) => { 148 | let elements = parsed.next().unwrap().into_inner(); 149 | for element in elements { 150 | match element.as_rule() { 151 | Rule::directive => { 152 | let inner = element.into_inner().next().unwrap(); 153 | match inner.as_rule() { 154 | Rule::include => { 155 | // This is the special case 156 | let mut new_ledger = 157 | self.include(inner, options, &ledger.commodities).unwrap(); 158 | ledger.append(&mut new_ledger); 159 | } 160 | Rule::price => { 161 | ledger.prices.push(self.parse_price(inner)); 162 | } 163 | Rule::tag_dir => { 164 | ledger.tags.push(self.parse_tag(inner)); 165 | } 166 | Rule::commodity => { 167 | let commodity = self.parse_commodity(inner); 168 | if let Ok(old_commodity) = 169 | ledger.commodities.get(commodity.get_name()) 170 | { 171 | commodity.update_precision(old_commodity.get_precision()); 172 | } 173 | ledger.commodities.remove(&commodity); 174 | ledger.commodities.insert(commodity); 175 | } 176 | Rule::account_dir => { 177 | ledger.accounts.insert(self.parse_account(inner)); 178 | } 179 | Rule::payee_dir => { 180 | ledger.payees.insert(self.parse_payee(inner)); 181 | } 182 | _ => {} 183 | } 184 | } 185 | Rule::transaction | Rule::automated_transaction => { 186 | let transaction = self.parse_transaction(element); 187 | for posting in transaction.postings.borrow().iter() { 188 | let currencies = &[ 189 | (&posting.money_currency, &posting.money_format), 190 | (&posting.cost_currency, &posting.cost_format), 191 | (&posting.balance_currency, &posting.balance_format), 192 | ]; 193 | for (currency, format) in currencies { 194 | if let Some(c) = currency { 195 | match ledger.commodities.get(c) { 196 | Err(_) => { 197 | if options.pedantic { 198 | panic!("Error: commodity {} not declared.", c); 199 | } 200 | if options.strict { 201 | eprintln!( 202 | "Warning: commodity {} not declared.", 203 | c 204 | ); 205 | } 206 | 207 | let commodity = Currency::from(c.as_str()); 208 | if let Some(format_string) = format { 209 | commodity.update_precision(count_decimals( 210 | format_string.as_str(), 211 | )); 212 | } 213 | ledger.commodities.insert(commodity); 214 | } 215 | Ok(c) => { 216 | if let Some(format_string) = format { 217 | c.update_precision(count_decimals( 218 | format_string.as_str(), 219 | )); 220 | } 221 | } 222 | } 223 | } 224 | } 225 | } 226 | ledger.transactions.push(transaction); 227 | } 228 | _x => { 229 | // eprintln!("{:?}", x); 230 | } 231 | } 232 | } 233 | } 234 | Err(e) => { 235 | if let Some(file) = &self.file { 236 | eprintln!("Can't parse {:?} {}", file, e); 237 | } 238 | eprintln!("Error found in line {}", e) 239 | } 240 | } 241 | 242 | ledger 243 | } 244 | } 245 | #[cfg(test)] 246 | mod tests { 247 | use structopt::StructOpt; 248 | 249 | use super::*; 250 | 251 | #[test] 252 | fn test_empty_string() { 253 | let content = "".to_string(); 254 | let mut tokenizer = Tokenizer::from(content); 255 | let items = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 256 | assert_eq!(items.len(), 0, "Should be empty"); 257 | } 258 | 259 | #[test] 260 | fn test_only_spaces() { 261 | let content = "\n\n\n\n\n".to_string(); 262 | let mut tokenizer = Tokenizer::from(content); 263 | let items = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 264 | assert_eq!(items.len(), 0, "Should be empty") 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/parser/include.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | models::Currency, 3 | parser::{ParsedLedger, Rule, Tokenizer}, 4 | CommonOpts, List, 5 | }; 6 | use glob::glob; 7 | use pest::iterators::Pair; 8 | 9 | use std::{convert::TryFrom, path::PathBuf}; 10 | 11 | impl<'a> Tokenizer<'a> { 12 | /// Handles include directive 13 | /// 14 | /// Add the found file of files it it has wildcards in the pattern to the queue of files to process and process them. 15 | /// TODO this is a good place to include parallelism 16 | pub fn include( 17 | &self, 18 | element: Pair, 19 | options: &CommonOpts, 20 | commodities: &List, 21 | ) -> Result> { 22 | let mut pattern = String::new(); 23 | let mut files: Vec = Vec::new(); 24 | if let Some(current_path) = self.file { 25 | let mut parent = current_path.parent().unwrap().to_str().unwrap().to_string(); 26 | if parent.is_empty() { 27 | parent.push('.') 28 | } 29 | parent.push('/'); 30 | pattern.push_str(parent.as_str()); 31 | } 32 | let parsed_glob = element.into_inner().next().unwrap().as_str(); 33 | pattern.push_str(parsed_glob); 34 | for entry in glob(&pattern).expect("Failed to read glob pattern") { 35 | match entry { 36 | Ok(path) => { 37 | files.push(path.clone()); 38 | if self.seen_files.get(&path).is_some() { 39 | panic!("Cycle detected. {:?}", &path); 40 | } 41 | } 42 | Err(e) => eprintln!("{:?}", e), 43 | } 44 | } 45 | let mut items: ParsedLedger = ParsedLedger::new(); 46 | for file in files { 47 | let mut inner_tokenizer: Tokenizer = Tokenizer::try_from(&file)?; 48 | for p in self.seen_files.iter() { 49 | inner_tokenizer.seen_files.insert(*p); 50 | } 51 | let mut new_items: ParsedLedger = 52 | inner_tokenizer.tokenize_with_currencies(options, Some(commodities)); 53 | items.append(&mut new_items); 54 | } 55 | Ok(items) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/parser/tokenizers/account.rs: -------------------------------------------------------------------------------- 1 | use super::super::Rule; 2 | use crate::parser::Tokenizer; 3 | use crate::{models::Account, parser::utils::parse_string}; 4 | 5 | use pest::iterators::Pair; 6 | use regex::Regex; 7 | 8 | impl<'a> Tokenizer<'a> { 9 | pub(crate) fn parse_account(&self, element: Pair) -> Account { 10 | let mut parsed = element.into_inner(); 11 | let name = parse_string(parsed.next().unwrap()); 12 | 13 | let mut account = Account::from_directive(name); 14 | 15 | for part in parsed { 16 | match part.as_rule() { 17 | Rule::account_property => { 18 | let mut property = part.into_inner(); 19 | match property.next().unwrap().as_rule() { 20 | Rule::alias => { 21 | account 22 | .aliases 23 | .insert(parse_string(property.next().unwrap())); 24 | } 25 | Rule::note => account.note = Some(parse_string(property.next().unwrap())), 26 | Rule::iban => account.iban = Some(parse_string(property.next().unwrap())), 27 | Rule::country => { 28 | account.country = Some(parse_string(property.next().unwrap())) 29 | } 30 | Rule::assert => account.assert.push(parse_string(property.next().unwrap())), 31 | Rule::check => account.check.push(parse_string(property.next().unwrap())), 32 | Rule::payee_subdirective => account.payee.push( 33 | Regex::new(parse_string(property.next().unwrap()).trim()).unwrap(), 34 | ), 35 | _x => {} 36 | } 37 | } 38 | Rule::flag => account.default = true, 39 | _x => {} 40 | } 41 | } 42 | account 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use structopt::StructOpt; 49 | 50 | use super::*; 51 | use crate::models::HasName; 52 | use crate::CommonOpts; 53 | 54 | #[test] 55 | fn test_spaces_in_account_names() { 56 | let mut tokenizer = Tokenizer::from("account An account name with spaces ".to_string()); 57 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 58 | let items = tokenizer.tokenize(&options); 59 | let account = items 60 | .accounts 61 | .get("An account name with spaces") 62 | .unwrap() 63 | .as_ref(); 64 | assert_eq!(account.get_name(), "An account name with spaces"); 65 | assert_eq!(items.accounts.len(), 1); 66 | } 67 | 68 | #[test] 69 | fn test_parse_account() { 70 | let mut tokenizer = Tokenizer::from( 71 | "account Assets:Checking account 72 | alias checking 73 | note An account for everyday expenses 74 | ; this line will be ignored 75 | alias checking account 76 | iban 123456789 77 | payee Employer 78 | " 79 | .to_string(), 80 | ); 81 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 82 | let items = tokenizer.tokenize(&options); 83 | let account = items 84 | .accounts 85 | .get("Assets:Checking account") 86 | .unwrap() 87 | .as_ref(); 88 | assert!(!account.is_default(), "Not a default account"); 89 | assert_eq!(account.get_name(), "Assets:Checking account"); 90 | } 91 | 92 | #[test] 93 | fn get_account_from_alias() { 94 | let mut tokenizer = Tokenizer::from( 95 | "account Assets:MyAccount 96 | alias myAccount 97 | check commodity == \"$\" 98 | assert commodity == \"$\" 99 | default 100 | " 101 | .to_string(), 102 | ); 103 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 104 | let items = tokenizer.tokenize(&options); 105 | let account = items.accounts.get("myAccount").unwrap().as_ref(); 106 | assert!(account.is_default(), "A default account"); 107 | assert_eq!(account.get_name(), "Assets:MyAccount"); 108 | assert!(!account.check.is_empty(), "It has a check"); 109 | assert!(!account.assert.is_empty(), "It has an assert"); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/parser/tokenizers/commodity.rs: -------------------------------------------------------------------------------- 1 | use super::super::Rule; 2 | use std::collections::HashSet; 3 | 4 | use crate::models::{Comment, Currency, CurrencyDisplayFormat}; 5 | use crate::parser::utils::parse_string; 6 | use crate::parser::Tokenizer; 7 | 8 | use pest::iterators::Pair; 9 | 10 | impl<'a> Tokenizer<'a> { 11 | pub(crate) fn parse_commodity(&self, element: Pair) -> Currency { 12 | let mut parsed = element.into_inner(); 13 | let name = parse_string(parsed.next().unwrap()); 14 | let mut note: Option = None; 15 | let mut format: Option = None; 16 | let mut comments: Vec = vec![]; 17 | let mut default = false; 18 | let mut aliases = HashSet::new(); 19 | 20 | for part in parsed { 21 | match part.as_rule() { 22 | Rule::comment => comments.push(Comment::from(parse_string( 23 | part.into_inner().next().unwrap(), 24 | ))), 25 | Rule::commodity_property => { 26 | let mut property = part.into_inner(); 27 | match property.next().unwrap().as_rule() { 28 | Rule::alias => { 29 | aliases.insert(parse_string(property.next().unwrap())); 30 | } 31 | Rule::note => note = Some(parse_string(property.next().unwrap())), 32 | Rule::format => format = Some(parse_string(property.next().unwrap())), 33 | _ => {} 34 | } 35 | } 36 | Rule::flag => default = true, 37 | Rule::end => {} 38 | Rule::blank_line => {} 39 | x => panic!("{:?} not expected", x), 40 | } 41 | } 42 | 43 | let mut currency = Currency::from_directive(name.trim().to_string()); 44 | currency.set_aliases(aliases); 45 | if default { 46 | currency.set_default(); 47 | } 48 | if let Some(n) = note { 49 | currency.set_note(n); 50 | } 51 | if let Some(f) = format { 52 | currency.format = Some(f.clone()); 53 | currency.set_format(&CurrencyDisplayFormat::from(f.as_str())); 54 | } 55 | 56 | currency 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/parser/tokenizers/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod account; 2 | pub(crate) mod commodity; 3 | pub(crate) mod payee; 4 | pub(crate) mod price; 5 | pub(crate) mod tag; 6 | pub(crate) mod transaction; 7 | -------------------------------------------------------------------------------- /src/parser/tokenizers/payee.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use regex::Regex; 4 | 5 | use crate::models::{Origin, Payee}; 6 | use crate::parser::Tokenizer; 7 | 8 | use super::super::Rule; 9 | 10 | use crate::parser::utils::parse_string; 11 | 12 | use pest::iterators::Pair; 13 | 14 | impl<'a> Tokenizer<'a> { 15 | pub(crate) fn parse_payee(&self, element: Pair) -> Payee { 16 | let mut parsed = element.into_inner(); 17 | let name = parse_string(parsed.next().unwrap()); 18 | let mut note: Option = None; 19 | let mut alias = HashSet::new(); 20 | 21 | for part in parsed { 22 | match part.as_rule() { 23 | Rule::comment => {} 24 | Rule::payee_property => { 25 | let mut property = part.into_inner(); 26 | match property.next().unwrap().as_rule() { 27 | Rule::alias => { 28 | alias.insert(parse_string(property.next().unwrap())); 29 | } 30 | Rule::note => note = Some(parse_string(property.next().unwrap())), 31 | _ => {} 32 | } 33 | } 34 | _ => {} 35 | } 36 | } 37 | 38 | let alias_regex: Vec = alias 39 | .iter() 40 | .map(|x| Regex::new(x.clone().as_str()).unwrap()) 41 | .collect(); 42 | Payee::new(name, note, alias, alias_regex, Origin::FromDirective) 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use structopt::StructOpt; 49 | 50 | use super::*; 51 | use crate::{models::HasName, CommonOpts}; 52 | 53 | #[test] 54 | //#[should_panic(expected = "Can't compare different currencies. € and USD.")] 55 | fn parse_ko() { 56 | let input = "payee ACME ; From the Looney Tunes\n\tWrong Acme, Inc.\n".to_string(); 57 | let mut tokenizer = Tokenizer::from(input); 58 | let items = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 59 | assert_eq!(items.payees.len(), 0); 60 | } 61 | 62 | #[test] 63 | fn parse_ok() { 64 | let input = "payee ACME\n\talias Acme, Inc.\n".to_string(); 65 | let mut tokenizer = Tokenizer::from(input); 66 | 67 | let items = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 68 | assert_eq!(items.payees.len(), 1); 69 | 70 | assert!(items.payees.get("acme, inc.").is_ok()); 71 | let payee = &items.payees.get("acme").unwrap(); 72 | assert_eq!(payee.get_name(), "ACME"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/parser/tokenizers/price.rs: -------------------------------------------------------------------------------- 1 | use super::super::Rule; 2 | use crate::models::ParsedPrice; 3 | use crate::parser::utils::{parse_date, parse_rational, parse_string}; 4 | use crate::parser::Tokenizer; 5 | 6 | use pest::iterators::Pair; 7 | 8 | impl<'a> Tokenizer<'a> { 9 | pub(crate) fn parse_price(&self, element: Pair) -> ParsedPrice { 10 | let mut parsed = element.into_inner(); 11 | let date = parse_date(parsed.next().unwrap()); 12 | let commodity = { 13 | let time_or_commodity = parsed.next().unwrap(); 14 | match time_or_commodity.as_rule() { 15 | Rule::time => parse_string(parsed.next().unwrap()), 16 | _ => parse_string(time_or_commodity), 17 | } 18 | }; 19 | let amount = parse_rational(parsed.next().unwrap()); 20 | let other_commodity = parse_string(parsed.next().unwrap()); 21 | 22 | ParsedPrice { 23 | date, 24 | commodity, 25 | other_commodity, 26 | other_quantity: amount, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/parser/tokenizers/tag.rs: -------------------------------------------------------------------------------- 1 | use crate::models::Tag; 2 | 3 | use crate::parser::Tokenizer; 4 | 5 | use super::super::Rule; 6 | 7 | use crate::parser::utils::parse_string; 8 | 9 | use pest::iterators::Pair; 10 | 11 | impl<'a> Tokenizer<'a> { 12 | pub(crate) fn parse_tag(&self, element: Pair) -> Tag { 13 | let mut parsed = element.into_inner(); 14 | let name = parse_string(parsed.next().unwrap()); 15 | 16 | let mut check = vec![]; 17 | let mut assert = vec![]; 18 | 19 | for part in parsed { 20 | match part.as_rule() { 21 | Rule::commodity_property => { 22 | let mut property = part.into_inner(); 23 | match property.next().unwrap().as_rule() { 24 | Rule::check => { 25 | check.push(parse_string(property.next().unwrap())); 26 | } 27 | Rule::assert => assert.push(parse_string(property.next().unwrap())), 28 | _ => {} 29 | } 30 | } 31 | _x => {} 32 | } 33 | } 34 | Tag { 35 | name, 36 | check, 37 | assert, 38 | value: None, 39 | } 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use structopt::StructOpt; 46 | 47 | use super::*; 48 | use crate::{models::HasName, CommonOpts}; 49 | 50 | #[test] 51 | fn test_spaces_in_tag_names() { 52 | let mut tokenizer = Tokenizer::from("tag A tag name with spaces ".to_string()); 53 | let items = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 54 | let tag = &items.tags[0]; 55 | assert_eq!(tag.get_name(), "A tag name with spaces"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/parser/tokenizers/transaction.rs: -------------------------------------------------------------------------------- 1 | use super::super::Rule; 2 | use crate::models::{Cleared, Comment, PostingType, PriceType, Transaction, TransactionType}; 3 | use crate::parser::utils::{parse_date, parse_rational, parse_string}; 4 | use crate::parser::Tokenizer; 5 | use chrono::NaiveDate; 6 | use num::{rational::BigRational, BigInt}; 7 | use pest::iterators::Pair; 8 | 9 | impl<'a> Tokenizer<'a> { 10 | /// Parses a transaction 11 | pub(crate) fn parse_transaction(&self, element: Pair) -> Transaction { 12 | let mut transaction = Transaction::::new(match element.as_rule() { 13 | Rule::transaction => TransactionType::Real, 14 | Rule::automated_transaction => TransactionType::Automated, 15 | x => panic!("{:?}", x), 16 | }); 17 | 18 | let mut parsed_transaction = element.into_inner(); 19 | 20 | // 21 | // Parse the transaction head 22 | // 23 | let head = parsed_transaction.next().unwrap().into_inner(); 24 | 25 | for part in head { 26 | match part.as_rule() { 27 | Rule::transaction_date => { 28 | transaction.date = Some(parse_date(part.into_inner().next().unwrap())); 29 | } 30 | Rule::effective_date => { 31 | transaction.effective_date = 32 | Some(parse_date(part.into_inner().next().unwrap())); 33 | } 34 | Rule::status => { 35 | transaction.cleared = match part.as_str() { 36 | "!" => Cleared::NotCleared, 37 | "*" => Cleared::Cleared, 38 | x => panic!("Found '{}', expected '!' or '*'", x), 39 | } 40 | } 41 | Rule::code => { 42 | let mut code = part.as_str().chars(); 43 | code.next(); 44 | code.next_back(); 45 | transaction.code = Some(code.as_str().trim().to_string()) 46 | } 47 | Rule::description | Rule::automated_description => { 48 | transaction.description = parse_string(part).trim().to_string(); 49 | } 50 | Rule::payee => { 51 | transaction.payee = Some(parse_string(part).trim().to_string()); 52 | } 53 | Rule::comment => { 54 | // it can only be a comment 55 | transaction.comments.push(Comment::from(parse_string( 56 | part.into_inner().next().unwrap(), 57 | ))); 58 | } 59 | x => panic!("Expected amount, cost or balance {:?}", x), 60 | } 61 | } 62 | 63 | // Adjust the payee 64 | if transaction.payee.is_none() { 65 | if !transaction.description.is_empty() { 66 | transaction.payee = Some(transaction.description.clone()); 67 | } else { 68 | transaction.payee = Some("[Unspecified payee]".to_string()) 69 | } 70 | } 71 | 72 | // 73 | // Go for the indented part 74 | // 75 | for part in parsed_transaction { 76 | match part.as_rule() { 77 | Rule::posting | Rule::automated_posting => transaction 78 | .postings 79 | .borrow_mut() 80 | .push(parse_posting(part, &transaction.payee, &transaction.date)), 81 | Rule::comment => transaction.comments.push(Comment::from(parse_string( 82 | part.into_inner().next().unwrap(), 83 | ))), 84 | Rule::blank_line => {} 85 | x => panic!("{:?}", x), 86 | } 87 | } 88 | // dbg!(&transaction); 89 | transaction 90 | } 91 | } 92 | 93 | #[derive(Debug, Clone)] 94 | pub struct RawPosting { 95 | pub account: String, 96 | pub date: Option, 97 | pub money_amount: Option, 98 | pub money_currency: Option, 99 | pub money_format: Option, 100 | pub cost_amount: Option, 101 | pub cost_currency: Option, 102 | pub cost_format: Option, 103 | pub cost_type: Option, 104 | pub balance_amount: Option, 105 | pub balance_currency: Option, 106 | pub balance_format: Option, 107 | pub comments: Vec, 108 | pub amount_expr: Option, 109 | pub kind: PostingType, 110 | pub payee: Option, 111 | } 112 | 113 | impl RawPosting { 114 | fn new() -> RawPosting { 115 | RawPosting { 116 | account: String::new(), 117 | date: None, 118 | money_amount: None, 119 | money_currency: None, 120 | cost_amount: None, 121 | cost_currency: None, 122 | cost_type: None, 123 | balance_amount: None, 124 | balance_currency: None, 125 | comments: vec![], 126 | amount_expr: None, 127 | kind: PostingType::Real, 128 | payee: None, 129 | money_format: None, 130 | cost_format: None, 131 | balance_format: None, 132 | } 133 | } 134 | } 135 | 136 | /// Parses a posting 137 | fn parse_posting( 138 | raw: Pair, 139 | default_payee: &Option, 140 | default_date: &Option, 141 | ) -> RawPosting { 142 | let mut posting = RawPosting::new(); 143 | let elements = raw.into_inner(); 144 | for part in elements { 145 | let rule = part.as_rule(); 146 | match rule { 147 | Rule::posting_kind => { 148 | let kind = part.into_inner().next().unwrap(); 149 | posting.kind = match kind.as_rule() { 150 | Rule::virtual_no_balance => PostingType::Virtual, 151 | Rule::virtual_balance => PostingType::VirtualMustBalance, 152 | _ => PostingType::Real, 153 | }; 154 | posting.account = kind.into_inner().next().unwrap().as_str().to_string(); 155 | } 156 | Rule::amount | Rule::cost | Rule::balance => { 157 | let cost_type = if part.as_str().starts_with("@@") { 158 | Some(PriceType::Total) 159 | } else { 160 | Some(PriceType::PerUnit) 161 | }; 162 | let mut inner = part.into_inner(); 163 | let negative = inner.as_str().starts_with('-'); 164 | let mut money = inner.next().unwrap().into_inner(); 165 | let money_format = money.as_str().to_string(); 166 | let amount: BigRational; 167 | let mut currency = None; 168 | match money.next() { 169 | Some(money_part) => match money_part.as_rule() { 170 | Rule::number => { 171 | amount = parse_rational(money_part); 172 | currency = Some(parse_string(money.next().unwrap())); 173 | } 174 | Rule::currency => { 175 | currency = Some(parse_string(money_part)); 176 | if negative { 177 | amount = -parse_rational(money.next().unwrap()); 178 | } else { 179 | amount = parse_rational(money.next().unwrap()); 180 | } 181 | } 182 | _ => amount = BigRational::new(BigInt::from(0), BigInt::from(1)), 183 | }, 184 | None => amount = BigRational::new(BigInt::from(0), BigInt::from(1)), 185 | } 186 | 187 | match rule { 188 | Rule::amount => { 189 | posting.money_amount = Some(amount); 190 | posting.money_currency = currency; 191 | posting.money_format = Some(money_format); 192 | } 193 | Rule::cost => { 194 | posting.cost_amount = Some(amount); 195 | posting.cost_currency = currency; 196 | posting.cost_type = cost_type; 197 | posting.cost_format = Some(money_format); 198 | } 199 | Rule::balance => { 200 | posting.balance_amount = Some(amount); 201 | posting.balance_currency = currency; 202 | posting.balance_format = Some(money_format); 203 | } 204 | x => panic!("Expected amount, cost or balance {:?}", x), 205 | } 206 | } 207 | Rule::number => posting.amount_expr = Some(format!("({})", part.as_str())), 208 | Rule::value_expr => posting.amount_expr = Some(part.as_str().to_string()), 209 | Rule::comment => posting.comments.push(Comment::from(parse_string( 210 | part.into_inner().next().unwrap(), 211 | ))), 212 | Rule::blank_line => {} 213 | Rule::EOI => {} 214 | x => panic!("{:?}", x), 215 | } 216 | } 217 | for c in posting.comments.iter() { 218 | if let Some(payee) = c.get_payee_str() { 219 | posting.payee = Some(payee); 220 | } 221 | if let Some(date) = c.get_date() { 222 | posting.date = Some(date); 223 | } 224 | } 225 | if posting.payee.is_none() { 226 | posting.payee = default_payee.clone(); 227 | } 228 | if posting.date.is_none() { 229 | posting.date = *default_date; 230 | } 231 | posting 232 | } 233 | #[cfg(test)] 234 | mod tests { 235 | use structopt::StructOpt; 236 | 237 | use super::*; 238 | use crate::models::{Cleared, TransactionStatus}; 239 | use crate::{parser::Tokenizer, CommonOpts}; 240 | 241 | #[test] 242 | 243 | fn difficult_transaction_head() { 244 | let mut tokenizer = Tokenizer::from( 245 | "2022-05-13 ! (8760) Intereses | EstateGuru 246 | ; a transaction comment 247 | EstateGuru 1.06 EUR 248 | ; a posting comment 249 | Ingresos:Rendimientos 250 | " 251 | .to_string(), 252 | ); 253 | 254 | let parsed = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 255 | let transaction = &parsed.transactions[0]; 256 | assert_eq!(transaction.cleared, Cleared::NotCleared); 257 | assert_eq!(transaction.status, TransactionStatus::NotChecked); 258 | assert_eq!(transaction.code, Some(String::from("8760"))); 259 | assert_eq!(transaction.payee, Some(String::from("EstateGuru"))); 260 | assert_eq!(transaction.description, String::from("Intereses")); 261 | for p in transaction.postings.borrow().iter() { 262 | assert_eq!(p.kind, PostingType::Real); 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/parser/utils.rs: -------------------------------------------------------------------------------- 1 | //! This module contains auxiliary parsers 2 | 3 | use super::{GrammarParser, Rule}; 4 | use chrono::NaiveDate; 5 | use num::{BigInt, BigRational}; 6 | use pest::iterators::Pair; 7 | 8 | use pest::Parser; 9 | use std::str::FromStr; 10 | use std::usize; 11 | 12 | pub(crate) fn parse_str_as_date(date: &str) -> NaiveDate { 13 | parse_date( 14 | GrammarParser::parse(Rule::date, date) 15 | .unwrap() 16 | .next() 17 | .unwrap(), 18 | ) 19 | } 20 | 21 | /// Parses a date 22 | pub(crate) fn parse_date(date: Pair) -> NaiveDate { 23 | // Assume date is a Rule::date 24 | let mut parsed = date.into_inner(); 25 | let year = i32::from_str(parsed.next().unwrap().as_str()).unwrap(); 26 | let sep1 = parsed.next().unwrap().as_str(); 27 | let month = u32::from_str(parsed.next().unwrap().as_str()).unwrap(); 28 | let sep2 = parsed.next().unwrap().as_str(); 29 | let day = u32::from_str(parsed.next().unwrap().as_str()).unwrap(); 30 | 31 | assert_eq!(sep1, sep2, "wrong date separator"); 32 | NaiveDate::from_ymd(year, month, day) 33 | } 34 | 35 | pub(crate) fn parse_rational(number: Pair) -> BigRational { 36 | let mut num = String::new(); 37 | let mut den = "1".to_string(); 38 | let mut decimal = false; 39 | for c in number.as_str().chars() { 40 | if c == '.' { 41 | decimal = true 42 | } else { 43 | num.push(c); 44 | if decimal { 45 | den.push('0') 46 | }; 47 | } 48 | } 49 | BigRational::new( 50 | BigInt::from_str(num.as_str()).unwrap(), 51 | BigInt::from_str(den.as_str()).unwrap(), 52 | ) 53 | } 54 | 55 | pub(crate) fn parse_string(string: Pair) -> String { 56 | match string.as_rule() { 57 | Rule::string => { 58 | let quoted = string.as_str(); 59 | let len = quoted.len(); 60 | quoted[1..len - 1].to_string() 61 | } 62 | Rule::unquoted => string.as_str().to_string(), 63 | Rule::commodity_spec => { 64 | let as_str = string.as_str(); 65 | match string.into_inner().next() { 66 | Some(x) => parse_string(x), 67 | None => as_str.trim().to_string(), 68 | } 69 | } 70 | Rule::currency | Rule::commodity_in_directive => { 71 | parse_string(string.into_inner().next().unwrap()) 72 | } 73 | _ => string.as_str().trim().to_string(), 74 | } 75 | } 76 | 77 | /// Counts the number of decimals in an amount as defined in the grammar 78 | pub(crate) fn count_decimals(amount: &str) -> usize { 79 | let mut parsed = GrammarParser::parse(Rule::money, amount) 80 | .unwrap() 81 | .next() 82 | .unwrap() 83 | .into_inner(); 84 | let number = parsed.next().unwrap(); 85 | 86 | let number = match number.as_rule() { 87 | Rule::number => number, 88 | //Rule::currency is the only other option 89 | _ => parsed.next().unwrap(), 90 | }; 91 | 92 | assert_eq!(number.as_rule(), Rule::number); 93 | 94 | let text = number.as_str(); 95 | // dbg!(text); 96 | if text.contains('.') { 97 | number.as_str().split('.').last().unwrap().len() 98 | } else { 99 | 0 100 | } 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use crate::parser::utils::count_decimals; 106 | 107 | #[test] 108 | fn count_decimals_test() { 109 | assert_eq!(count_decimals("150.4 EUR"), 1); 110 | assert_eq!(count_decimals("150 EUR"), 0); 111 | assert_eq!(count_decimals("EUR 150.4"), 1); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/common.rs: -------------------------------------------------------------------------------- 1 | use dinero::run_app; 2 | 3 | pub fn test_args(args: &[&str]) { 4 | let mut function_args: Vec<&str> = vec!["testing"]; 5 | for arg in args { 6 | function_args.push(arg); 7 | } 8 | let res = run_app(function_args.iter().map(|x| x.to_string()).collect()); 9 | assert!(res.is_ok()); 10 | } 11 | pub fn test_err(args: &[&str]) { 12 | let mut function_args: Vec<&str> = vec!["testing"]; 13 | for arg in args { 14 | function_args.push(arg); 15 | } 16 | let res = run_app(function_args.iter().map(|x| x.to_string()).collect()); 17 | assert!(res.is_err()); 18 | } 19 | -------------------------------------------------------------------------------- /tests/example_files/automated.ledger: -------------------------------------------------------------------------------- 1 | = Income:Salary 2 | (Savings) 600 EUR 3 | (Free spending) (abs(amount) - EUR 600) 4 | = Savings 5 | (Savings:Risky investments) (amount * 0.10) 6 | (Savings:Deposits) 0.40 7 | (Savings:Shares) (amount / 2) 8 | = Expenses:Rent 9 | Expenses:Rent EUR 553.12 10 | Expenses:Utilities (amount - 553.12 EUR) 11 | Expenses:Rent (-amount) 12 | = @"My favorite restaurant" 13 | ; :yummy: 14 | 15 | 2021-01-01=2021-01-02 16 | Income:Salary -1000 EUR 17 | Assets:Checking account 18 | 2021-01-05 * Rent 19 | Expenses:Rent 705.43 EUR 20 | Assets:Checking account 21 | 2021-01-06 * Meal | My favorite restaurant 22 | ; :one_tag: 23 | Expenses:Restaurants 58.33 EUR 24 | Assets:Cash 25 | -------------------------------------------------------------------------------- /tests/example_files/automated_fail.ledger: -------------------------------------------------------------------------------- 1 | ; This should fail because the added posting makes the transaction unbalanced 2 | 3 | = flights 4 | [Bugdet:Holiday] -1 5 | 6 | 2021-01-01 * Flights 7 | ; :holiday: 8 | Expenses:Flights 200 USD 9 | Assets:Checking account 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/example_files/collapse_demo.ledger: -------------------------------------------------------------------------------- 1 | 2021-09-01 * Several Flights 2 | Expenses:Travel 200 EUR 3 | Expenses:Travel 150 EUR 4 | Assets:Checking account 5 | -------------------------------------------------------------------------------- /tests/example_files/demo.ledger: -------------------------------------------------------------------------------- 1 | ; A simple demo file with comments 2 | ! Comments can also be like this 3 | # or like this 4 | 5 | 2021-01-01 * Groceries 6 | ; :fruit: 7 | Expenses:Groceries $100 8 | Assets:Bank - 1:Checking account 9 | 10 | 2021-01-03 * Clothing 11 | ; :skirt: 12 | Expenses:Clothes $69.37 ; payee: Clothing shop 13 | Assets:Bank - 1:Checking account $-69.37 14 | 15 | 2021-01-15 * Flights 16 | ; destination: spain 17 | Expenses:Unknown 200 EUR ; this will get translated to Expenses:Flights 18 | Assets:Bank - 1:Checking account $-210.12 19 | 20 | 2021-01-16 * Alphabet Inc. 21 | Assets:Shares 2 GOOGL @ $957.37 22 | Assets:Bank - 1:Checking account 23 | 24 | 2021-01-16 * ACME. 25 | Assets:Shares 2 ACME @@ $957.37 26 | Assets:Bank - 1:Checking account 27 | 28 | 2021-01-27 * ACME, inc. 29 | Income:Salary $-100 30 | Assets:Bank - 1:Checking account 31 | 32 | commodity € 33 | alias EUR 34 | alias EURUSD=X 35 | format 1.234,00 € 36 | commodity USD 37 | alias $ 38 | format -$1,234.00 39 | P 2021-01-23 AAPL 139.07 USD 40 | 41 | payee ACME, inc. 42 | alias (?i)(.*acme.*) 43 | account Expenses:Travel 44 | payee Flights 45 | 46 | ; Difficult things to parse that were in my ledger 47 | P 2018/01/14 17:37:11 BTC 13420.7 USD 48 | P 2013/12/11 EURUSD=X 1.376444 USD 49 | P 2015/08/07 ETH-USD 2.772120 USD 50 | commodity Acme_2021 51 | P 2015/08/07 Acme_2021 1000 USD 52 | -------------------------------------------------------------------------------- /tests/example_files/demo_bad.ledger: -------------------------------------------------------------------------------- 1 | ; A simple demo file with comments 2 | ! Comments can also be like this 3 | # or like this 4 | 5 | 2021-01-01 * Groceries 6 | ; :fruit: 7 | Expenses:Groceries $100 8 | Assets:Checking account 9 | 10 | ; This does not balance... on purpose 11 | 2021-01-03 * Clothing 12 | ; :skirt: 13 | Expenses:Clothes $69.37 14 | Assets:Checking account $-100 15 | 16 | 2021-01-15 * Flights 17 | ; destination: spain 18 | Expenses:Travel 200 EUR 19 | Assets:Checking account $-210.12 20 | 21 | 2021-01-16 * Alphabet Inc. 22 | Assets:Shares 2 GOOGL @ $957.37 23 | Assets:Checking account 24 | 25 | 2021-01-16 * ACME. 26 | Assets:Shares 2 ACME @@ $957.37 27 | Assets:Checking account 28 | 29 | 2021-01-27 * ACME, inc. 30 | Income:Salary $-100 31 | Assets:Checking account 32 | 33 | commodity € 34 | alias EUR 35 | commodity USD 36 | alias $ 37 | P 2021-01-23 AAPL 139.07 USD 38 | -------------------------------------------------------------------------------- /tests/example_files/empty_ledgerrc: -------------------------------------------------------------------------------- 1 | # This is an empty ledgerrc -------------------------------------------------------------------------------- /tests/example_files/example_bad_ledgerrc: -------------------------------------------------------------------------------- 1 | # This is a sample file of ledgerrc, very comfortable for setting default options 2 | This line should be a comment but isn't, it is bad on purpose. 3 | -------------------------------------------------------------------------------- /tests/example_files/example_bad_ledgerrc2: -------------------------------------------------------------------------------- 1 | - This does not parse either. And it shouldn't. -------------------------------------------------------------------------------- /tests/example_files/example_ledgerrc: -------------------------------------------------------------------------------- 1 | # This is a sample file of ledgerrc, very comfortable for setting default options 2 | --force-color 3 | --real 4 | -------------------------------------------------------------------------------- /tests/example_files/exchange.ledger: -------------------------------------------------------------------------------- 1 | 2020-01-01 * ACME, Inc. 2 | Assets:Shares 1 ACME @ 1000.00 USD 3 | Assets:Bank:Checking account 4 | 2021-01-01 * ACME, Inc. 5 | Assets:Shares 1 ACME @ 1000.00 EUR 6 | Assets:Bank:Checking account 7 | 8 | P 2020-07-01 EUR 1.5 USD 9 | 10 | ; I have 2 ACME Shares 11 | ; worth 2000 EUR 12 | ; worth 3000 USD because the last exchange rate was 1.5 13 | ; in terms of nodes there should be 14 | ; 2021-01-01 ACME 15 | ; 2021-01-01 EUR 16 | ; 2020-07-01 EUR 17 | ; 2020-07-01 USD 18 | ; NOTHING for 2020-01-01 19 | ; -------------------------------------------------------------------------------- /tests/example_files/hledger_roi.ledger: -------------------------------------------------------------------------------- 1 | ; This file was copied from https://hledger.org/return-on-investment.html 2 | ; it was later modified 3 | ; will test with a command similar to: 4 | ; roi --init-file tests/example_files/empty_ledgerrc -f tests/example_files/hledger_roi.ledger --cash-flows cash --assets-value snake -X $ 5 | 2019-01-01 Investing in Snake Oil 6 | assets:cash -$100 7 | investment:snake oil 8 | 9 | 2019-01-02 Buyers remorse 10 | assets:cash $90 11 | investment:snake oil 12 | 13 | 2019-02-28 Recording the growth of Snake Oil 14 | investment:snake oil 15 | equity:unrealized gains -$0.25 16 | 17 | 2019-06-30 Recording the growth of Snake Oil 18 | investment:snake oil 19 | equity:unrealized gains -$0.25 20 | 21 | 2019-09-30 Recording the growth of Snake Oil 22 | investment:snake oil 23 | equity:unrealized gains -$0.25 24 | 25 | 2019-12-30 Fear of missing out 26 | assets:cash -$90 27 | investment:snake oil 28 | 29 | 2019-12-31 Recording the growth of Snake Oil 30 | investment:snake oil 31 | equity:unrealized gains -$0.25 32 | 33 | 2020-01-01 Start the new year with a new investment 34 | assets:cash -$100 35 | investment:snake oil -------------------------------------------------------------------------------- /tests/example_files/include.ledger: -------------------------------------------------------------------------------- 1 | ; this file just includes the other file 2 | ; and has more comments 3 | 4 | 2021-01-01 * Groceries 5 | Expenses:Groceries $100 6 | Assets:Checking account 7 | 8 | 2021-01-03 * Clothing 9 | Expenses:Groceries $69.37 10 | Assets:Checking account $-69.37 11 | 12 | 2021-01-15 * Flights 13 | Expenses:Travel 200 EUR 14 | Assets:Checking account $-210.12 15 | 16 | include demo.ledger 17 | 18 | 19 | 2021-01-27 * ACME, inc. 20 | Income:Salary $-100 21 | Assets:Checking account 22 | 23 | include quotes/*.dat 24 | -------------------------------------------------------------------------------- /tests/example_files/prices.ledger: -------------------------------------------------------------------------------- 1 | ; Example journal to test price conversions 2 | 2021-07-31 I have 1 Bitcoin 3 | Assets:Crypto 1 BTC 4 | Equity:Opening balances 5 | 6 | ; Define commodity 7 | commodity USD 8 | format -1234 USD 9 | 10 | ; Bitcoin prices in USD 11 | P 2021-08-01 BTC 39974.8945 USD 12 | P 2021-08-03 BTC 38027.8242 USD 13 | P 2021-08-03 BTC 38152.9805 USD 14 | P 2021-08-04 BTC 39747.5039 USD 15 | P 2021-08-06 BTC 42611.5469 USD 16 | P 2021-08-06 BTC 42816.5000 USD 17 | P 2021-08-07 BTC 44555.8008 USD 18 | P 2021-08-08 BTC 43798.1172 USD 19 | P 2021-08-09 BTC 46365.4023 USD 20 | P 2021-08-12 BTC 45243.8203 USD 21 | P 2021-08-15 BTC 46600.8281 USD 22 | P 2021-08-17 BTC 46672.6250 USD 23 | P 2021-08-18 BTC 44801.1875 USD 24 | P 2021-08-19 BTC 44817.6133 USD 25 | P 2021-08-22 BTC 48863.6328 USD 26 | -------------------------------------------------------------------------------- /tests/example_files/quotes/bitcoin.dat: -------------------------------------------------------------------------------- 1 | P 2019/10/12 BTC 8336.56 USD 2 | P 2019/10/13 BTC 8321.01 USD 3 | P 2019/10/14 BTC 8374.69 USD 4 | -------------------------------------------------------------------------------- /tests/example_files/quotes/sp500.dat: -------------------------------------------------------------------------------- 1 | P 2020/09/17 ES0152769032 18.63 EUR 2 | P 2020/09/18 ES0152769032 18.44 EUR 3 | P 2020/09/21 ES0152769032 18.33 EUR -------------------------------------------------------------------------------- /tests/example_files/reg_exchange.ledger: -------------------------------------------------------------------------------- 1 | P 2021-09-01 EUR 2.0 USD 2 | P 2021-10-01 EUR 1.5 USD 3 | 4 | 2021-09-03 * Several Flights 5 | Expenses:Travel 200 USD 6 | Assets:Checking account 7 | 8 | 2021-10-03 * Several Flights 9 | Expenses:Travel 200 USD 10 | Assets:Checking account 11 | -------------------------------------------------------------------------------- /tests/example_files/roi_fail_currencies.ledger: -------------------------------------------------------------------------------- 1 | ; This should fail, can't convert € to $ 2 | 2019-01-01 Investing in Snake Oil 3 | assets:cash -$100 4 | investment:snake oil 5 | 6 | 2019-01-02 Buyers remorse 7 | assets:cash 90€ 8 | investment:snake oil 9 | 10 | 2019-02-28 Recording the growth of Snake Oil 11 | investment:snake oil 12 | equity:unrealized gains -$0.25 13 | 14 | 2019-06-30 Recording the growth of Snake Oil 15 | investment:snake oil 16 | equity:unrealized gains -$0.25 17 | 18 | 2019-09-30 Recording the growth of Snake Oil 19 | investment:snake oil 20 | equity:unrealized gains -$0.25 21 | 22 | 2019-12-30 Fear of missing out 23 | assets:cash -$90 24 | investment:snake oil 25 | 26 | 2019-12-31 Recording the growth of Snake Oil 27 | investment:snake oil 28 | equity:unrealized gains -$0.25 29 | 30 | 2020-01-01 Start the new year with a new investment 31 | assets:cash -$100 32 | investment:snake oil -------------------------------------------------------------------------------- /tests/example_files/tags.ledger: -------------------------------------------------------------------------------- 1 | ; File to test tags and tag related options 2 | 2021-01-01 * Fruit 3 | ; :shopping: 4 | Expenses:Groceries 15.03 EUR 5 | ; :healthy: 6 | Assets:Cash 7 | -------------------------------------------------------------------------------- /tests/example_files/virtual_postings.ledger: -------------------------------------------------------------------------------- 1 | 2021-01-01 * Groceries 1 2 | Expenses:Groceries $100 3 | (Budget:Food) $-100 4 | Assets:Checking account 5 | 6 | 2021-01-02 * Groceries 2 7 | Budget:Food $50 8 | Assets:Checking account 9 | 10 | 2021-01-03 * Groceries virtual balanced 11 | [Budget:Food] $200 12 | [Bugget:Savings] $-200 13 | -------------------------------------------------------------------------------- /tests/test_accounts.rs: -------------------------------------------------------------------------------- 1 | use dinero::{parser::Tokenizer, CommonOpts}; 2 | use structopt::StructOpt; 3 | 4 | #[test] 5 | fn test_account_names() { 6 | let tokenizers: Vec = vec![ 7 | Tokenizer::from( 8 | "2021-01-15 * Flights 9 | Expenses:Travel 200 EUR 10 | Assets:Checking account -200 EUR 11 | 2021-01-16 * More flights 12 | Expenses:Travel 300 EUR 13 | Assets:Checking account 14 | " 15 | .to_string(), 16 | ), 17 | Tokenizer::from( 18 | "2021-01-15 * Flights 19 | Expenses:Travel 200 EUR 20 | Assets:Checking account -200 EUR 21 | 2021-01-16 * More flights 22 | Expenses:Travel 300 EUR 23 | Assets:Checking account =-500 EUR 24 | " 25 | .to_string(), 26 | ), 27 | Tokenizer::from( 28 | "2021-01-15 * Flights 29 | Expenses:Travel 200 EUR 30 | Assets:Checking account -200 EUR 31 | 2021-01-16 * More flights 32 | Expenses:Travel 300 EUR 33 | Assets:Checking account -300 EUR 34 | " 35 | .to_string(), 36 | ), 37 | Tokenizer::from( 38 | "2021-01-15 * Flights 39 | Expenses:Travel 200 EUR 40 | Assets:Checking account -200 EUR 41 | 2021-01-16 * More flights 42 | Expenses:Travel 300 EUR 43 | Assets:Checking account -300 EUR = -500 EUR 44 | " 45 | .to_string(), 46 | ), 47 | ]; 48 | 49 | for (i, mut tokenizer) in tokenizers.into_iter().enumerate() { 50 | println!("Test case #{}", i); 51 | let parsed = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 52 | let mut num_accounts = parsed.accounts.len(); 53 | assert_eq!(num_accounts, 0, "There should be no accounts when parsed"); 54 | let mut options = CommonOpts::from_iter(["", "-f", ""].iter()); 55 | options.no_balance_check = true; 56 | num_accounts = parsed.to_ledger(&options).unwrap().accounts.len(); 57 | assert_eq!(num_accounts, 2, "There should be two accounts"); 58 | } 59 | } 60 | 61 | #[test] 62 | fn test_account_directive() { 63 | let mut tokenizer = Tokenizer::from( 64 | "account Assets:Revolut 65 | country GB 66 | alias revolut 67 | payee Revolut " 68 | .to_string(), 69 | ); 70 | 71 | let parsed = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 72 | let num_accounts = parsed.accounts.len(); 73 | assert_eq!(num_accounts, 1, "Parse one account") 74 | } 75 | -------------------------------------------------------------------------------- /tests/test_balances.rs: -------------------------------------------------------------------------------- 1 | use dinero::{parser::Tokenizer, CommonOpts}; 2 | use structopt::StructOpt; 3 | #[test] 4 | fn test_balances() { 5 | let mut tokenizer = Tokenizer::from( 6 | "2021-01-15 * Flights 7 | Expenses:Travel 200 EUR 8 | Assets:Checking account -200 EUR 9 | 2021-01-16 * More flights 1 10 | Expenses:Travel 300 EUR 11 | Assets:Checking account = -500 EUR 12 | 2021-01-16 * More flights 2 13 | Expenses:Travel 300 EUR 14 | Assets:Checking account -300 EUR = -800 EUR 15 | 2021-01-16 * More flights 3 16 | Expenses:Travel 300 EUR 17 | Assets:Checking account = -1100 EUR 18 | " 19 | .to_string(), 20 | ); 21 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 22 | let parsed = tokenizer.tokenize(&options); 23 | let ledger = parsed.to_ledger(&options); 24 | assert!(ledger.is_ok(), "This should balance"); 25 | } 26 | -------------------------------------------------------------------------------- /tests/test_currency_formats.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use chrono::Utc; 4 | use dinero::models::{Currency, CurrencyDisplayFormat, Money}; 5 | use dinero::parser::Tokenizer; 6 | use dinero::{models::conversion, CommonOpts}; 7 | use num::traits::Inv; 8 | use num::{BigInt, BigRational}; 9 | use structopt::StructOpt; 10 | 11 | #[test] 12 | fn currency_formats() { 13 | let mut tokenizer: Tokenizer = Tokenizer::from( 14 | "2020-01-01 * ACME, Inc. 15 | Assets:Shares 1 ACME @ 1000 USD 16 | Assets:Bank:Checking account 17 | 2021-01-01 * ACME, Inc. 18 | Assets:Shares 1 ACME @ 1000 EUR 19 | Assets:Bank:Checking account 20 | 21 | P 2020-07-01 EUR 1.5 USD 22 | commodity € 23 | alias EUR 24 | format -1.234,00 € 25 | commodity $ 26 | alias USD 27 | format ($1,234.00) 28 | commodity ACME 29 | format -1 ACME 30 | ; I have 2 ACME Shares 31 | ; worth 2000 EUR 32 | ; worth 3000 USD because the last exchange rate was 1.5 33 | ; in terms of nodes there should be 34 | ; 2021-01-01 ACME 35 | ; 2021-01-01 EUR 36 | ; 2020-07-01 EUR 37 | ; 2020-07-01 USD 38 | ; NOTHING for 2020-01-01 39 | ; 40 | " 41 | .to_string(), 42 | ); 43 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 44 | let items = tokenizer.tokenize(&options); 45 | let ledger = items.to_ledger(&options).unwrap(); 46 | let eur = ledger.get_commodities().get("eur").unwrap(); 47 | let usd = ledger.get_commodities().get("usd").unwrap(); 48 | let acme = ledger.get_commodities().get("acme").unwrap(); 49 | for _ in 0..30 { 50 | let multipliers_acme = conversion( 51 | acme.clone(), 52 | Utc::now().naive_local().date(), 53 | ledger.get_prices(), 54 | ); 55 | 56 | let to_eur = multipliers_acme.get(eur).unwrap(); 57 | let to_usd = multipliers_acme.get(usd).unwrap(); 58 | assert_eq!( 59 | to_eur, 60 | &BigRational::from_integer(BigInt::from(1000)).inv(), 61 | "1 ACME = 1000 EUR" 62 | ); 63 | assert_eq!( 64 | to_usd, 65 | &BigRational::from_integer(BigInt::from(1500)).inv(), 66 | "1 ACME = 1500 USD" 67 | ); 68 | } 69 | } 70 | 71 | #[test] 72 | fn display_currencies() { 73 | let format_1 = CurrencyDisplayFormat::from("-1.234,00 €"); 74 | let format_2 = CurrencyDisplayFormat::from("(1.234,00 €)"); 75 | let format_3 = CurrencyDisplayFormat::from("-€1.234,00 €"); 76 | 77 | let currency = Rc::new(Currency::from("€")); 78 | let money = Money::from((currency.clone(), BigRational::from_float(-12.3).unwrap())); 79 | 80 | currency.set_format(&format_1); 81 | assert_eq!(format!("{}", money), "-12,30 €"); 82 | 83 | // TODO Fix this test 84 | // currency.set_format(&format_2); 85 | // assert_eq!(format!("{}", money), "(12,30 €)"); 86 | 87 | currency.set_format(&format_3); 88 | assert_eq!(format!("{}", money), "-€12,30"); 89 | } 90 | -------------------------------------------------------------------------------- /tests/test_exchange.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use dinero::parser::Tokenizer; 3 | use dinero::{models::conversion, CommonOpts}; 4 | use num::traits::Inv; 5 | use num::{BigInt, BigRational}; 6 | use structopt::StructOpt; 7 | 8 | #[test] 9 | fn exchange() { 10 | let mut tokenizer: Tokenizer = Tokenizer::from( 11 | "2020-01-01 * ACME, Inc. 12 | Assets:Shares 1 ACME @ 1000.00 USD 13 | Assets:Bank:Checking account 14 | 2021-01-01 * ACME, Inc. 15 | Assets:Shares 1 ACME @ 1000.00 EUR 16 | Assets:Bank:Checking account 17 | 18 | P 2020-07-01 EUR 1.5 USD 19 | 20 | ; I have 2 ACME Shares 21 | ; worth 2000 EUR 22 | ; worth 3000 USD because the last exchange rate was 1.5 23 | ; in terms of nodes there should be 24 | ; 2021-01-01 ACME 25 | ; 2021-01-01 EUR 26 | ; 2020-07-01 EUR 27 | ; 2020-07-01 USD 28 | ; NOTHING for 2020-01-01 29 | ; 30 | " 31 | .to_string(), 32 | ); 33 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 34 | let items = tokenizer.tokenize(&options); 35 | let ledger = items.to_ledger(&options).unwrap(); 36 | let eur = ledger.get_commodities().get("eur").unwrap(); 37 | let usd = ledger.get_commodities().get("usd").unwrap(); 38 | let acme = ledger.get_commodities().get("acme").unwrap(); 39 | 40 | let multipliers_acme = conversion( 41 | acme.clone(), 42 | Utc::now().naive_local().date(), 43 | ledger.get_prices(), 44 | ); 45 | 46 | let to_eur = multipliers_acme.get(eur).unwrap(); 47 | let to_usd = multipliers_acme.get(usd).unwrap(); 48 | assert_eq!( 49 | to_eur, 50 | &BigRational::from_integer(BigInt::from(1000)).inv(), 51 | "1 ACME = 1000 EUR" 52 | ); 53 | assert_eq!( 54 | to_usd, 55 | &BigRational::from_integer(BigInt::from(1500)).inv(), 56 | "1 ACME = 1500 USD" 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /tests/test_expressions.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use common::{test_args, test_err}; 3 | mod common; 4 | 5 | #[test] 6 | /// A test for value expressions with dates and comparisons 7 | fn compare_dates() { 8 | let args_1 = &[ 9 | "reg", 10 | "--init-file", 11 | "tests/example_files/empty_ledgerrc", 12 | "-f", 13 | "tests/example_files/demo.ledger", 14 | "expr", 15 | "date > to_date('2021/01/16')", 16 | ]; 17 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args_1).assert(); 18 | let output_1 = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); 19 | assert_eq!(output_1.lines().into_iter().count(), 2); 20 | test_args(args_1); 21 | 22 | let args_2 = &[ 23 | "reg", 24 | "--init-file", 25 | "tests/example_files/empty_ledgerrc", 26 | "-f", 27 | "tests/example_files/demo.ledger", 28 | "expr", 29 | "date >= to_date('2021/01/16')", 30 | ]; 31 | let assert_2 = Command::cargo_bin("dinero").unwrap().args(args_2).assert(); 32 | let output_2 = String::from_utf8(assert_2.get_output().to_owned().stdout).unwrap(); 33 | assert_eq!(output_2.lines().into_iter().count(), 15); 34 | test_args(args_2); 35 | 36 | let args_3 = &[ 37 | "reg", 38 | "-f", 39 | "tests/example_files/demo.ledger", 40 | "expr", 41 | "date < to_date('2021/01/16')", 42 | ]; 43 | let assert_3 = Command::cargo_bin("dinero").unwrap().args(args_3).assert(); 44 | let output_3 = String::from_utf8(assert_3.get_output().to_owned().stdout).unwrap(); 45 | assert_eq!(output_3.lines().into_iter().count(), 7); 46 | test_args(args_3); 47 | 48 | let args_4 = &[ 49 | "reg", 50 | "-f", 51 | "tests/example_files/demo.ledger", 52 | "expr", 53 | "date <= to_date('2021/01/01')", 54 | ]; 55 | let assert_4 = Command::cargo_bin("dinero").unwrap().args(args_4).assert(); 56 | let output_4 = String::from_utf8(assert_4.get_output().to_owned().stdout).unwrap(); 57 | assert_eq!(output_4.lines().into_iter().count(), 2); 58 | test_args(args_4); 59 | 60 | let args_4 = &[ 61 | "reg", 62 | "-f", 63 | "tests/example_files/demo.ledger", 64 | "expr", 65 | "date == to_date('2021/01/01')", 66 | ]; 67 | let assert_4 = Command::cargo_bin("dinero").unwrap().args(args_4).assert(); 68 | let output_4 = String::from_utf8(assert_4.get_output().to_owned().stdout).unwrap(); 69 | assert_eq!(output_4.lines().into_iter().count(), 2); 70 | test_args(args_4); 71 | } 72 | 73 | #[test] 74 | /// A test for value expressions with dates and comparisons 75 | fn function_any() { 76 | let args_1 = &[ 77 | "reg", 78 | "-f", 79 | "tests/example_files/demo.ledger", 80 | "expr", 81 | "any(abs(amount) > 1000)", 82 | ]; 83 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args_1).assert(); 84 | let output_1 = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); 85 | assert_eq!(output_1.lines().into_iter().count(), 3); 86 | test_args(args_1); 87 | let args_2 = &[ 88 | "reg", 89 | "-f", 90 | "tests/example_files/demo.ledger", 91 | "expr", 92 | "any((500 + 500) < abs(amount))", 93 | ]; 94 | let assert_2 = Command::cargo_bin("dinero").unwrap().args(args_2).assert(); 95 | let output_2 = String::from_utf8(assert_2.get_output().to_owned().stdout).unwrap(); 96 | assert_eq!(output_2.lines().into_iter().count(), 3); 97 | test_args(args_2); 98 | } 99 | 100 | #[test] 101 | /// A test for value expressions with dates and comparisons 102 | fn test_equality() { 103 | let args_1 = &[ 104 | "reg", 105 | "--init-file", 106 | "tests/example_files/empty_ledgerrc", 107 | "-f", 108 | "tests/example_files/demo.ledger", 109 | "expr", 110 | "any((2 * 1) == abs(amount) )", 111 | ]; 112 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args_1).assert(); 113 | let output_1 = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); 114 | assert_eq!(output_1.lines().into_iter().count(), 9); 115 | test_args(args_1); 116 | let args_2 = &[ 117 | "reg", 118 | "--init-file", 119 | "tests/example_files/empty_ledgerrc", 120 | "-f", 121 | "tests/example_files/demo.ledger", 122 | "expr", 123 | "any(abs(amount) == 100 $)", 124 | ]; 125 | let assert_2 = Command::cargo_bin("dinero").unwrap().args(args_2).assert(); 126 | let output_2 = String::from_utf8(assert_2.get_output().to_owned().stdout).unwrap(); 127 | assert_eq!(output_2.lines().into_iter().count(), 4); 128 | test_args(args_2); 129 | } 130 | 131 | #[test] 132 | #[should_panic(expected = "Can't compare different currencies. € and USD.")] 133 | /// Bad comparison 134 | fn bad_comparison() { 135 | let args_1 = &[ 136 | "reg", 137 | "-f", 138 | "tests/example_files/demo.ledger", 139 | "expr", 140 | "(2 * (5 eur)) < ((3 usd) / 5))", 141 | ]; 142 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args_1).assert(); 143 | let output_1 = String::from_utf8(assert_1.get_output().to_owned().stderr).unwrap(); 144 | assert!(output_1.lines().into_iter().count() >= 1); 145 | test_err(args_1); 146 | } 147 | -------------------------------------------------------------------------------- /tests/test_failures.rs: -------------------------------------------------------------------------------- 1 | use dinero::{parser::Tokenizer, CommonOpts}; 2 | use structopt::StructOpt; 3 | 4 | #[test] 5 | #[should_panic(expected = "Should be money.")] 6 | /// The expression in an automated account should evaluate to money 7 | fn not_money() { 8 | let mut tokenizer: Tokenizer = Tokenizer::from( 9 | " 10 | = travel 11 | (failure) (account) 12 | 2021-01-15 * Flights 13 | Expenses:Travel 200 EUR 14 | Assets:Checking account -200 EUR 15 | " 16 | .to_string(), 17 | ); 18 | let parsed = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 19 | 20 | // But to a wrong ledger -- panics! 21 | let _ledger = parsed.to_ledger(&CommonOpts::from_iter(["", "-f", ""].iter())); 22 | unreachable!("This has panicked") 23 | } 24 | -------------------------------------------------------------------------------- /tests/test_include.rs: -------------------------------------------------------------------------------- 1 | use dinero::parser::Tokenizer; 2 | use dinero::CommonOpts; 3 | 4 | use std::convert::TryFrom; 5 | use std::path::PathBuf; 6 | use structopt::StructOpt; 7 | 8 | #[test] 9 | fn test_include() { 10 | let p1 = PathBuf::from("tests/example_files/include.ledger".to_string()); 11 | let mut tokenizer: Tokenizer = Tokenizer::try_from(&p1).unwrap(); 12 | let _res = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 13 | // simply that it does not panic 14 | // todo change for something meaningful 15 | assert!(true); 16 | } 17 | 18 | #[test] 19 | fn test_build_ledger_from_demo() { 20 | let p1 = PathBuf::from("tests/example_files/demo.ledger".to_string()); 21 | let mut tokenizer: Tokenizer = Tokenizer::try_from(&p1).unwrap(); 22 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 23 | let items = tokenizer.tokenize(&options); 24 | let ledger = items.to_ledger(&options); 25 | assert!(ledger.is_ok()); 26 | } 27 | 28 | #[test] 29 | fn test_fail() { 30 | let mut tokenizer: Tokenizer = Tokenizer::from( 31 | " 32 | 2021-01-15 * Flights 33 | Expenses:Travel 200 EUR 34 | Assets:Checking account -180 EUR 35 | " 36 | .to_string(), 37 | ); 38 | let parsed = tokenizer.tokenize(&CommonOpts::from_iter(["", "-f", ""].iter())); 39 | // It parses 40 | assert!(true); 41 | 42 | // But to a wrong ledger 43 | let ledger = parsed.to_ledger(&CommonOpts::from_iter(["", "-f", ""].iter())); 44 | assert!(ledger.is_err()); 45 | } 46 | 47 | #[test] 48 | fn comment_no_spaces() { 49 | let mut tokenizer: Tokenizer = Tokenizer::from( 50 | " 51 | 2000-01-01 * Sell shares 52 | Assets:Shares -3.25 ACME @@ 326 USD;@ 100 USD 53 | Assets:Checking 326 USD 54 | " 55 | .to_string(), 56 | ); 57 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 58 | let items = tokenizer.tokenize(&options); 59 | let ledger = items.to_ledger(&options); 60 | assert!(ledger.is_ok()); 61 | } 62 | #[test] 63 | fn comment_spaces() { 64 | let mut tokenizer: Tokenizer = Tokenizer::from( 65 | " 66 | 2000-01-01 * Sell shares 67 | Assets:Shares -3.25 ACME @@ 326 USD ;@ 100 USD 68 | Assets:Checking 326 USD 69 | " 70 | .to_string(), 71 | ); 72 | let options = CommonOpts::from_iter(["", "-f", ""].iter()); 73 | let items = tokenizer.tokenize(&options); 74 | let ledger = items.to_ledger(&options); 75 | assert!(ledger.is_ok()); 76 | } 77 | -------------------------------------------------------------------------------- /tests/test_prices.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | mod common; 3 | use common::test_args; 4 | 5 | #[test] 6 | fn bitcoin_balances() { 7 | let first_args = &[ 8 | "bal", 9 | "crypto", 10 | "--args-only", 11 | "-f", 12 | "tests/example_files/prices.ledger", 13 | "--exchange", 14 | "USD", 15 | ]; 16 | 17 | let expectations = &[ 18 | ("2021-09", "48864"), 19 | ("2021-08-15", "45244"), 20 | ("2021-08-12", "46365"), 21 | ]; 22 | 23 | for (date, amount) in expectations { 24 | let mut args: Vec<&str> = first_args.to_vec(); 25 | args.append(&mut vec!["--end", date]); 26 | 27 | let assert = Command::cargo_bin("dinero").unwrap().args(&args).assert(); 28 | let output = String::from_utf8(assert.get_output().to_owned().stdout).unwrap(); 29 | for (i, line) in output.lines().into_iter().enumerate() { 30 | match i { 31 | 0 => assert!(String::from(line).contains(amount), "Should be {}", amount), 32 | _ => unreachable!(), 33 | } 34 | } 35 | test_args(&args); 36 | } 37 | } 38 | #[test] 39 | fn bitcoin_balances_convert() { 40 | let first_args = &[ 41 | "bal", 42 | "crypto", 43 | "--args-only", 44 | "-f", 45 | "tests/example_files/prices.ledger", 46 | "--convert", 47 | "USD", 48 | ]; 49 | 50 | let expectations = &[ 51 | ("2021-09", "48864"), 52 | ("2021-08-15", "45244"), 53 | ("2021-08-12", "46365"), 54 | ]; 55 | 56 | for (date, amount) in expectations { 57 | let mut args: Vec<&str> = first_args.to_vec(); 58 | args.append(&mut vec!["--end", date]); 59 | 60 | let assert = Command::cargo_bin("dinero").unwrap().args(&args).assert(); 61 | let output = String::from_utf8(assert.get_output().to_owned().stdout).unwrap(); 62 | for (i, line) in output.lines().into_iter().enumerate() { 63 | match i { 64 | 0 => { 65 | assert!(String::from(line).contains(amount), "Should be {}", amount); 66 | assert!(String::from(line).contains("BTC"), "Should contain 1 BTC"); 67 | } 68 | _ => unreachable!(), 69 | } 70 | } 71 | test_args(&args); 72 | } 73 | } 74 | 75 | #[test] 76 | /// Check the exchange option in the register report 77 | fn reg_exchange() { 78 | let args = &[ 79 | "reg", 80 | "--init-file", 81 | "tests/example_files/empty_ledgerrc", 82 | "-f", 83 | "tests/example_files/reg_exchange.ledger", 84 | "--exchange", 85 | "EUR", 86 | "travel", 87 | ]; 88 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args).assert(); 89 | let output = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); 90 | 91 | for (i, line) in output.lines().into_iter().enumerate() { 92 | match i { 93 | 0 => assert!(String::from(line).contains("100")), 94 | 1 => assert!(String::from(line).contains("133")), 95 | _ => unreachable!(), 96 | } 97 | } 98 | 99 | test_args(args); 100 | } 101 | -------------------------------------------------------------------------------- /tests/test_repl.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | mod common; 3 | // const CARGO: &'static str = env!("CARGO"); 4 | 5 | #[test] 6 | fn stdin_repl() { 7 | let args1 = &[ 8 | "--init-file", 9 | "tests/example_files/empty_ledgerrc", 10 | "-f", 11 | "tests/example_files/demo.ledger", 12 | ]; 13 | let command = Command::cargo_bin("dinero") 14 | .unwrap() 15 | .args(args1) 16 | .write_stdin("exit") 17 | .ok(); 18 | assert!(command.is_ok()); 19 | } 20 | -------------------------------------------------------------------------------- /tests/test_tags.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use common::test_args; 3 | mod common; 4 | #[test] 5 | /// Check the search by tag command 6 | fn tags() { 7 | let args1 = &["reg", "-f", "tests/example_files/tags.ledger", "%healthy"]; 8 | let assert_1 = Command::cargo_bin("dinero").unwrap().args(args1).assert(); 9 | let output1 = String::from_utf8(assert_1.get_output().to_owned().stdout).unwrap(); 10 | assert_eq!(output1.lines().into_iter().count(), 1); 11 | test_args(args1); 12 | 13 | let args2 = &["reg", "-f", "tests/example_files/tags.ledger", "%shopping"]; 14 | let assert_2 = Command::cargo_bin("dinero").unwrap().args(args2).assert(); 15 | let output2 = String::from_utf8(assert_2.get_output().to_owned().stdout).unwrap(); 16 | assert_eq!(output2.lines().into_iter().count(), 2); 17 | 18 | test_args(args2); 19 | } 20 | --------------------------------------------------------------------------------