1mod align;
7mod amount;
8mod directives;
9mod helpers;
10mod transaction;
11
12pub use align::{Alignment, FormatLine, render_lines, resolve_alignment};
13pub(crate) use amount::{format_amount_with, format_cost_spec, format_price_annotation};
14use directives::{
15 format_balance_lines, format_close_lines, format_commodity_lines, format_custom_lines,
16 format_document_lines, format_event_lines, format_note_lines, format_open_lines,
17 format_pad_lines, format_price_lines, format_query_lines,
18};
19pub(crate) use helpers::format_meta_value;
20pub use helpers::{escape_csv, escape_json, escape_string};
21pub(crate) use transaction::{format_incomplete_amount, format_transaction_lines};
22pub use transaction::{format_posting_line, posting_format_line};
23
24use crate::Directive;
25
26#[derive(Debug, Clone)]
28pub struct FormatConfig {
29 pub alignment: Alignment,
32 pub indent: String,
34 pub number_display: Option<crate::DisplayContext>,
50}
51
52impl Default for FormatConfig {
53 fn default() -> Self {
54 Self {
55 alignment: Alignment::default(),
56 indent: " ".to_string(),
57 number_display: None,
58 }
59 }
60}
61
62impl FormatConfig {
63 #[must_use]
66 pub fn with_column(column: usize) -> Self {
67 Self {
68 alignment: Alignment::CurrencyColumn(column),
69 ..Self::default()
70 }
71 }
72
73 #[must_use]
75 pub fn with_indent(indent_width: usize) -> Self {
76 Self {
77 indent: " ".repeat(indent_width),
78 ..Self::default()
79 }
80 }
81
82 #[must_use]
84 pub fn new(column: usize, indent_width: usize) -> Self {
85 Self {
86 alignment: Alignment::CurrencyColumn(column),
87 indent: " ".repeat(indent_width),
88 ..Self::default()
89 }
90 }
91}
92
93#[must_use]
100pub fn format_directive_lines(directive: &Directive, config: &FormatConfig) -> Vec<FormatLine> {
101 match directive {
102 Directive::Transaction(txn) => format_transaction_lines(txn, config),
103 Directive::Balance(bal) => format_balance_lines(bal, config),
104 Directive::Open(open) => format_open_lines(open, config),
105 Directive::Close(close) => format_close_lines(close, config),
106 Directive::Commodity(comm) => format_commodity_lines(comm, config),
107 Directive::Pad(pad) => format_pad_lines(pad, config),
108 Directive::Event(event) => format_event_lines(event, config),
109 Directive::Query(query) => format_query_lines(query, config),
110 Directive::Note(note) => format_note_lines(note, config),
111 Directive::Document(doc) => format_document_lines(doc, config),
112 Directive::Price(price) => format_price_lines(price, config),
113 Directive::Custom(custom) => format_custom_lines(custom, config),
114 }
115}
116
117#[must_use]
131pub fn render_number(
132 number: rust_decimal::Decimal,
133 currency: &str,
134 config: &FormatConfig,
135) -> String {
136 match &config.number_display {
137 Some(ctx) => ctx.format_plain(number, currency),
138 None => number.to_string(),
139 }
140}
141
142#[must_use]
168pub fn format_directives<'a, I>(directives: I, config: &FormatConfig) -> String
169where
170 I: IntoIterator<Item = &'a Directive>,
171{
172 let mut lines: Vec<FormatLine> = Vec::new();
173 for directive in directives {
174 lines.extend(format_directive_lines(directive, config));
175 }
176 render_lines(&lines, &config.alignment)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::directives::{
182 format_balance, format_close, format_commodity, format_custom, format_document,
183 format_event, format_note, format_open, format_pad, format_price, format_query,
184 };
185 use super::transaction::{format_posting, format_transaction};
186 use super::*;
187 use crate::{
188 Amount, Balance, Close, Commodity, CostSpec, Custom, Directive, Document, Event,
189 IncompleteAmount, MetaValue, Metadata, NaiveDate, Note, Open, Pad, Posting, Price,
190 PriceAnnotation, Query, Transaction,
191 };
192 use rust_decimal_macros::dec;
193
194 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
195 crate::naive_date(year, month, day).unwrap()
196 }
197
198 #[test]
199 fn test_format_simple_transaction() {
200 let txn = Transaction::new(date(2024, 1, 15), "Morning coffee")
201 .with_flag('*')
202 .with_payee("Coffee Shop")
203 .with_synthesized_posting(Posting::new(
204 "Expenses:Food:Coffee",
205 Amount::new(dec!(5.00), "USD"),
206 ))
207 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-5.00), "USD")));
208
209 let config = FormatConfig::with_column(50);
210 let formatted = format_transaction(&txn, &config);
211
212 assert!(formatted.contains("2024-01-15 * \"Coffee Shop\" \"Morning coffee\""));
213 assert!(formatted.contains("Expenses:Food:Coffee"));
214 assert!(formatted.contains("5.00 USD"));
215 }
216
217 #[test]
218 fn test_format_balance() {
219 let bal = Balance::new(
220 date(2024, 1, 1),
221 "Assets:Bank",
222 Amount::new(dec!(1000.00), "USD"),
223 );
224 let config = FormatConfig::default();
225 let formatted = format_balance(&bal, &config);
226 assert_eq!(formatted, "2024-01-01 balance Assets:Bank 1000.00 USD\n");
229 }
230
231 #[test]
232 fn test_format_open() {
233 let open = Open {
234 date: date(2024, 1, 1),
235 account: "Assets:Bank:Checking".into(),
236 currencies: vec!["USD".into(), "EUR".into()],
237 booking: None,
238 meta: Default::default(),
239 };
240 let config = FormatConfig::default();
241 let formatted = format_open(&open, &config);
242 assert_eq!(formatted, "2024-01-01 open Assets:Bank:Checking USD,EUR\n");
243 }
244
245 #[test]
246 fn test_escape_csv() {
247 assert_eq!(escape_csv("plain"), "plain");
248 assert_eq!(escape_csv("a,b"), "\"a,b\"");
249 assert_eq!(escape_csv("say \"hi\""), "\"say \"\"hi\"\"\"");
250 assert_eq!(escape_csv("line1\nline2"), "\"line1\nline2\"");
251 assert_eq!(escape_csv(""), "");
252 }
253
254 #[test]
255 fn test_escape_string() {
256 assert_eq!(escape_string("hello"), "hello");
257 assert_eq!(escape_string("say \"hi\""), "say \\\"hi\\\"");
258 assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
259 }
260
261 #[test]
266 fn test_escape_string_combined() {
267 assert_eq!(
269 escape_string("path\\to\\file\n\"quoted\""),
270 "path\\\\to\\\\file\\n\\\"quoted\\\""
271 );
272 }
273
274 #[test]
275 fn test_escape_string_backslash_quote() {
276 assert_eq!(escape_string("\\\""), "\\\\\\\"");
278 }
279
280 #[test]
281 fn test_escape_string_empty() {
282 assert_eq!(escape_string(""), "");
283 }
284
285 #[test]
286 fn test_escape_string_unicode() {
287 assert_eq!(escape_string("café résumé"), "café résumé");
288 assert_eq!(escape_string("日本語"), "日本語");
289 assert_eq!(escape_string("emoji 🎉"), "emoji 🎉");
290 }
291
292 #[test]
293 fn test_format_meta_value_string() {
294 let val = MetaValue::String("hello world".to_string());
295 assert_eq!(
296 format_meta_value(&val, &FormatConfig::default()),
297 "\"hello world\""
298 );
299 }
300
301 #[test]
302 fn test_format_meta_value_string_with_quotes() {
303 let val = MetaValue::String("say \"hello\"".to_string());
304 assert_eq!(
305 format_meta_value(&val, &FormatConfig::default()),
306 "\"say \\\"hello\\\"\""
307 );
308 }
309
310 #[test]
311 fn test_format_meta_value_account() {
312 let val = MetaValue::Account("Assets:Bank:Checking".into());
313 assert_eq!(
314 format_meta_value(&val, &FormatConfig::default()),
315 "Assets:Bank:Checking"
316 );
317 }
318
319 #[test]
320 fn test_format_meta_value_currency() {
321 let val = MetaValue::Currency("USD".into());
322 assert_eq!(format_meta_value(&val, &FormatConfig::default()), "USD");
323 }
324
325 #[test]
326 fn test_format_meta_value_tag() {
327 let val = MetaValue::Tag("trip-2024".into());
328 assert_eq!(
329 format_meta_value(&val, &FormatConfig::default()),
330 "#trip-2024"
331 );
332 }
333
334 #[test]
335 fn test_format_meta_value_link() {
336 let val = MetaValue::Link("invoice-123".into());
337 assert_eq!(
338 format_meta_value(&val, &FormatConfig::default()),
339 "^invoice-123"
340 );
341 }
342
343 #[test]
344 fn test_format_meta_value_date() {
345 let val = MetaValue::Date(date(2024, 6, 15));
346 assert_eq!(
347 format_meta_value(&val, &FormatConfig::default()),
348 "2024-06-15"
349 );
350 }
351
352 #[test]
353 fn test_format_meta_value_number() {
354 let val = MetaValue::Number(dec!(123.456));
355 assert_eq!(format_meta_value(&val, &FormatConfig::default()), "123.456");
356 }
357
358 #[test]
359 fn test_format_meta_value_amount() {
360 let val = MetaValue::Amount(Amount::new(dec!(99.99), "USD"));
361 assert_eq!(
362 format_meta_value(&val, &FormatConfig::default()),
363 "99.99 USD"
364 );
365 }
366
367 #[test]
368 fn test_format_meta_value_bool_true() {
369 let val = MetaValue::Bool(true);
370 assert_eq!(format_meta_value(&val, &FormatConfig::default()), "TRUE");
371 }
372
373 #[test]
374 fn test_format_meta_value_bool_false() {
375 let val = MetaValue::Bool(false);
376 assert_eq!(format_meta_value(&val, &FormatConfig::default()), "FALSE");
377 }
378
379 #[test]
380 fn test_format_meta_value_none() {
381 let val = MetaValue::None;
382 assert_eq!(format_meta_value(&val, &FormatConfig::default()), "");
383 }
384
385 #[test]
386 fn test_format_cost_spec_per_unit() {
387 let spec = CostSpec {
388 number: Some(crate::CostNumber::PerUnit {
389 value: dec!(150.00),
390 }),
391 currency: Some("USD".into()),
392 date: None,
393 label: None,
394 merge: false,
395 };
396 assert_eq!(
397 format_cost_spec(&spec, &FormatConfig::default()),
398 "{150.00 USD}"
399 );
400 }
401
402 #[test]
403 fn test_format_cost_spec_total() {
404 let spec = CostSpec {
405 number: Some(crate::CostNumber::Total {
406 value: dec!(1500.00),
407 }),
408 currency: Some("USD".into()),
409 date: None,
410 label: None,
411 merge: false,
412 };
413 assert_eq!(
414 format_cost_spec(&spec, &FormatConfig::default()),
415 "{{1500.00 USD}}"
416 );
417 }
418
419 #[test]
420 fn test_format_cost_spec_with_date() {
421 let spec = CostSpec {
422 number: Some(crate::CostNumber::PerUnit {
423 value: dec!(150.00),
424 }),
425 currency: Some("USD".into()),
426 date: Some(date(2024, 1, 15)),
427 label: None,
428 merge: false,
429 };
430 assert_eq!(
431 format_cost_spec(&spec, &FormatConfig::default()),
432 "{150.00 USD, 2024-01-15}"
433 );
434 }
435
436 #[test]
437 fn test_format_cost_spec_with_label() {
438 let spec = CostSpec {
439 number: Some(crate::CostNumber::PerUnit {
440 value: dec!(150.00),
441 }),
442 currency: Some("USD".into()),
443 date: None,
444 label: Some("lot-a".to_string()),
445 merge: false,
446 };
447 assert_eq!(
448 format_cost_spec(&spec, &FormatConfig::default()),
449 "{150.00 USD, \"lot-a\"}"
450 );
451 }
452
453 #[test]
454 fn test_format_cost_spec_with_merge() {
455 let spec = CostSpec {
456 number: Some(crate::CostNumber::PerUnit {
457 value: dec!(150.00),
458 }),
459 currency: Some("USD".into()),
460 date: None,
461 label: None,
462 merge: true,
463 };
464 assert_eq!(
465 format_cost_spec(&spec, &FormatConfig::default()),
466 "{150.00 USD, *}"
467 );
468 }
469
470 #[test]
471 fn test_format_cost_spec_all_fields() {
472 let spec = CostSpec {
473 number: Some(crate::CostNumber::PerUnit {
474 value: dec!(150.00),
475 }),
476 currency: Some("USD".into()),
477 date: Some(date(2024, 1, 15)),
478 label: Some("lot-a".to_string()),
479 merge: true,
480 };
481 assert_eq!(
482 format_cost_spec(&spec, &FormatConfig::default()),
483 "{150.00 USD, 2024-01-15, \"lot-a\", *}"
484 );
485 }
486
487 #[test]
488 fn test_format_cost_spec_empty() {
489 let spec = CostSpec {
490 number: None,
491 currency: None,
492 date: None,
493 label: None,
494 merge: false,
495 };
496 assert_eq!(format_cost_spec(&spec, &FormatConfig::default()), "{}");
497 }
498
499 #[test]
500 fn test_format_price_annotation_unit() {
501 let price = PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"));
502 assert_eq!(
503 format_price_annotation(&price, &FormatConfig::default()),
504 "@ 150.00 USD"
505 );
506 }
507
508 #[test]
509 fn test_format_price_annotation_total() {
510 let price = PriceAnnotation::total(Amount::new(dec!(1500.00), "USD"));
511 assert_eq!(
512 format_price_annotation(&price, &FormatConfig::default()),
513 "@@ 1500.00 USD"
514 );
515 }
516
517 #[test]
518 fn test_format_price_annotation_unit_incomplete() {
519 let price = PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(150.00)));
520 assert_eq!(
521 format_price_annotation(&price, &FormatConfig::default()),
522 "@ 150.00"
523 );
524 }
525
526 #[test]
527 fn test_format_price_annotation_total_incomplete() {
528 let price = PriceAnnotation::total_incomplete(IncompleteAmount::CurrencyOnly("USD".into()));
529 assert_eq!(
530 format_price_annotation(&price, &FormatConfig::default()),
531 "@@ USD"
532 );
533 }
534
535 #[test]
536 fn test_format_price_annotation_unit_empty() {
537 let price = PriceAnnotation::unit_empty();
538 assert_eq!(
539 format_price_annotation(&price, &FormatConfig::default()),
540 "@"
541 );
542 }
543
544 #[test]
545 fn test_format_price_annotation_total_empty() {
546 let price = PriceAnnotation::total_empty();
547 assert_eq!(
548 format_price_annotation(&price, &FormatConfig::default()),
549 "@@"
550 );
551 }
552
553 #[test]
554 fn test_format_incomplete_amount_complete() {
555 let amount = IncompleteAmount::Complete(Amount::new(dec!(100.50), "EUR"));
556 assert_eq!(
557 format_incomplete_amount(&amount, &FormatConfig::default()),
558 "100.50 EUR"
559 );
560 }
561
562 #[test]
563 fn test_format_incomplete_amount_number_only() {
564 let amount = IncompleteAmount::NumberOnly(dec!(42.00));
565 assert_eq!(
566 format_incomplete_amount(&amount, &FormatConfig::default()),
567 "42.00"
568 );
569 }
570
571 #[test]
572 fn test_format_incomplete_amount_currency_only() {
573 let amount = IncompleteAmount::CurrencyOnly("BTC".into());
574 assert_eq!(
575 format_incomplete_amount(&amount, &FormatConfig::default()),
576 "BTC"
577 );
578 }
579
580 #[test]
581 fn test_format_close() {
582 let close = Close {
583 date: date(2024, 12, 31),
584 account: "Assets:OldAccount".into(),
585 meta: Default::default(),
586 };
587 let config = FormatConfig::default();
588 let formatted = format_close(&close, &config);
589 assert_eq!(formatted, "2024-12-31 close Assets:OldAccount\n");
590 }
591
592 #[test]
593 fn test_format_commodity() {
594 let comm = Commodity {
595 date: date(2024, 1, 1),
596 currency: "BTC".into(),
597 meta: Default::default(),
598 };
599 let config = FormatConfig::default();
600 let formatted = format_commodity(&comm, &config);
601 assert_eq!(formatted, "2024-01-01 commodity BTC\n");
602 }
603
604 #[test]
605 fn test_format_pad() {
606 let pad = Pad {
607 date: date(2024, 1, 15),
608 account: "Assets:Checking".into(),
609 source_account: "Equity:Opening-Balances".into(),
610 meta: Default::default(),
611 };
612 let config = FormatConfig::default();
613 let formatted = format_pad(&pad, &config);
614 assert_eq!(
615 formatted,
616 "2024-01-15 pad Assets:Checking Equity:Opening-Balances\n"
617 );
618 }
619
620 #[test]
621 fn test_format_event() {
622 let event = Event {
623 date: date(2024, 6, 1),
624 event_type: "location".to_string(),
625 value: "New York".to_string(),
626 meta: Default::default(),
627 };
628 let config = FormatConfig::default();
629 let formatted = format_event(&event, &config);
630 assert_eq!(formatted, "2024-06-01 event \"location\" \"New York\"\n");
631 }
632
633 #[test]
634 fn test_format_event_with_quotes() {
635 let event = Event {
636 date: date(2024, 6, 1),
637 event_type: "quote".to_string(),
638 value: "He said \"hello\"".to_string(),
639 meta: Default::default(),
640 };
641 let config = FormatConfig::default();
642 let formatted = format_event(&event, &config);
643 assert_eq!(
644 formatted,
645 "2024-06-01 event \"quote\" \"He said \\\"hello\\\"\"\n"
646 );
647 }
648
649 #[test]
650 fn test_format_query() {
651 let query = Query {
652 date: date(2024, 1, 1),
653 name: "monthly_expenses".to_string(),
654 query: "SELECT account, sum(position) WHERE account ~ 'Expenses'".to_string(),
655 meta: Default::default(),
656 };
657 let config = FormatConfig::default();
658 let formatted = format_query(&query, &config);
659 assert!(formatted.contains("query \"monthly_expenses\""));
660 assert!(formatted.contains("SELECT account"));
661 }
662
663 #[test]
664 fn test_format_note() {
665 let note = Note {
666 date: date(2024, 3, 15),
667 account: "Assets:Bank".into(),
668 comment: "Called the bank about fee".to_string(),
669 tags: Vec::new(),
670 links: Vec::new(),
671 meta: Default::default(),
672 };
673 let config = FormatConfig::default();
674 let formatted = format_note(¬e, &config);
675 assert_eq!(
676 formatted,
677 "2024-03-15 note Assets:Bank \"Called the bank about fee\"\n"
678 );
679 }
680
681 #[test]
682 fn test_format_document() {
683 let doc = Document {
684 date: date(2024, 2, 10),
685 account: "Assets:Bank".into(),
686 path: "/docs/statement-2024-02.pdf".to_string(),
687 tags: vec![],
688 links: vec![],
689 meta: Default::default(),
690 };
691 let config = FormatConfig::default();
692 let formatted = format_document(&doc, &config);
693 assert_eq!(
694 formatted,
695 "2024-02-10 document Assets:Bank \"/docs/statement-2024-02.pdf\"\n"
696 );
697 }
698
699 #[test]
700 fn test_format_price() {
701 let price = Price {
702 date: date(2024, 1, 15),
703 currency: "AAPL".into(),
704 amount: Amount::new(dec!(185.50), "USD"),
705 meta: Default::default(),
706 };
707 let config = FormatConfig::default();
708 let formatted = format_price(&price, &config);
709 assert_eq!(formatted, "2024-01-15 price AAPL 185.50 USD\n");
710 }
711
712 #[test]
713 fn test_format_custom() {
714 let custom = Custom {
715 date: date(2024, 1, 1),
716 custom_type: "budget".to_string(),
717 values: vec![],
718 meta: Default::default(),
719 };
720 let config = FormatConfig::default();
721 let formatted = format_custom(&custom, &config);
722 assert_eq!(formatted, "2024-01-01 custom \"budget\"\n");
723 }
724
725 #[test]
728 fn test_issue_573_format_custom_with_values() {
729 let custom = Custom {
731 date: date(2024, 1, 1),
732 custom_type: "fava-option".to_string(),
733 values: vec![
734 MetaValue::String("language".to_string()),
735 MetaValue::String("en".to_string()),
736 ],
737 meta: Default::default(),
738 };
739 let config = FormatConfig::default();
740 let formatted = format_custom(&custom, &config);
741 assert_eq!(
742 formatted,
743 "2024-01-01 custom \"fava-option\" \"language\" \"en\"\n"
744 );
745 }
746
747 #[test]
748 fn test_format_custom_with_mixed_values() {
749 let custom = Custom {
751 date: date(2024, 3, 15),
752 custom_type: "budget".to_string(),
753 values: vec![
754 MetaValue::Account("Expenses:Food".into()),
755 MetaValue::Amount(Amount::new(dec!(500), "USD")),
756 MetaValue::String("monthly".to_string()),
757 ],
758 meta: Default::default(),
759 };
760 let config = FormatConfig::default();
761 let formatted = format_custom(&custom, &config);
762 assert_eq!(
763 formatted,
764 "2024-03-15 custom \"budget\" Expenses:Food 500 USD \"monthly\"\n"
765 );
766 }
767
768 #[test]
769 fn test_format_open_with_booking() {
770 let open = Open {
771 date: date(2024, 1, 1),
772 account: "Assets:Brokerage".into(),
773 currencies: vec!["USD".into()],
774 booking: Some("FIFO".to_string()),
775 meta: Default::default(),
776 };
777 let config = FormatConfig::default();
778 let formatted = format_open(&open, &config);
779 assert_eq!(formatted, "2024-01-01 open Assets:Brokerage USD \"FIFO\"\n");
780 }
781
782 #[test]
783 fn test_format_open_no_currencies() {
784 let open = Open {
785 date: date(2024, 1, 1),
786 account: "Assets:Misc".into(),
787 currencies: vec![],
788 booking: None,
789 meta: Default::default(),
790 };
791 let config = FormatConfig::default();
792 let formatted = format_open(&open, &config);
793 assert_eq!(formatted, "2024-01-01 open Assets:Misc\n");
794 }
795
796 #[test]
797 fn test_format_balance_with_tolerance() {
798 let bal = Balance {
799 date: date(2024, 1, 1),
800 account: "Assets:Bank".into(),
801 amount: Amount::new(dec!(1000.00), "USD"),
802 tolerance: Some(dec!(0.01)),
803 meta: Default::default(),
804 };
805 let config = FormatConfig::default();
806 let formatted = format_balance(&bal, &config);
807 assert_eq!(
808 formatted,
809 "2024-01-01 balance Assets:Bank 1000.00 USD ~ 0.01\n"
810 );
811 }
812
813 #[test]
814 fn test_format_transaction_with_tags() {
815 let txn = Transaction::new(date(2024, 1, 15), "Dinner")
816 .with_flag('*')
817 .with_tag("trip-2024")
818 .with_tag("food")
819 .with_synthesized_posting(Posting::new(
820 "Expenses:Food",
821 Amount::new(dec!(50.00), "USD"),
822 ))
823 .with_synthesized_posting(Posting::new(
824 "Assets:Cash",
825 Amount::new(dec!(-50.00), "USD"),
826 ));
827
828 let config = FormatConfig::default();
829 let formatted = format_transaction(&txn, &config);
830
831 assert!(formatted.contains("#trip-2024"));
832 assert!(formatted.contains("#food"));
833 }
834
835 #[test]
836 fn test_format_transaction_with_links() {
837 let txn = Transaction::new(date(2024, 1, 15), "Invoice payment")
838 .with_flag('*')
839 .with_link("invoice-123")
840 .with_synthesized_posting(Posting::new(
841 "Income:Freelance",
842 Amount::new(dec!(-1000.00), "USD"),
843 ))
844 .with_synthesized_posting(Posting::new(
845 "Assets:Bank",
846 Amount::new(dec!(1000.00), "USD"),
847 ));
848
849 let config = FormatConfig::default();
850 let formatted = format_transaction(&txn, &config);
851
852 assert!(formatted.contains("^invoice-123"));
853 }
854
855 #[test]
856 fn test_format_transaction_with_metadata() {
857 let mut meta = Metadata::default();
858 meta.insert(
859 "filename".to_string(),
860 MetaValue::String("receipt.pdf".to_string()),
861 );
862 meta.insert("verified".to_string(), MetaValue::Bool(true));
863
864 let txn = Transaction {
865 date: date(2024, 1, 15),
866 flag: '*',
867 payee: None,
868 narration: "Purchase".into(),
869 tags: vec![],
870 links: vec![],
871 postings: vec![],
872 meta,
873 trailing_comments: Vec::new(),
874 };
875
876 let config = FormatConfig::default();
877 let formatted = format_transaction(&txn, &config);
878
879 assert!(formatted.contains("filename: \"receipt.pdf\""));
880 assert!(formatted.contains("verified: TRUE"));
881 }
882
883 #[test]
884 fn test_format_posting_with_flag() {
885 let mut posting = Posting::new("Expenses:Unknown", Amount::new(dec!(100.00), "USD"));
886 posting.flag = Some('!');
887
888 let config = FormatConfig::default();
889 let formatted = format_posting(&posting, &config);
890
891 assert!(formatted.contains("! Expenses:Unknown"));
892 }
893
894 #[test]
900 fn number_display_context_pads_without_separators() {
901 use crate::DisplayContext;
902 let mut ctx = DisplayContext::new();
903 ctx.set_fixed_precision("USD", 2);
904 ctx.set_render_commas(true);
905 let config = FormatConfig {
906 number_display: Some(ctx),
907 ..FormatConfig::default()
908 };
909
910 assert_eq!(
911 render_number(rust_decimal_macros::dec!(1234.5), "USD", &config),
912 "1234.50",
913 "fixed precision pads; separators stay a display concern"
914 );
915 assert_eq!(
916 render_number(rust_decimal_macros::dec!(7), "JPY", &config),
917 "7",
918 "untracked currencies keep natural rendering"
919 );
920 assert_eq!(
921 render_number(rust_decimal_macros::dec!(100.50), "EUR", &config),
922 "100.50",
923 "untracked currencies stay byte-faithful — no trailing-zero \
924 stripping, which would widen a balance assertion's implicit \
925 tolerance (deep review of #1807)"
926 );
927 assert_eq!(
928 render_number(
929 rust_decimal_macros::dec!(1234.5),
930 "USD",
931 &FormatConfig::default()
932 ),
933 "1234.5",
934 "no context = historical own-scale rendering"
935 );
936 }
937
938 #[test]
941 fn number_display_context_threads_through_directives() {
942 use crate::DisplayContext;
943 let mut ctx = DisplayContext::new();
944 ctx.set_fixed_precision("USD", 2);
945 ctx.set_render_commas(true);
946 let config = FormatConfig {
947 number_display: Some(ctx),
948 ..FormatConfig::default()
949 };
950
951 let bal = Balance::new(
952 crate::naive_date(2024, 1, 15).unwrap(),
953 "Assets:Bank",
954 Amount::new(rust_decimal_macros::dec!(1234.5), "USD"),
955 );
956 let out = format_directives(std::iter::once(&Directive::Balance(bal)), &config);
957 assert_eq!(
958 out, "2024-01-15 balance Assets:Bank 1234.50 USD\n",
959 "balance renders through the context (padded, no separators)"
960 );
961 }
962
963 #[test]
964 fn test_format_posting_no_units() {
965 let posting = Posting {
966 flag: None,
967 account: "Assets:Bank".into(),
968 units: None,
969 cost: None,
970 price: None,
971 meta: Default::default(),
972 comments: Vec::new(),
973 trailing_comments: Vec::new(),
974 };
975
976 let config = FormatConfig::default();
977 let formatted = format_posting(&posting, &config);
978
979 assert!(formatted.contains("Assets:Bank"));
980 assert!(!formatted.contains("USD"));
982 }
983
984 #[test]
985 fn test_format_config_with_column() {
986 let config = FormatConfig::with_column(80);
987 assert!(matches!(config.alignment, Alignment::CurrencyColumn(80)));
988 assert_eq!(config.indent, " ");
989 }
990
991 #[test]
992 fn test_format_config_with_indent() {
993 let config = FormatConfig::with_indent(4);
994 assert!(matches!(config.alignment, Alignment::Auto { .. }));
995 assert_eq!(config.indent, " ");
996 }
997
998 #[test]
999 fn test_format_config_new() {
1000 let config = FormatConfig::new(70, 3);
1001 assert!(matches!(config.alignment, Alignment::CurrencyColumn(70)));
1002 assert_eq!(config.indent, " ");
1003 }
1004
1005 #[test]
1006 fn test_format_config_default_is_auto() {
1007 let config = FormatConfig::default();
1008 assert!(matches!(
1009 config.alignment,
1010 Alignment::Auto {
1011 prefix_width: None,
1012 num_width: None
1013 }
1014 ));
1015 }
1016
1017 #[test]
1018 fn test_format_posting_long_account_name() {
1019 let posting = Posting::new(
1020 "Assets:Bank:Checking:Primary:Joint:Savings:Emergency:Fund:Extra:Long",
1021 Amount::new(dec!(100.00), "USD"),
1022 );
1023
1024 let config = FormatConfig::with_column(50);
1025 let formatted = format_posting(&posting, &config);
1026
1027 assert!(formatted.contains(" 100.00 USD"));
1029 }
1030
1031 #[test]
1032 fn test_format_posting_with_cost_and_price() {
1033 let posting = Posting {
1034 flag: None,
1035 account: "Assets:Brokerage".into(),
1036 units: Some(IncompleteAmount::Complete(Amount::new(dec!(10), "AAPL"))),
1037 cost: Some(Box::new(CostSpec {
1038 number: Some(crate::CostNumber::PerUnit {
1039 value: dec!(150.00),
1040 }),
1041 currency: Some("USD".into()),
1042 date: Some(date(2024, 1, 15)),
1043 label: None,
1044 merge: false,
1045 })),
1046 price: Some(Box::new(PriceAnnotation::unit(Amount::new(
1047 dec!(155.00),
1048 "USD",
1049 )))),
1050 meta: Default::default(),
1051 comments: Vec::new(),
1052 trailing_comments: Vec::new(),
1053 };
1054
1055 let config = FormatConfig::default();
1056 let formatted = format_posting(&posting, &config);
1057
1058 assert!(formatted.contains("10 AAPL"));
1059 assert!(formatted.contains("{150.00 USD, 2024-01-15}"));
1060 assert!(formatted.contains("@ 155.00 USD"));
1061 }
1062
1063 #[test]
1064 fn test_format_directives_all_types() {
1065 let config = FormatConfig::default();
1066
1067 let txn = Transaction::new(date(2024, 1, 1), "Test")
1069 .with_flag('*')
1070 .with_synthesized_posting(Posting::new("Expenses:Test", Amount::new(dec!(1), "USD")))
1071 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1), "USD")));
1072 let formatted = format_directives([&Directive::Transaction(txn)], &config);
1073 assert!(formatted.contains("2024-01-01"));
1074
1075 let bal = Balance::new(
1077 date(2024, 1, 1),
1078 "Assets:Bank",
1079 Amount::new(dec!(100), "USD"),
1080 );
1081 let formatted = format_directives([&Directive::Balance(bal)], &config);
1082 assert!(formatted.contains("balance"));
1083
1084 let open = Open {
1086 date: date(2024, 1, 1),
1087 account: "Assets:Test".into(),
1088 currencies: vec![],
1089 booking: None,
1090 meta: Default::default(),
1091 };
1092 let formatted = format_directives([&Directive::Open(open)], &config);
1093 assert!(formatted.contains("open"));
1094
1095 let close = Close {
1097 date: date(2024, 1, 1),
1098 account: "Assets:Test".into(),
1099 meta: Default::default(),
1100 };
1101 let formatted = format_directives([&Directive::Close(close)], &config);
1102 assert!(formatted.contains("close"));
1103
1104 let comm = Commodity {
1106 date: date(2024, 1, 1),
1107 currency: "BTC".into(),
1108 meta: Default::default(),
1109 };
1110 let formatted = format_directives([&Directive::Commodity(comm)], &config);
1111 assert!(formatted.contains("commodity"));
1112
1113 let pad = Pad {
1115 date: date(2024, 1, 1),
1116 account: "Assets:A".into(),
1117 source_account: "Equity:B".into(),
1118 meta: Default::default(),
1119 };
1120 let formatted = format_directives([&Directive::Pad(pad)], &config);
1121 assert!(formatted.contains("pad"));
1122
1123 let event = Event {
1125 date: date(2024, 1, 1),
1126 event_type: "test".to_string(),
1127 value: "value".to_string(),
1128 meta: Default::default(),
1129 };
1130 let formatted = format_directives([&Directive::Event(event)], &config);
1131 assert!(formatted.contains("event"));
1132
1133 let query = Query {
1135 date: date(2024, 1, 1),
1136 name: "test".to_string(),
1137 query: "SELECT *".to_string(),
1138 meta: Default::default(),
1139 };
1140 let formatted = format_directives([&Directive::Query(query)], &config);
1141 assert!(formatted.contains("query"));
1142
1143 let note = Note {
1145 date: date(2024, 1, 1),
1146 account: "Assets:Bank".into(),
1147 comment: "test".to_string(),
1148 tags: Vec::new(),
1149 links: Vec::new(),
1150 meta: Default::default(),
1151 };
1152 let formatted = format_directives([&Directive::Note(note)], &config);
1153 assert!(formatted.contains("note"));
1154
1155 let doc = Document {
1157 date: date(2024, 1, 1),
1158 account: "Assets:Bank".into(),
1159 path: "/path".to_string(),
1160 tags: vec![],
1161 links: vec![],
1162 meta: Default::default(),
1163 };
1164 let formatted = format_directives([&Directive::Document(doc)], &config);
1165 assert!(formatted.contains("document"));
1166
1167 let price = Price {
1169 date: date(2024, 1, 1),
1170 currency: "AAPL".into(),
1171 amount: Amount::new(dec!(150), "USD"),
1172 meta: Default::default(),
1173 };
1174 let formatted = format_directives([&Directive::Price(price)], &config);
1175 assert!(formatted.contains("price"));
1176
1177 let custom = Custom {
1179 date: date(2024, 1, 1),
1180 custom_type: "test".to_string(),
1181 values: vec![],
1182 meta: Default::default(),
1183 };
1184 let formatted = format_directives([&Directive::Custom(custom)], &config);
1185 assert!(formatted.contains("custom"));
1186 }
1187
1188 #[test]
1189 fn test_format_amount_negative() {
1190 let amount = Amount::new(dec!(-100.50), "USD");
1191 assert_eq!(
1192 format_amount_with(&amount, &FormatConfig::default()),
1193 "-100.50 USD"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_format_amount_zero() {
1199 let amount = Amount::new(dec!(0), "EUR");
1200 assert_eq!(
1201 format_amount_with(&amount, &FormatConfig::default()),
1202 "0 EUR"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_format_amount_large_number() {
1208 let amount = Amount::new(dec!(1234567890.12), "USD");
1209 assert_eq!(
1210 format_amount_with(&amount, &FormatConfig::default()),
1211 "1234567890.12 USD"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_format_amount_small_decimal() {
1217 let amount = Amount::new(dec!(0.00001), "BTC");
1218 assert_eq!(
1219 format_amount_with(&amount, &FormatConfig::default()),
1220 "0.00001 BTC"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_format_transaction_with_inline_comment() {
1226 let config = FormatConfig::default();
1227
1228 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1230 posting.comments = vec!["; This is an inline comment".to_string()];
1231
1232 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1233 .with_flag('*')
1234 .with_synthesized_posting(posting)
1235 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")));
1236
1237 let formatted = format_transaction(&txn, &config);
1238
1239 assert!(
1241 formatted.contains("; This is an inline comment"),
1242 "Formatted transaction should contain inline comment: {formatted}"
1243 );
1244 let comment_pos = formatted.find("; This is an inline comment").unwrap();
1246 let expenses_pos = formatted.find("Expenses:Food").unwrap();
1247 assert!(
1248 comment_pos < expenses_pos,
1249 "Comment should appear before the posting"
1250 );
1251 }
1252
1253 #[test]
1255 fn test_issue_364_format_all_comment_types() {
1256 let config = FormatConfig::default();
1257
1258 let mut posting1 = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1260 posting1.comments = vec!["; Pre-comment 1".to_string(), "; Pre-comment 2".to_string()];
1261 posting1.trailing_comments = vec!["; trailing on posting".to_string()];
1262
1263 let mut posting2 = Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD"));
1265 posting2.comments = vec!["; Comment before second posting".to_string()];
1266
1267 let mut txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1269 .with_flag('*')
1270 .with_synthesized_posting(posting1)
1271 .with_synthesized_posting(posting2);
1272 txn.trailing_comments = vec![
1273 "; Transaction trailing 1".to_string(),
1274 "; Transaction trailing 2".to_string(),
1275 ];
1276
1277 let formatted = format_transaction(&txn, &config);
1278
1279 let lines: Vec<&str> = formatted.lines().collect();
1281
1282 assert!(lines[0].contains("2024-01-15 * \"Test transaction\""));
1284
1285 assert_eq!(lines[1].trim(), "; Pre-comment 1");
1287 assert_eq!(lines[2].trim(), "; Pre-comment 2");
1288
1289 assert!(lines[3].contains("Expenses:Food"));
1291 assert!(lines[3].contains("; trailing on posting"));
1292
1293 assert_eq!(lines[4].trim(), "; Comment before second posting");
1295
1296 assert!(lines[5].contains("Assets:Bank"));
1298
1299 assert_eq!(lines[6].trim(), "; Transaction trailing 1");
1301 assert_eq!(lines[7].trim(), "; Transaction trailing 2");
1302 }
1303
1304 #[test]
1306 fn test_issue_364_trailing_comment_on_posting_line() {
1307 let config = FormatConfig::default();
1308
1309 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1310 posting.trailing_comments = vec!["; This goes on same line".to_string()];
1311
1312 let txn = Transaction::new(date(2024, 1, 15), "Test")
1313 .with_flag('*')
1314 .with_synthesized_posting(posting)
1315 .with_synthesized_posting(Posting::auto("Assets:Bank"));
1316
1317 let formatted = format_transaction(&txn, &config);
1318
1319 for line in formatted.lines() {
1321 if line.contains("Expenses:Food") {
1322 assert!(
1323 line.contains("; This goes on same line"),
1324 "Trailing comment should be on same line as posting: {line}"
1325 );
1326 break;
1327 }
1328 }
1329 }
1330
1331 #[test]
1332 fn test_format_posting_metadata_issue_701() {
1333 let mut posting_meta = Metadata::default();
1335 posting_meta.insert(
1336 "note".to_string(),
1337 MetaValue::String("this note is lost".to_string()),
1338 );
1339
1340 let mut posting = Posting::new("Expenses:Expense", Amount::new(dec!(10), "USD"));
1341 posting.meta = posting_meta;
1342
1343 let txn = Transaction {
1344 date: date(2026, 4, 7),
1345 flag: '*',
1346 payee: None,
1347 narration: "my expense".into(),
1348 tags: vec![],
1349 links: vec![],
1350 postings: vec![
1351 crate::Spanned::synthesized(posting),
1352 crate::Spanned::synthesized(Posting::auto("Assets:Wallet")),
1353 ],
1354 meta: Metadata::default(),
1355 trailing_comments: Vec::new(),
1356 };
1357
1358 let config = FormatConfig::default();
1359 let formatted = format_transaction(&txn, &config);
1360
1361 assert!(
1362 formatted.contains("note: \"this note is lost\""),
1363 "posting metadata should be preserved in formatted output, got:\n{formatted}"
1364 );
1365 assert!(
1367 formatted.contains(" note:"),
1368 "posting metadata should have double indent (4 spaces), got:\n{formatted}"
1369 );
1370 }
1371}