ledger_rs_lib/
lib.rs

1/*!
2# Ledger-rs library
3
4Ledger-cli functionality implemented in Rust
5
6Early work-in-progress.
7
8The basic functionality demo:
9
10Given a `basic.ledger` text file, with the contents
11
12```ledger
132023-04-21 Supermarket
14    Expenses:Food  20 EUR
15    Assets:Cash
16```
17
18you can use the library to parse the transactions from the file and provide a basic
19report on account balances
20
21```
22    let actual = ledger_rs_lib::run_command("b -f tests/basic.ledger");
23
24    assert!(!actual.is_empty());
25    assert_eq!(5, actual.len());
26    assert_eq!("Account  has balance 0 EUR", actual[0]);
27    assert_eq!("Account Assets has balance -20 EUR", actual[1]);
28    assert_eq!("Account Assets:Cash has balance -20 EUR", actual[2]);
29    assert_eq!("Account Expenses has balance 20 EUR", actual[3]);
30    assert_eq!("Account Expenses:Food has balance 20 EUR", actual[4]);
31```
32*/
33use std::{fs::File, io::Cursor};
34
35use journal::Journal;
36use option::InputOptions;
37
38#[cfg(target_arch = "wasm32")]
39use wasm_bindgen::prelude::*;
40
41pub mod account;
42mod annotate;
43pub mod amount;
44mod balance;
45pub mod commodity;
46mod directives;
47pub mod history;
48mod iterator;
49pub mod journal;
50mod journalreader;
51mod option;
52pub mod parser;
53pub mod pool;
54pub mod post;
55pub mod report;
56pub mod scanner;
57pub mod utilities;
58mod value;
59pub mod xact;
60
61/// An entry point for the CLIs.
62/// The commands and arguments sent to the CLI are processed here. This is
63/// so that 3rd-party clients can pass argv and get the same result.
64/// The arguments should be compatible with Ledger, so that the functionality is comparable.
65///
66pub fn run(args: Vec<String>) -> Vec<String> {
67    // separates commands from the options
68    let (commands, options) = option::process_arguments(args);
69
70    execute_command(commands, options)
71}
72
73/// A convenient entry point if you want to use a command string directly.
74/// command: &str A Ledger-style command, i.e. "balance -f journal.ledger"
75///
76pub fn run_command(command: &str) -> Vec<String> {
77    let args = shell_words::split(command).unwrap();
78    run(args)
79}
80
81/// global::execute_command equivalent
82fn execute_command(commands: Vec<String>, input_options: InputOptions) -> Vec<String> {
83    let verb = commands.iter().nth(0).unwrap();
84
85    // todo: look for pre-command
86    // look_for_precommand(verb);
87
88    // if !precommand
89    //   if !at_repl
90    let journal = session_read_journal_files(&input_options);
91
92    // todo: lookup(COMMAND, verb)
93
94    let command_args = &commands[1..];
95
96    // execute command
97    match verb.chars().next().unwrap() {
98        'a' => {
99            // accounts?
100            // TODO: replace this temporary report
101            let mut output = report::report_accounts(&journal);
102            output.sort();
103            output
104        }
105        'b' => {
106            match verb.as_str() {
107                "b" | "bal" | "balance" => {
108                    // balance report
109                    report::balance_report(&journal)
110                }
111                "budget" => {
112                    // budget
113                    todo!("budget!")
114                }
115                _ => {
116                    todo!("?")
117                }
118            }
119        }
120        _ => todo!("handle"),
121    }
122}
123
124fn look_for_precommand(verb: &str) {
125    todo!()
126}
127
128fn session_read_journal_files(options: &InputOptions) -> Journal {
129    // Minimalistic approach:
130    // get the file input
131
132    // multiple filenames
133    let mut journal = Journal::new();
134    for filename in &options.filenames {
135        // parse the journal file(s)
136        parse_file(filename, &mut journal);
137    }
138
139    journal
140}
141
142/// Parse input and return the model structure.
143pub fn parse_file(file_path: &str, journal: &mut Journal) {
144    let file = File::open(file_path).expect("file opened");
145    parser::read_into_journal(file, journal);
146}
147
148/// Parses text containing Ledger-style journal.
149/// text: &str  A Ledger-style journal. The same content that is normally
150///             stored in text files
151/// journal: &mut Journal  The result are stored in the given Journal instance.
152pub fn parse_text(text: &str, journal: &mut Journal) {
153    let source = Cursor::new(text);
154    parser::read_into_journal(source, journal);
155}
156
157pub fn parser_experiment() {
158    // read line from the Journal
159    // determine the type
160    // DirectiveType
161    // scan the line
162    // parse into a model instance
163    // read additional lines, as needed. Ie for Xact/Posts.
164    // return the directive with the entity, if created
165
166    todo!("try the new approach")
167}
168
169#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
170pub fn wasm_test() -> String {
171    "hello from wasm".to_owned()
172}
173
174#[cfg(test)]
175mod lib_tests {
176    use std::assert_eq;
177
178    use crate::{amount::{Amount, Quantity}, option, run};
179
180    // Try to understand why this test fails when dereferencing.
181    #[test]
182    fn test_minimal() {
183        // create a ledger command
184        let command = "b -f tests/minimal.ledger";
185        let args = shell_words::split(command).unwrap();
186        let expected = r#"Account  has balance 0
187Account Assets has balance -20
188Account Expenses has balance 20"#;
189
190        // the test fails when checking the parent in fullname() for
191        // Assets (the first child account).
192
193        let actual = run(args).join("\n");
194
195        // Assert
196        assert!(!actual.is_empty());
197        assert_eq!(expected, actual);
198    }
199
200    #[test]
201    fn test_multiple_files() {
202        // arrange
203        let args =
204            shell_words::split("accounts -f tests/minimal.ledger -f tests/basic.ledger").unwrap();
205        let (_commands, input_options) = option::process_arguments(args);
206        // let cdty = Commodity::new("EUR");
207
208        // Act
209        let journal = super::session_read_journal_files(&input_options);
210
211        // Assert
212        let xact0 = &journal.xacts[0];
213        let xact1 = &journal.xacts[1];
214
215        // xacts
216        assert_eq!(2, journal.xacts.len());
217        assert_eq!("Payee", xact0.payee);
218        assert_eq!("Supermarket", xact1.payee);
219
220        // posts
221        // assert_eq!(4, journal.posts.len());
222        assert_eq!(2, xact0.posts.len());
223        assert_eq!(2, xact1.posts.len());
224        assert_eq!(Some(Amount::new(20.into(), None)), xact0.posts[0].amount);
225        // amount
226        assert_eq!(xact1.posts[0].amount.unwrap().quantity, Quantity::from_str("20").unwrap());
227        assert_eq!(xact1.posts[0].amount.unwrap().get_commodity().unwrap().symbol, "EUR");
228
229        // accounts
230        let mut accounts = journal.master.flatten_account_tree();
231        // hack for the test, since the order of items in a hashmap is not guaranteed.
232        accounts.sort_unstable_by_key(|acc| &acc.name);
233
234        assert_eq!("", accounts[0].name);
235        assert_eq!("Assets", accounts[1].name);
236        assert_eq!("Cash", accounts[2].name);
237        assert_eq!("Expenses", accounts[3].name);
238        assert_eq!("Food", accounts[4].name);
239
240        // commodities
241        assert_eq!(1, journal.commodity_pool.commodities.len());
242        assert_eq!(
243            "EUR",
244            journal.commodity_pool.find("EUR").unwrap().symbol
245        );
246    }
247}