Skip to main content

okane_core/
format.rs

1//! format functionalities of Ledger format files.
2
3use crate::{
4    parse::{ParseError, ParseOptions, parse_ledger},
5    syntax::{self, display::DisplayContext},
6};
7
8use std::io::{Read, Write};
9
10/// Error occured during Format.
11#[derive(thiserror::Error, Debug)]
12pub enum FormatError {
13    #[error("failed to perform IO")]
14    IO(#[from] std::io::Error),
15    #[error("failed to parse the file")]
16    Parse(#[from] ParseError),
17    // TODO: Remove this once supported.
18    #[error("recursive format isn't supported yet")]
19    UnsupportedRecursiveFormat,
20}
21
22/// Options to control format functionalities.
23#[derive(Debug, Default)]
24pub struct FormatOptions {
25    recursive: bool,
26}
27
28impl FormatOptions {
29    /// Create a default FormatOptions instance.
30    /// All options are initially set to `false`.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Sets `recursive` option to given value.
36    /// If `recursive` is set to `true`, it'll try to follow `include` directive.
37    pub fn recursive(&mut self, recursive: bool) -> &mut Self {
38        self.recursive = recursive;
39        self
40    }
41
42    /// Formats given `Read` instance and write it back to `Write`.
43    pub fn format<R, W>(&self, r: &mut R, w: &mut W) -> Result<(), FormatError>
44    where
45        R: Read,
46        W: Write,
47    {
48        // TODO: Implement recursive formatting.
49        if self.recursive {
50            return Err(FormatError::UnsupportedRecursiveFormat);
51        }
52        let mut buf = String::new();
53        r.read_to_string(&mut buf)?;
54        // TODO: Grab DisplayContext externally, or from LedgerEntry.
55        let ctx = DisplayContext::default();
56        for parsed in parse_ledger(&ParseOptions::default(), &buf) {
57            let (_, entry): (_, syntax::plain::LedgerEntry) = parsed?;
58            write!(w, "{}", ctx.as_display(&entry))?;
59        }
60        Ok(())
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    use indoc::indoc;
69    use pretty_assertions::assert_eq;
70
71    fn format_str(input: &str) -> String {
72        let mut output = Vec::new();
73        FormatOptions::new()
74            .format(&mut input.as_bytes(), &mut output)
75            .expect("format() should succeeds");
76        String::from_utf8(output).expect("output should be valid UTF-8")
77    }
78
79    #[test]
80    fn format_keeps_comment_attached_to_the_next_entry() {
81        let input = indoc! {"
82            ; Explains the transaction below.
83            2021/03/12 Opening Balance
84                Assets:Bank                                          = 1000 CHF
85                Equity
86            ; Explains the transaction above.
87
88            ; Standalone comment, separated by blank lines.
89
90            2021/03/13 Grocery
91                Expenses:Grocery                              10 CHF
92                Assets:Bank
93        "};
94
95        assert_eq!(input, format_str(input));
96    }
97
98    #[test]
99    fn format_collapses_consecutive_blank_lines() {
100        let input = indoc! {"
101            include a.ledger
102
103
104
105            include b.ledger
106        "};
107        let want = indoc! {"
108            include a.ledger
109
110            include b.ledger
111        "};
112
113        assert_eq!(want, format_str(input));
114    }
115
116    #[test]
117    fn format_is_idempotent() {
118        let input = indoc! {"
119            ; Top
120            #comment
121
122            account  Foo\t
123             alias Bar
124
125            apply    tag   foo
126            end  apply   tag
127
128            2021/05/14 !(#txn-1) My Grocery
129                Expenses:Grocery\t10 CHF
130                Assets:Bank  -10 CHF
131        "};
132
133        let once = format_str(input);
134        assert_eq!(once, format_str(&once));
135    }
136
137    #[test]
138    fn format_succeeds_transaction_without_lot_price() {
139        let input = indoc! {"
140            ; Top
141            ; level
142            #comment
143            %can
144            |have several prefixes.
145
146            ; second
147            ; round
148
149            account  Foo\t
150             alias Bar\t
151               note これは何でしょうか
152              alias Baz
153
154            commodity  USD\t
155             \talias 米ドル\t
156             \talias $\t
157
158            apply    tag   foo
159
160            apply tag key: value
161            apply tag key:: 10 USD
162
163            end  apply   tag
164            ; key:: 10 USD
165
166            end apply tag
167            end apply tag
168
169            include        path/to/other.ledger
170
171            2021/03/12 Opening Balance  ; initial balance
172             Assets:Bank     = 1000 CHF
173             Equity
174
175            2021/05/14 !(#txn-1) My Grocery
176                Expenses:Grocery\t10 CHF
177                Expenses:Commissions    1 USD   @ 0.98 CHF ; Payee: My Card
178                ; My card took commission
179                ; :financial:経済:
180                Assets:Bank  -20 CHF=1CHF
181                Expenses:Household  = 0
182                Assets:Complex  (-10 * 2.1 $) @ (1 $ + 1 $) = 2.5 $
183                Assets:Broker  -2 SPINX (bought before Xmas) {100 USD} [2010/12/23] @ 10000 USD
184                Liabilities:Comma      5,678.00 CHF @ 1,000,000 JPYRIN = -123,456.12 CHF
185        "};
186        // TODO: 1. guess commodity width if not available.
187        // TOOD: 2. remove trailing space on non-commodity value.
188        let want = indoc! {"
189            ; Top
190            ; level
191            ;comment
192            ;can
193            ;have several prefixes.
194
195            ; second
196            ; round
197
198            account Foo
199                alias Bar
200                note これは何でしょうか
201                alias Baz
202
203            commodity USD
204                alias 米ドル
205                alias $
206
207            apply tag foo
208
209            apply tag key: value
210            apply tag key:: 10 USD
211
212            end apply tag
213            ; key:: 10 USD
214
215            end apply tag
216            end apply tag
217
218            include path/to/other.ledger
219
220            2021/03/12 Opening Balance
221                ; initial balance
222                Assets:Bank                                          = 1000 CHF
223                Equity
224
225            2021/05/14 ! (#txn-1) My Grocery
226                Expenses:Grocery                              10 CHF
227                Expenses:Commissions                           1 USD @ 0.98 CHF
228                ; Payee: My Card
229                ; My card took commission
230                ; :financial:経済:
231                Assets:Bank                                  -20 CHF = 1 CHF
232                Expenses:Household                               = 0
233                Assets:Complex                        (-10 * 2.1 $) @ (1 $ + 1 $) = 2.5 $
234                Assets:Broker                                 -2 SPINX {100 USD} [2010/12/23] (bought before Xmas) @ 10000 USD
235                Liabilities:Comma                       5,678.00 CHF @ 1,000,000 JPYRIN = -123,456.12 CHF
236        "};
237        let mut output = Vec::new();
238        let mut r = input.as_bytes();
239
240        FormatOptions::new()
241            .format(&mut r, &mut output)
242            .expect("format() should succeeds");
243        let got = std::str::from_utf8(&output).expect("output should be valid UTF-8");
244        assert_eq!(want, got);
245    }
246}