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 meta: Default::default(),
670 };
671 let config = FormatConfig::default();
672 let formatted = format_note(¬e, &config);
673 assert_eq!(
674 formatted,
675 "2024-03-15 note Assets:Bank \"Called the bank about fee\"\n"
676 );
677 }
678
679 #[test]
680 fn test_format_document() {
681 let doc = Document {
682 date: date(2024, 2, 10),
683 account: "Assets:Bank".into(),
684 path: "/docs/statement-2024-02.pdf".to_string(),
685 tags: vec![],
686 links: vec![],
687 meta: Default::default(),
688 };
689 let config = FormatConfig::default();
690 let formatted = format_document(&doc, &config);
691 assert_eq!(
692 formatted,
693 "2024-02-10 document Assets:Bank \"/docs/statement-2024-02.pdf\"\n"
694 );
695 }
696
697 #[test]
698 fn test_format_price() {
699 let price = Price {
700 date: date(2024, 1, 15),
701 currency: "AAPL".into(),
702 amount: Amount::new(dec!(185.50), "USD"),
703 meta: Default::default(),
704 };
705 let config = FormatConfig::default();
706 let formatted = format_price(&price, &config);
707 assert_eq!(formatted, "2024-01-15 price AAPL 185.50 USD\n");
708 }
709
710 #[test]
711 fn test_format_custom() {
712 let custom = Custom {
713 date: date(2024, 1, 1),
714 custom_type: "budget".to_string(),
715 values: vec![],
716 meta: Default::default(),
717 };
718 let config = FormatConfig::default();
719 let formatted = format_custom(&custom, &config);
720 assert_eq!(formatted, "2024-01-01 custom \"budget\"\n");
721 }
722
723 #[test]
726 fn test_issue_573_format_custom_with_values() {
727 let custom = Custom {
729 date: date(2024, 1, 1),
730 custom_type: "fava-option".to_string(),
731 values: vec![
732 MetaValue::String("language".to_string()),
733 MetaValue::String("en".to_string()),
734 ],
735 meta: Default::default(),
736 };
737 let config = FormatConfig::default();
738 let formatted = format_custom(&custom, &config);
739 assert_eq!(
740 formatted,
741 "2024-01-01 custom \"fava-option\" \"language\" \"en\"\n"
742 );
743 }
744
745 #[test]
746 fn test_format_custom_with_mixed_values() {
747 let custom = Custom {
749 date: date(2024, 3, 15),
750 custom_type: "budget".to_string(),
751 values: vec![
752 MetaValue::Account("Expenses:Food".into()),
753 MetaValue::Amount(Amount::new(dec!(500), "USD")),
754 MetaValue::String("monthly".to_string()),
755 ],
756 meta: Default::default(),
757 };
758 let config = FormatConfig::default();
759 let formatted = format_custom(&custom, &config);
760 assert_eq!(
761 formatted,
762 "2024-03-15 custom \"budget\" Expenses:Food 500 USD \"monthly\"\n"
763 );
764 }
765
766 #[test]
767 fn test_format_open_with_booking() {
768 let open = Open {
769 date: date(2024, 1, 1),
770 account: "Assets:Brokerage".into(),
771 currencies: vec!["USD".into()],
772 booking: Some("FIFO".to_string()),
773 meta: Default::default(),
774 };
775 let config = FormatConfig::default();
776 let formatted = format_open(&open, &config);
777 assert_eq!(formatted, "2024-01-01 open Assets:Brokerage USD \"FIFO\"\n");
778 }
779
780 #[test]
781 fn test_format_open_no_currencies() {
782 let open = Open {
783 date: date(2024, 1, 1),
784 account: "Assets:Misc".into(),
785 currencies: vec![],
786 booking: None,
787 meta: Default::default(),
788 };
789 let config = FormatConfig::default();
790 let formatted = format_open(&open, &config);
791 assert_eq!(formatted, "2024-01-01 open Assets:Misc\n");
792 }
793
794 #[test]
795 fn test_format_balance_with_tolerance() {
796 let bal = Balance {
797 date: date(2024, 1, 1),
798 account: "Assets:Bank".into(),
799 amount: Amount::new(dec!(1000.00), "USD"),
800 tolerance: Some(dec!(0.01)),
801 meta: Default::default(),
802 };
803 let config = FormatConfig::default();
804 let formatted = format_balance(&bal, &config);
805 assert_eq!(
806 formatted,
807 "2024-01-01 balance Assets:Bank 1000.00 USD ~ 0.01\n"
808 );
809 }
810
811 #[test]
812 fn test_format_transaction_with_tags() {
813 let txn = Transaction::new(date(2024, 1, 15), "Dinner")
814 .with_flag('*')
815 .with_tag("trip-2024")
816 .with_tag("food")
817 .with_synthesized_posting(Posting::new(
818 "Expenses:Food",
819 Amount::new(dec!(50.00), "USD"),
820 ))
821 .with_synthesized_posting(Posting::new(
822 "Assets:Cash",
823 Amount::new(dec!(-50.00), "USD"),
824 ));
825
826 let config = FormatConfig::default();
827 let formatted = format_transaction(&txn, &config);
828
829 assert!(formatted.contains("#trip-2024"));
830 assert!(formatted.contains("#food"));
831 }
832
833 #[test]
834 fn test_format_transaction_with_links() {
835 let txn = Transaction::new(date(2024, 1, 15), "Invoice payment")
836 .with_flag('*')
837 .with_link("invoice-123")
838 .with_synthesized_posting(Posting::new(
839 "Income:Freelance",
840 Amount::new(dec!(-1000.00), "USD"),
841 ))
842 .with_synthesized_posting(Posting::new(
843 "Assets:Bank",
844 Amount::new(dec!(1000.00), "USD"),
845 ));
846
847 let config = FormatConfig::default();
848 let formatted = format_transaction(&txn, &config);
849
850 assert!(formatted.contains("^invoice-123"));
851 }
852
853 #[test]
854 fn test_format_transaction_with_metadata() {
855 let mut meta = Metadata::default();
856 meta.insert(
857 "filename".to_string(),
858 MetaValue::String("receipt.pdf".to_string()),
859 );
860 meta.insert("verified".to_string(), MetaValue::Bool(true));
861
862 let txn = Transaction {
863 date: date(2024, 1, 15),
864 flag: '*',
865 payee: None,
866 narration: "Purchase".into(),
867 tags: vec![],
868 links: vec![],
869 postings: vec![],
870 meta,
871 trailing_comments: Vec::new(),
872 };
873
874 let config = FormatConfig::default();
875 let formatted = format_transaction(&txn, &config);
876
877 assert!(formatted.contains("filename: \"receipt.pdf\""));
878 assert!(formatted.contains("verified: TRUE"));
879 }
880
881 #[test]
882 fn test_format_posting_with_flag() {
883 let mut posting = Posting::new("Expenses:Unknown", Amount::new(dec!(100.00), "USD"));
884 posting.flag = Some('!');
885
886 let config = FormatConfig::default();
887 let formatted = format_posting(&posting, &config);
888
889 assert!(formatted.contains("! Expenses:Unknown"));
890 }
891
892 #[test]
898 fn number_display_context_pads_without_separators() {
899 use crate::DisplayContext;
900 let mut ctx = DisplayContext::new();
901 ctx.set_fixed_precision("USD", 2);
902 ctx.set_render_commas(true);
903 let config = FormatConfig {
904 number_display: Some(ctx),
905 ..FormatConfig::default()
906 };
907
908 assert_eq!(
909 render_number(rust_decimal_macros::dec!(1234.5), "USD", &config),
910 "1234.50",
911 "fixed precision pads; separators stay a display concern"
912 );
913 assert_eq!(
914 render_number(rust_decimal_macros::dec!(7), "JPY", &config),
915 "7",
916 "untracked currencies keep natural rendering"
917 );
918 assert_eq!(
919 render_number(rust_decimal_macros::dec!(100.50), "EUR", &config),
920 "100.50",
921 "untracked currencies stay byte-faithful — no trailing-zero \
922 stripping, which would widen a balance assertion's implicit \
923 tolerance (deep review of #1807)"
924 );
925 assert_eq!(
926 render_number(
927 rust_decimal_macros::dec!(1234.5),
928 "USD",
929 &FormatConfig::default()
930 ),
931 "1234.5",
932 "no context = historical own-scale rendering"
933 );
934 }
935
936 #[test]
939 fn number_display_context_threads_through_directives() {
940 use crate::DisplayContext;
941 let mut ctx = DisplayContext::new();
942 ctx.set_fixed_precision("USD", 2);
943 ctx.set_render_commas(true);
944 let config = FormatConfig {
945 number_display: Some(ctx),
946 ..FormatConfig::default()
947 };
948
949 let bal = Balance::new(
950 crate::naive_date(2024, 1, 15).unwrap(),
951 "Assets:Bank",
952 Amount::new(rust_decimal_macros::dec!(1234.5), "USD"),
953 );
954 let out = format_directives(std::iter::once(&Directive::Balance(bal)), &config);
955 assert_eq!(
956 out, "2024-01-15 balance Assets:Bank 1234.50 USD\n",
957 "balance renders through the context (padded, no separators)"
958 );
959 }
960
961 #[test]
962 fn test_format_posting_no_units() {
963 let posting = Posting {
964 flag: None,
965 account: "Assets:Bank".into(),
966 units: None,
967 cost: None,
968 price: None,
969 meta: Default::default(),
970 comments: Vec::new(),
971 trailing_comments: Vec::new(),
972 };
973
974 let config = FormatConfig::default();
975 let formatted = format_posting(&posting, &config);
976
977 assert!(formatted.contains("Assets:Bank"));
978 assert!(!formatted.contains("USD"));
980 }
981
982 #[test]
983 fn test_format_config_with_column() {
984 let config = FormatConfig::with_column(80);
985 assert!(matches!(config.alignment, Alignment::CurrencyColumn(80)));
986 assert_eq!(config.indent, " ");
987 }
988
989 #[test]
990 fn test_format_config_with_indent() {
991 let config = FormatConfig::with_indent(4);
992 assert!(matches!(config.alignment, Alignment::Auto { .. }));
993 assert_eq!(config.indent, " ");
994 }
995
996 #[test]
997 fn test_format_config_new() {
998 let config = FormatConfig::new(70, 3);
999 assert!(matches!(config.alignment, Alignment::CurrencyColumn(70)));
1000 assert_eq!(config.indent, " ");
1001 }
1002
1003 #[test]
1004 fn test_format_config_default_is_auto() {
1005 let config = FormatConfig::default();
1006 assert!(matches!(
1007 config.alignment,
1008 Alignment::Auto {
1009 prefix_width: None,
1010 num_width: None
1011 }
1012 ));
1013 }
1014
1015 #[test]
1016 fn test_format_posting_long_account_name() {
1017 let posting = Posting::new(
1018 "Assets:Bank:Checking:Primary:Joint:Savings:Emergency:Fund:Extra:Long",
1019 Amount::new(dec!(100.00), "USD"),
1020 );
1021
1022 let config = FormatConfig::with_column(50);
1023 let formatted = format_posting(&posting, &config);
1024
1025 assert!(formatted.contains(" 100.00 USD"));
1027 }
1028
1029 #[test]
1030 fn test_format_posting_with_cost_and_price() {
1031 let posting = Posting {
1032 flag: None,
1033 account: "Assets:Brokerage".into(),
1034 units: Some(IncompleteAmount::Complete(Amount::new(dec!(10), "AAPL"))),
1035 cost: Some(Box::new(CostSpec {
1036 number: Some(crate::CostNumber::PerUnit {
1037 value: dec!(150.00),
1038 }),
1039 currency: Some("USD".into()),
1040 date: Some(date(2024, 1, 15)),
1041 label: None,
1042 merge: false,
1043 })),
1044 price: Some(Box::new(PriceAnnotation::unit(Amount::new(
1045 dec!(155.00),
1046 "USD",
1047 )))),
1048 meta: Default::default(),
1049 comments: Vec::new(),
1050 trailing_comments: Vec::new(),
1051 };
1052
1053 let config = FormatConfig::default();
1054 let formatted = format_posting(&posting, &config);
1055
1056 assert!(formatted.contains("10 AAPL"));
1057 assert!(formatted.contains("{150.00 USD, 2024-01-15}"));
1058 assert!(formatted.contains("@ 155.00 USD"));
1059 }
1060
1061 #[test]
1062 fn test_format_directives_all_types() {
1063 let config = FormatConfig::default();
1064
1065 let txn = Transaction::new(date(2024, 1, 1), "Test")
1067 .with_flag('*')
1068 .with_synthesized_posting(Posting::new("Expenses:Test", Amount::new(dec!(1), "USD")))
1069 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1), "USD")));
1070 let formatted = format_directives([&Directive::Transaction(txn)], &config);
1071 assert!(formatted.contains("2024-01-01"));
1072
1073 let bal = Balance::new(
1075 date(2024, 1, 1),
1076 "Assets:Bank",
1077 Amount::new(dec!(100), "USD"),
1078 );
1079 let formatted = format_directives([&Directive::Balance(bal)], &config);
1080 assert!(formatted.contains("balance"));
1081
1082 let open = Open {
1084 date: date(2024, 1, 1),
1085 account: "Assets:Test".into(),
1086 currencies: vec![],
1087 booking: None,
1088 meta: Default::default(),
1089 };
1090 let formatted = format_directives([&Directive::Open(open)], &config);
1091 assert!(formatted.contains("open"));
1092
1093 let close = Close {
1095 date: date(2024, 1, 1),
1096 account: "Assets:Test".into(),
1097 meta: Default::default(),
1098 };
1099 let formatted = format_directives([&Directive::Close(close)], &config);
1100 assert!(formatted.contains("close"));
1101
1102 let comm = Commodity {
1104 date: date(2024, 1, 1),
1105 currency: "BTC".into(),
1106 meta: Default::default(),
1107 };
1108 let formatted = format_directives([&Directive::Commodity(comm)], &config);
1109 assert!(formatted.contains("commodity"));
1110
1111 let pad = Pad {
1113 date: date(2024, 1, 1),
1114 account: "Assets:A".into(),
1115 source_account: "Equity:B".into(),
1116 meta: Default::default(),
1117 };
1118 let formatted = format_directives([&Directive::Pad(pad)], &config);
1119 assert!(formatted.contains("pad"));
1120
1121 let event = Event {
1123 date: date(2024, 1, 1),
1124 event_type: "test".to_string(),
1125 value: "value".to_string(),
1126 meta: Default::default(),
1127 };
1128 let formatted = format_directives([&Directive::Event(event)], &config);
1129 assert!(formatted.contains("event"));
1130
1131 let query = Query {
1133 date: date(2024, 1, 1),
1134 name: "test".to_string(),
1135 query: "SELECT *".to_string(),
1136 meta: Default::default(),
1137 };
1138 let formatted = format_directives([&Directive::Query(query)], &config);
1139 assert!(formatted.contains("query"));
1140
1141 let note = Note {
1143 date: date(2024, 1, 1),
1144 account: "Assets:Bank".into(),
1145 comment: "test".to_string(),
1146 meta: Default::default(),
1147 };
1148 let formatted = format_directives([&Directive::Note(note)], &config);
1149 assert!(formatted.contains("note"));
1150
1151 let doc = Document {
1153 date: date(2024, 1, 1),
1154 account: "Assets:Bank".into(),
1155 path: "/path".to_string(),
1156 tags: vec![],
1157 links: vec![],
1158 meta: Default::default(),
1159 };
1160 let formatted = format_directives([&Directive::Document(doc)], &config);
1161 assert!(formatted.contains("document"));
1162
1163 let price = Price {
1165 date: date(2024, 1, 1),
1166 currency: "AAPL".into(),
1167 amount: Amount::new(dec!(150), "USD"),
1168 meta: Default::default(),
1169 };
1170 let formatted = format_directives([&Directive::Price(price)], &config);
1171 assert!(formatted.contains("price"));
1172
1173 let custom = Custom {
1175 date: date(2024, 1, 1),
1176 custom_type: "test".to_string(),
1177 values: vec![],
1178 meta: Default::default(),
1179 };
1180 let formatted = format_directives([&Directive::Custom(custom)], &config);
1181 assert!(formatted.contains("custom"));
1182 }
1183
1184 #[test]
1185 fn test_format_amount_negative() {
1186 let amount = Amount::new(dec!(-100.50), "USD");
1187 assert_eq!(
1188 format_amount_with(&amount, &FormatConfig::default()),
1189 "-100.50 USD"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_format_amount_zero() {
1195 let amount = Amount::new(dec!(0), "EUR");
1196 assert_eq!(
1197 format_amount_with(&amount, &FormatConfig::default()),
1198 "0 EUR"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_format_amount_large_number() {
1204 let amount = Amount::new(dec!(1234567890.12), "USD");
1205 assert_eq!(
1206 format_amount_with(&amount, &FormatConfig::default()),
1207 "1234567890.12 USD"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_format_amount_small_decimal() {
1213 let amount = Amount::new(dec!(0.00001), "BTC");
1214 assert_eq!(
1215 format_amount_with(&amount, &FormatConfig::default()),
1216 "0.00001 BTC"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_format_transaction_with_inline_comment() {
1222 let config = FormatConfig::default();
1223
1224 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1226 posting.comments = vec!["; This is an inline comment".to_string()];
1227
1228 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1229 .with_flag('*')
1230 .with_synthesized_posting(posting)
1231 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")));
1232
1233 let formatted = format_transaction(&txn, &config);
1234
1235 assert!(
1237 formatted.contains("; This is an inline comment"),
1238 "Formatted transaction should contain inline comment: {formatted}"
1239 );
1240 let comment_pos = formatted.find("; This is an inline comment").unwrap();
1242 let expenses_pos = formatted.find("Expenses:Food").unwrap();
1243 assert!(
1244 comment_pos < expenses_pos,
1245 "Comment should appear before the posting"
1246 );
1247 }
1248
1249 #[test]
1251 fn test_issue_364_format_all_comment_types() {
1252 let config = FormatConfig::default();
1253
1254 let mut posting1 = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1256 posting1.comments = vec!["; Pre-comment 1".to_string(), "; Pre-comment 2".to_string()];
1257 posting1.trailing_comments = vec!["; trailing on posting".to_string()];
1258
1259 let mut posting2 = Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD"));
1261 posting2.comments = vec!["; Comment before second posting".to_string()];
1262
1263 let mut txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1265 .with_flag('*')
1266 .with_synthesized_posting(posting1)
1267 .with_synthesized_posting(posting2);
1268 txn.trailing_comments = vec![
1269 "; Transaction trailing 1".to_string(),
1270 "; Transaction trailing 2".to_string(),
1271 ];
1272
1273 let formatted = format_transaction(&txn, &config);
1274
1275 let lines: Vec<&str> = formatted.lines().collect();
1277
1278 assert!(lines[0].contains("2024-01-15 * \"Test transaction\""));
1280
1281 assert_eq!(lines[1].trim(), "; Pre-comment 1");
1283 assert_eq!(lines[2].trim(), "; Pre-comment 2");
1284
1285 assert!(lines[3].contains("Expenses:Food"));
1287 assert!(lines[3].contains("; trailing on posting"));
1288
1289 assert_eq!(lines[4].trim(), "; Comment before second posting");
1291
1292 assert!(lines[5].contains("Assets:Bank"));
1294
1295 assert_eq!(lines[6].trim(), "; Transaction trailing 1");
1297 assert_eq!(lines[7].trim(), "; Transaction trailing 2");
1298 }
1299
1300 #[test]
1302 fn test_issue_364_trailing_comment_on_posting_line() {
1303 let config = FormatConfig::default();
1304
1305 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1306 posting.trailing_comments = vec!["; This goes on same line".to_string()];
1307
1308 let txn = Transaction::new(date(2024, 1, 15), "Test")
1309 .with_flag('*')
1310 .with_synthesized_posting(posting)
1311 .with_synthesized_posting(Posting::auto("Assets:Bank"));
1312
1313 let formatted = format_transaction(&txn, &config);
1314
1315 for line in formatted.lines() {
1317 if line.contains("Expenses:Food") {
1318 assert!(
1319 line.contains("; This goes on same line"),
1320 "Trailing comment should be on same line as posting: {line}"
1321 );
1322 break;
1323 }
1324 }
1325 }
1326
1327 #[test]
1328 fn test_format_posting_metadata_issue_701() {
1329 let mut posting_meta = Metadata::default();
1331 posting_meta.insert(
1332 "note".to_string(),
1333 MetaValue::String("this note is lost".to_string()),
1334 );
1335
1336 let mut posting = Posting::new("Expenses:Expense", Amount::new(dec!(10), "USD"));
1337 posting.meta = posting_meta;
1338
1339 let txn = Transaction {
1340 date: date(2026, 4, 7),
1341 flag: '*',
1342 payee: None,
1343 narration: "my expense".into(),
1344 tags: vec![],
1345 links: vec![],
1346 postings: vec![
1347 crate::Spanned::synthesized(posting),
1348 crate::Spanned::synthesized(Posting::auto("Assets:Wallet")),
1349 ],
1350 meta: Metadata::default(),
1351 trailing_comments: Vec::new(),
1352 };
1353
1354 let config = FormatConfig::default();
1355 let formatted = format_transaction(&txn, &config);
1356
1357 assert!(
1358 formatted.contains("note: \"this note is lost\""),
1359 "posting metadata should be preserved in formatted output, got:\n{formatted}"
1360 );
1361 assert!(
1363 formatted.contains(" note:"),
1364 "posting metadata should have double indent (4 spaces), got:\n{formatted}"
1365 );
1366 }
1367}