1use super::*;
4
5use decoration::AsUndecorated;
6
7use crate::utility;
8
9use pretty_decimal::PrettyDecimal;
10use unicode_width::UnicodeWidthStr;
11
12#[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 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
47pub 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#[derive(Debug, PartialEq, Copy, Clone)]
280enum Alignment {
281 Partial(usize),
283 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
316impl 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 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 Ok(Alignment::Complete(amount_str.as_str().len()))
371 }
372}
373
374fn 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 " 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 " 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 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 " 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 " 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 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 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 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}