okane_core/syntax/
display.rs

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