Skip to main content

okane_core/syntax/
display.rs

1//! Defines data & functions for displaying syntax types.
2
3use super::*;
4
5use std::collections::HashMap;
6
7use decoration::AsUndecorated;
8use expr::Visitable;
9
10use pretty_decimal::PrettyDecimal;
11use unicode_width::UnicodeWidthStr;
12
13/// Context information to control the formatting of the transaction.
14#[derive(Debug, Default, Clone)]
15pub struct DisplayContext {
16    default_commodity: CommodityDisplayOption,
17    commodity_overrides: HashMap<String, CommodityDisplayOption>,
18}
19
20impl DisplayContext {
21    /// Creates a new [`DisplayContext`].
22    pub fn new(
23        default_commodity: CommodityDisplayOption,
24        commodity_overrides: HashMap<String, CommodityDisplayOption>,
25    ) -> Self {
26        Self {
27            default_commodity,
28            commodity_overrides,
29        }
30    }
31
32    /// Returns given object reference wrapped with a context for `fmt::Display`.
33    pub fn as_display<'a, T>(&'a self, value: &'a T) -> WithContext<'a, T>
34    where
35        WithContext<'a, T>: fmt::Display,
36    {
37        WithContext {
38            value,
39            context: self,
40        }
41    }
42
43    /// Returns decimal format for the `commodity`.
44    pub fn decimal_format(&self, commodity: &str) -> Option<pretty_decimal::Format> {
45        self.commodity_overrides
46            .get(commodity)
47            .and_then(|o| o.format)
48            .or(self.default_commodity.format)
49    }
50
51    /// Returns the minimum scale of the amount for the `commodity`.
52    pub fn min_scale(&self, commodity: &str) -> Option<u8> {
53        self.commodity_overrides
54            .get(commodity)
55            .and_then(|o| o.min_scale)
56            .or(self.default_commodity.min_scale)
57    }
58}
59
60#[derive(Debug, Default, Clone)]
61pub struct CommodityDisplayOption {
62    pub format: Option<pretty_decimal::Format>,
63    pub min_scale: Option<u8>,
64}
65
66/// Object combined with the `DisplayContext`.
67pub struct WithContext<'a, T> {
68    value: &'a T,
69    context: &'a DisplayContext,
70}
71
72impl<'a, T> WithContext<'a, T> {
73    fn pass_context<U>(&self, other: &'a U) -> WithContext<'a, U> {
74        WithContext {
75            value: other,
76            context: self.context,
77        }
78    }
79}
80
81impl<Deco: Decoration> fmt::Display for WithContext<'_, LedgerEntry<'_, Deco>> {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        if self.value.separation == Separation::BlankLine {
84            writeln!(f)?;
85        }
86        self.pass_context(&self.value.statement).fmt(f)
87    }
88}
89
90impl<Deco: Decoration> fmt::Display for WithContext<'_, LedgerStatement<'_, Deco>> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match &self.value {
93            LedgerStatement::Txn(txn) => self.pass_context(txn).fmt(f),
94            LedgerStatement::Comment(v) => v.fmt(f),
95            LedgerStatement::ApplyTag(v) => v.fmt(f),
96            LedgerStatement::EndApplyTag => writeln!(f, "end apply tag"),
97            LedgerStatement::Include(v) => v.fmt(f),
98            LedgerStatement::Account(v) => v.fmt(f),
99            LedgerStatement::Commodity(v) => self.pass_context(v).fmt(f),
100        }
101    }
102}
103
104#[derive(Debug)]
105struct LineWrapStr<'a> {
106    prefix: &'static str,
107    content: &'a str,
108}
109
110impl<'a> LineWrapStr<'a> {
111    fn wrap(prefix: &'static str, content: &'a str) -> Self {
112        Self { prefix, content }
113    }
114}
115
116impl fmt::Display for LineWrapStr<'_> {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        for line in self.content.lines() {
119            writeln!(f, "{}{}", self.prefix, line)?;
120        }
121        Ok(())
122    }
123}
124
125impl fmt::Display for TopLevelComment<'_> {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        LineWrapStr::wrap(";", &self.0).fmt(f)
128    }
129}
130
131impl fmt::Display for ApplyTag<'_> {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(f, "apply tag {}", self.key)?;
134        match &self.value {
135            None => writeln!(f),
136            Some(v) => writeln!(f, "{}", v),
137        }
138    }
139}
140
141impl fmt::Display for IncludeFile<'_> {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        writeln!(f, "include {}", self.0)
144    }
145}
146
147impl fmt::Display for AccountDeclaration<'_> {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        writeln!(f, "account {}", self.name)?;
150        for detail in &self.details {
151            detail.fmt(f)?;
152        }
153        Ok(())
154    }
155}
156impl fmt::Display for AccountDetail<'_> {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            AccountDetail::Comment(v) => LineWrapStr::wrap("    ; ", v).fmt(f),
160            AccountDetail::Note(v) => LineWrapStr::wrap("    note ", v).fmt(f),
161            AccountDetail::Alias(v) => writeln!(f, "    alias {}", v),
162        }
163    }
164}
165
166impl fmt::Display for WithContext<'_, CommodityDeclaration<'_>> {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        writeln!(f, "commodity {}", self.value.name)?;
169        for detail in &self.value.details {
170            self.pass_context(detail).fmt(f)?;
171        }
172        Ok(())
173    }
174}
175impl fmt::Display for WithContext<'_, CommodityDetail<'_>> {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self.value {
178            CommodityDetail::Comment(v) => LineWrapStr::wrap("    ; ", v).fmt(f),
179            CommodityDetail::Note(v) => LineWrapStr::wrap("    note ", v).fmt(f),
180            CommodityDetail::Alias(v) => writeln!(f, "    alias {}", v),
181            CommodityDetail::Format(v) => writeln!(f, "    format {}", self.pass_context(v)),
182        }
183    }
184}
185impl<Deco: Decoration> fmt::Display for WithContext<'_, Transaction<'_, Deco>> {
186    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
187        let xact = self.value;
188        write!(f, "{}", xact.date.format("%Y/%m/%d"))?;
189        if let Some(edate) = &xact.effective_date {
190            write!(f, "={}", edate.format("%Y/%m/%d"))?;
191        }
192        write!(f, " {}", print_clear_state(xact.clear_state))?;
193        if let Some(code) = &xact.code {
194            write!(f, "({}) ", code)?;
195        }
196        writeln!(f, "{}", xact.payee)?;
197        for m in &xact.metadata {
198            m.fmt(f)?;
199        }
200        for post in &xact.posts {
201            write!(f, "{}", self.context.as_display(post.as_undecorated()))?;
202        }
203        Ok(())
204    }
205}
206
207const METADATA_PREFIX: &str = "    ; ";
208
209impl fmt::Display for Metadata<'_> {
210    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
211        match self {
212            Metadata::WordTags(tags) => {
213                f.write_str(METADATA_PREFIX)?;
214                f.write_str(":")?;
215                for tag in tags {
216                    write!(f, "{}:", tag)?;
217                }
218                f.write_str("\n")?
219            }
220            Metadata::KeyValueTag { key, value } => {
221                f.write_str(METADATA_PREFIX)?;
222                writeln!(f, "{}{}", key, value)?
223            }
224            Metadata::Comment(s) => LineWrapStr::wrap(METADATA_PREFIX, s).fmt(f)?,
225        };
226        Ok(())
227    }
228}
229
230impl fmt::Display for MetadataValue<'_> {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            MetadataValue::Expr(expr) => write!(f, ":: {}", expr),
234            MetadataValue::Text(text) => write!(f, ": {}", text),
235        }
236    }
237}
238
239impl<Deco: Decoration> fmt::Display for WithContext<'_, Posting<'_, Deco>> {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        let post = self.value;
242        let post_clear = print_clear_state(post.clear_state);
243        write!(f, "    {}{}", post_clear, post.account.as_undecorated())?;
244        let account_width = UnicodeWidthStr::width_cjk(post.account.as_undecorated().as_ref())
245            + UnicodeWidthStr::width(post_clear);
246        if let Some(amount) = &post.amount {
247            let mut amount_str = String::new();
248            let alignment = self
249                .pass_context(amount.amount.as_undecorated())
250                .fmt_with_alignment(&mut amount_str)?
251                .absolute();
252            write!(
253                f,
254                "{:>width$}{}",
255                "",
256                amount_str.as_str(),
257                width = get_column(48, account_width + alignment, 2)
258            )?;
259            write!(f, "{}", self.pass_context(&amount.lot))?;
260            if let Some(exchange) = &amount.cost {
261                match exchange.as_undecorated() {
262                    Exchange::Rate(v) => write!(f, " @ {}", self.pass_context(v)),
263                    Exchange::Total(v) => write!(f, " @@ {}", self.pass_context(v)),
264                }?
265            }
266        }
267        if let Some(balance) = &post.balance {
268            let mut balance_str = String::new();
269            let alignment = self
270                .pass_context(balance.as_undecorated())
271                .fmt_with_alignment(&mut balance_str)?
272                .absolute();
273            let trailing = UnicodeWidthStr::width_cjk(balance_str.as_str()) - alignment;
274            let balance_padding = if post.amount.is_some() {
275                0
276            } else {
277                get_column(50 + trailing, account_width, 2)
278            };
279            write!(
280                f,
281                "{:>width$} {}",
282                " =",
283                self.pass_context(balance.as_undecorated()),
284                width = balance_padding
285            )?;
286        }
287        writeln!(f)?;
288        for m in &post.metadata {
289            m.fmt(f)?;
290        }
291        Ok(())
292    }
293}
294
295impl<Deco: Decoration> fmt::Display for WithContext<'_, Lot<'_, Deco>> {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        if let Some(price) = &self.value.price {
298            match price.as_undecorated() {
299                Exchange::Total(e) => write!(f, " {{{{{}}}}}", self.pass_context(e)),
300                Exchange::Rate(e) => write!(f, " {{{}}}", self.pass_context(e)),
301            }?;
302        }
303        if let Some(date) = &self.value.date {
304            write!(f, " [{}]", date.format("%Y/%m/%d"))?;
305        }
306        if let Some(note) = &self.value.note {
307            write!(f, " ({})", note)?;
308        }
309        Ok(())
310    }
311}
312
313/// Represents a width that should come to the **left** part of each posting.
314///
315/// Examples:
316/// ```text
317/// ----*----| <- alignment point
318///          v
319///         0           // 1
320///   (1 + 1)           // 7
321///         2 USD       // 1
322///        (1 USD * 2)  // 2
323///    (2 * 1 USD)      // 6
324/// ```
325#[derive(Debug, PartialEq, Clone, Copy)]
326enum Alignment {
327    /// Still alignment wasn't found.
328    /// Equivalent to the fact that no commodity is used.
329    Partial(usize),
330    /// Already alignment was found.
331    /// Equivalent to the fact that commodity is used at least once.
332    Complete(usize),
333}
334
335impl Alignment {
336    /// Takes out the width regardless of the alignment type.
337    fn absolute(self) -> usize {
338        match self {
339            Alignment::Complete(x) => x,
340            Alignment::Partial(x) => x,
341        }
342    }
343
344    /// Adds up prefix / suffix lengths.
345    fn plus(self, prefix_length: usize, suffix_length: usize) -> Alignment {
346        match self {
347            Alignment::Partial(x) => Alignment::Partial(prefix_length + x + suffix_length),
348            Alignment::Complete(x) => Alignment::Complete(prefix_length + x),
349        }
350    }
351}
352
353trait DisplayWithAlignment {
354    fn fmt_with_alignment<W: fmt::Write>(&self, f: &mut W) -> Result<Alignment, fmt::Error>;
355}
356
357impl<T> fmt::Display for WithContext<'_, T>
358where
359    Self: DisplayWithAlignment,
360{
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        self.fmt_with_alignment(f).map(|_| ())
363    }
364}
365
366/// [`expr::ExprVisitor`] printing the expression, and returning the length until the alignment.
367struct ExprFormatter<'a, W> {
368    writer: &'a mut W,
369    context: &'a DisplayContext,
370}
371
372impl<'i, W: fmt::Write> expr::ExprVisitor<'i> for ExprFormatter<'_, W> {
373    type Output = Alignment;
374    type Error = fmt::Error;
375
376    fn visit_amount(&mut self, amount: &expr::Amount<'i>) -> Result<Alignment, fmt::Error> {
377        let amount_str = rescale(amount, self.context).to_string();
378        // TODO: Implement prefix-amount.
379        if amount.commodity.is_empty() {
380            write!(self.writer, "{}", amount_str)?;
381            return Ok(Alignment::Partial(amount_str.as_str().len()));
382        }
383        write!(self.writer, "{} {}", amount_str, amount.commodity)?;
384        // Given the amount is only [0-9.], it's ok to count bytes.
385        Ok(Alignment::Complete(amount_str.as_str().len()))
386    }
387
388    fn visit_value_expr(&mut self, expr: &expr::ValueExpr<'i>) -> Result<Alignment, fmt::Error> {
389        match expr {
390            expr::ValueExpr::Amount(a) => self.visit_amount(a),
391            expr::ValueExpr::Paren(inner) => {
392                self.writer.write_char('(')?;
393                let alignment = self.visit_expr(inner)?;
394                self.writer.write_char(')')?;
395                Ok(alignment.plus(1, 1))
396            }
397        }
398    }
399
400    fn visit_unary(&mut self, expr: &expr::UnaryOpExpr<'i>) -> Result<Alignment, fmt::Error> {
401        write!(self.writer, "{}", expr.op)?;
402        self.visit_expr(&expr.expr).map(|x| x.plus(1, 0))
403    }
404
405    fn visit_binary(&mut self, expr: &expr::BinaryOpExpr<'i>) -> Result<Alignment, fmt::Error> {
406        let a1 = self.visit_expr(&expr.lhs)?;
407        write!(self.writer, " {} ", expr.op)?;
408        let a2 = self.visit_expr(&expr.rhs)?;
409        Ok(match a1.plus(0, 3) {
410            Alignment::Complete(x) => Alignment::Complete(x),
411            Alignment::Partial(x) => a2.plus(x, 0),
412        })
413    }
414}
415
416macro_rules! display_with_alignment_by_visitor {
417    ($t:ty) => {
418        impl DisplayWithAlignment for WithContext<'_, $t> {
419            fn fmt_with_alignment<W: fmt::Write>(
420                &self,
421                f: &mut W,
422            ) -> Result<Alignment, fmt::Error> {
423                self.value.accept(&mut ExprFormatter {
424                    writer: f,
425                    context: self.context,
426                })
427            }
428        }
429    };
430}
431
432display_with_alignment_by_visitor!(expr::ValueExpr<'_>);
433display_with_alignment_by_visitor!(expr::Amount<'_>);
434
435/// Returns column shift size so that the string will be located at `colsize`.
436/// At least `padding` is guaranteed to be spaced.
437fn get_column(colsize: usize, left: usize, padding: usize) -> usize {
438    if left + padding < colsize {
439        colsize - left
440    } else {
441        padding
442    }
443}
444
445fn rescale(x: &expr::Amount, context: &DisplayContext) -> PrettyDecimal {
446    let mut v = x.value;
447    if let Some(min_scale) = context.min_scale(x.commodity.as_ref()) {
448        v.as_mut().normalize_assign();
449        v.rescale(std::cmp::max(min_scale.into(), v.scale()));
450    }
451    match context.decimal_format(x.commodity.as_ref()) {
452        Some(format) => PrettyDecimal::with_format(v.value, Some(format)),
453        None => v,
454    }
455}
456
457fn print_clear_state(v: ClearState) -> &'static str {
458    match v {
459        ClearState::Uncleared => "",
460        ClearState::Cleared => "* ",
461        ClearState::Pending => "! ",
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    use std::collections::HashMap;
470
471    use maplit::hashmap;
472    use pretty_assertions::assert_eq;
473    use rust_decimal::Decimal;
474    use rust_decimal_macros::dec;
475
476    fn amount<'a, T, U>(value: T, commodity: U) -> expr::ValueExpr<'a>
477    where
478        T: Into<Decimal>,
479        U: Into<Cow<'a, str>>,
480    {
481        let value: Decimal = value.into();
482        expr::ValueExpr::Amount(expr::Amount {
483            commodity: commodity.into(),
484            value: PrettyDecimal::unformatted(value),
485        })
486    }
487
488    fn amount_expr<T: Into<Decimal>>(value: T, commodity: &'static str) -> expr::Expr<'static> {
489        let value: Decimal = value.into();
490        expr::Expr::Value(Box::new(amount(value, commodity)))
491    }
492
493    #[test]
494    fn display_ledger_entries_no_txn() {
495        let ctx = DisplayContext::default();
496        assert_eq!(
497            concat!(";this\n", ";is\n", ";a pen pineapple apple pen.\n"),
498            format!(
499                "{}",
500                ctx.as_display(&plain::LedgerStatement::Comment(TopLevelComment(
501                    Cow::Borrowed("this\nis\na pen pineapple apple pen."),
502                )))
503            )
504        );
505        assert_eq!(
506            "apply tag foo\n",
507            format!(
508                "{}",
509                ctx.as_display(&tracked::LedgerStatement::ApplyTag(ApplyTag {
510                    key: Cow::Borrowed("foo"),
511                    value: None
512                })),
513            )
514        );
515        assert_eq!(
516            "apply tag foo: bar\n",
517            format!(
518                "{}",
519                ctx.as_display(&plain::LedgerStatement::ApplyTag(ApplyTag {
520                    key: Cow::Borrowed("foo"),
521                    value: Some(MetadataValue::Text(Cow::Borrowed("bar")))
522                }))
523            ),
524        );
525        assert_eq!(
526            "apply tag foo:: 100\n",
527            format!(
528                "{}",
529                ctx.as_display(&tracked::LedgerStatement::ApplyTag(ApplyTag {
530                    key: Cow::Borrowed("foo"),
531                    value: Some(MetadataValue::Expr(Cow::Borrowed("100")))
532                }))
533            ),
534        );
535        assert_eq!(
536            "end apply tag\n",
537            format!("{}", ctx.as_display(&plain::LedgerStatement::EndApplyTag))
538        );
539    }
540
541    #[test]
542    fn display_ledger_entry_emits_blank_line_on_separation() {
543        let ctx = DisplayContext::default();
544        let entry = |separation| plain::LedgerEntry {
545            separation,
546            statement: plain::LedgerStatement::EndApplyTag,
547        };
548        assert_eq!(
549            "end apply tag\n",
550            format!("{}", ctx.as_display(&entry(Separation::Immediate)))
551        );
552        assert_eq!(
553            "\nend apply tag\n",
554            format!("{}", ctx.as_display(&entry(Separation::BlankLine)))
555        );
556    }
557
558    #[test]
559    fn display_txn() {
560        let got = format!(
561            "{}",
562            DisplayContext::default().as_display(&LedgerStatement::Txn(plain::Transaction {
563                date: NaiveDate::from_ymd_opt(2022, 12, 23).unwrap(),
564                effective_date: None,
565                clear_state: ClearState::Uncleared,
566                code: None,
567                payee: Cow::Borrowed("Example Grocery"),
568                posts: vec![Posting {
569                    account: Cow::Borrowed("Assets"),
570                    clear_state: ClearState::Uncleared,
571                    amount: Some(PostingAmount {
572                        amount: amount(dec!(123.45), "USD"),
573                        cost: None,
574                        lot: Lot::default(),
575                    }),
576                    balance: None,
577                    metadata: vec![Metadata::Comment("single-line".into()),],
578                }],
579                metadata: vec![
580                    Metadata::Comment("multi\nline\ntext\n".into()),
581                    Metadata::Comment("works\nfine".into()),
582                ],
583            }))
584        );
585        let want = concat!(
586            "2022/12/23 Example Grocery\n",
587            "    ; multi\n",
588            "    ; line\n",
589            "    ; text\n",
590            "    ; works\n",
591            "    ; fine\n",
592            "    Assets                                    123.45 USD\n",
593            "    ; single-line\n"
594        );
595        assert_eq!(want, got);
596    }
597
598    #[test]
599    fn posting_non_expr() {
600        let all = Posting {
601            amount: Some(PostingAmount {
602                amount: amount(1234, "USD"),
603                cost: Some(Exchange::Rate(amount(dec!(100.00), "JPY"))),
604                lot: plain::Lot {
605                    price: Some(Exchange::Rate(amount(dec!(1.1), "USD"))),
606                    date: Some(NaiveDate::from_ymd_opt(2022, 5, 20).unwrap()),
607                    note: Some(Cow::Borrowed("printable note")),
608                },
609            }),
610            balance: Some(amount(1234, "USD")),
611            ..Posting::new_untracked("Account")
612        };
613        let costbalance = Posting {
614            amount: Some(PostingAmount {
615                amount: amount(1234, "USD"),
616                cost: Some(Exchange::Rate(amount(100, "JPY"))),
617                lot: plain::Lot::default(),
618            }),
619            balance: Some(amount(1234, "USD")),
620            ..Posting::new_untracked("Account")
621        };
622        let total = Posting {
623            amount: Some(PostingAmount {
624                amount: amount(1234, "USD"),
625                cost: Some(Exchange::Total(amount(100, "JPY"))),
626                lot: plain::Lot::default(),
627            }),
628            ..Posting::new_untracked("Account")
629        };
630        let nocost = Posting {
631            amount: Some(PostingAmount {
632                amount: amount(1234, "USD"),
633                cost: None,
634                lot: plain::Lot::default(),
635            }),
636            balance: Some(amount(1234, "USD")),
637            ..Posting::new_untracked("Account")
638        };
639        let noamount = plain::Posting {
640            amount: None,
641            balance: Some(amount(1234, "USD")),
642            ..Posting::new_untracked("Account")
643        };
644        let zerobalance = plain::Posting {
645            amount: None,
646            balance: Some(amount(0, "")),
647            ..Posting::new_untracked("Account")
648        };
649
650        assert_eq!(
651            concat!(
652                //       10        20        30        40        50        60        70
653                // 34567890123456789012345678901234567890123456789012345678901234567890
654                "    Account                                     1234 USD {1.1 USD} [2022/05/20] (printable note) @ 100.00 JPY = 1234 USD\n",
655                "    Account                                     1234 USD @ 100 JPY = 1234 USD\n",
656                "    Account                                     1234 USD @@ 100 JPY\n",
657                "    Account                                     1234 USD = 1234 USD\n",
658                "    Account                                              = 1234 USD\n",
659                // we don't have shared state to determine where = should be aligned
660                "    Account                                          = 0\n"
661            ),
662            format!(
663                "{}{}{}{}{}{}",
664                DisplayContext::default().as_display(&all),
665                DisplayContext::default().as_display(&costbalance),
666                DisplayContext::default().as_display(&total),
667                DisplayContext::default().as_display(&nocost),
668                DisplayContext::default().as_display(&noamount),
669                DisplayContext::default().as_display(&zerobalance),
670            ),
671        );
672
673        // overrides only
674        let ctx = DisplayContext::new(
675            CommodityDisplayOption::default(),
676            hashmap! {"USD".to_string() => CommodityDisplayOption {format: Some(pretty_decimal::Format::Comma3Dot), min_scale: Some(4)}},
677        );
678        assert_eq!(
679            concat!(
680                //       10        20        30        40        50        60        70
681                // 34567890123456789012345678901234567890123456789012345678901234567890
682                "    Account                               1,234.0000 USD {1.1000 USD} [2022/05/20] (printable note) @ 100.00 JPY = 1,234.0000 USD\n",
683                "    Account                               1,234.0000 USD @ 100 JPY = 1,234.0000 USD\n",
684                "    Account                               1,234.0000 USD @@ 100 JPY\n",
685                "    Account                               1,234.0000 USD = 1,234.0000 USD\n",
686                "    Account                                              = 1,234.0000 USD\n",
687                "    Account                                          = 0\n"
688            ),
689            format!(
690                "{}{}{}{}{}{}",
691                ctx.as_display(&all),
692                ctx.as_display(&costbalance),
693                ctx.as_display(&total),
694                ctx.as_display(&nocost),
695                ctx.as_display(&noamount),
696                ctx.as_display(&zerobalance),
697            ),
698        );
699    }
700
701    #[test]
702    fn fmt_posting_comma_3_dot() {
703        let ctx = DisplayContext::default();
704        let large = plain::Posting {
705            amount: Some(
706                expr::ValueExpr::Amount(expr::Amount {
707                    commodity: Cow::Borrowed("JPY"),
708                    value: PrettyDecimal::comma3dot(dec!(1_234_567)),
709                })
710                .into(),
711            ),
712            ..Posting::new_untracked("Account")
713        };
714        let small = plain::Posting {
715            amount: Some(
716                expr::ValueExpr::Amount(expr::Amount {
717                    commodity: Cow::Borrowed("JPY"),
718                    value: PrettyDecimal::comma3dot(dec!(0.0011)),
719                })
720                .into(),
721            ),
722            ..Posting::new_untracked("Account")
723        };
724
725        assert_eq!(
726            concat!(
727                //       10        20        30        40        50        60        70
728                // 34567890123456789012345678901234567890123456789012345678901234567890
729                "    Account                                1,234,567 JPY\n",
730                "    Account                                   0.0011 JPY\n",
731            ),
732            format!("{}{}", ctx.as_display(&large), ctx.as_display(&small),),
733        );
734    }
735
736    #[test]
737    fn fmt_with_alignment_simple_amount_without_commodity() {
738        let mut buffer = String::new();
739        let alignment = DisplayContext::default()
740            .as_display(&amount(123i8, ""))
741            .fmt_with_alignment(&mut buffer)
742            .unwrap();
743        assert_eq!("123", buffer.as_str());
744        assert_eq!(Alignment::Partial(3), alignment);
745    }
746
747    #[test]
748    fn fmt_with_alignment_simple_amount_with_commodity() {
749        // no format, no min_scale
750        let mut buffer = String::new();
751        let usd1234 = amount(1234i16, "USD");
752        let alignment = DisplayContext::default()
753            .as_display(&usd1234)
754            .fmt_with_alignment(&mut buffer)
755            .unwrap();
756        assert_eq!("1234 USD", buffer.as_str());
757        assert_eq!(Alignment::Complete(4), alignment);
758
759        // min_scale
760        buffer.clear();
761        let alignment = DisplayContext::new(
762            CommodityDisplayOption {
763                format: None,
764                min_scale: Some(2),
765            },
766            HashMap::new(),
767        )
768        .as_display(&usd1234)
769        .fmt_with_alignment(&mut buffer)
770        .unwrap();
771        assert_eq!("1234.00 USD", buffer.as_str());
772        assert_eq!(Alignment::Complete(7), alignment);
773
774        buffer.clear();
775        let alignment = DisplayContext::new(
776            CommodityDisplayOption {
777                format: Some(pretty_decimal::Format::Comma3Dot),
778                min_scale: Some(2),
779            },
780            HashMap::new(),
781        )
782        .as_display(&usd1234)
783        .fmt_with_alignment(&mut buffer)
784        .unwrap();
785        assert_eq!("1,234.00 USD", buffer.as_str());
786        assert_eq!(Alignment::Complete(8), alignment);
787    }
788
789    #[test]
790    fn test_fmt_with_alignment_complex_expr() {
791        // ((1.20 + 2.67) * 3.1 USD + 5 USD)
792        let expr = expr::ValueExpr::Paren(expr::Expr::Binary(expr::BinaryOpExpr {
793            lhs: Box::new(expr::Expr::Binary(expr::BinaryOpExpr {
794                lhs: Box::new(expr::Expr::Value(Box::new(expr::ValueExpr::Paren(
795                    expr::Expr::Binary(expr::BinaryOpExpr {
796                        lhs: Box::new(amount_expr(dec!(1.20), "")),
797                        op: expr::BinaryOp::Add,
798                        rhs: Box::new(amount_expr(dec!(2.67), "")),
799                    }),
800                )))),
801                op: expr::BinaryOp::Mul,
802                rhs: Box::new(amount_expr(dec!(3.1), "USD")),
803            })),
804            op: expr::BinaryOp::Add,
805            rhs: Box::new(amount_expr(5i32, "USD")),
806        }));
807        let mut got = String::new();
808        let alignment = DisplayContext::default()
809            .as_display(&expr)
810            .fmt_with_alignment(&mut got)
811            .unwrap();
812        assert_eq!("((1.20 + 2.67) * 3.1 USD + 5 USD)", got.as_str());
813        assert_eq!(Alignment::Complete(20), alignment);
814    }
815}