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, 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 use helpers::escape_string;
20pub(crate) use helpers::format_meta_value;
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}
35
36impl Default for FormatConfig {
37 fn default() -> Self {
38 Self {
39 alignment: Alignment::default(),
40 indent: " ".to_string(),
41 }
42 }
43}
44
45impl FormatConfig {
46 #[must_use]
49 pub fn with_column(column: usize) -> Self {
50 Self {
51 alignment: Alignment::CurrencyColumn(column),
52 indent: " ".to_string(),
53 }
54 }
55
56 #[must_use]
58 pub fn with_indent(indent_width: usize) -> Self {
59 Self {
60 alignment: Alignment::default(),
61 indent: " ".repeat(indent_width),
62 }
63 }
64
65 #[must_use]
67 pub fn new(column: usize, indent_width: usize) -> Self {
68 Self {
69 alignment: Alignment::CurrencyColumn(column),
70 indent: " ".repeat(indent_width),
71 }
72 }
73}
74
75#[must_use]
82pub fn format_directive_lines(directive: &Directive, config: &FormatConfig) -> Vec<FormatLine> {
83 match directive {
84 Directive::Transaction(txn) => format_transaction_lines(txn, config),
85 Directive::Balance(bal) => format_balance_lines(bal, config),
86 Directive::Open(open) => format_open_lines(open, config),
87 Directive::Close(close) => format_close_lines(close, config),
88 Directive::Commodity(comm) => format_commodity_lines(comm, config),
89 Directive::Pad(pad) => format_pad_lines(pad, config),
90 Directive::Event(event) => format_event_lines(event, config),
91 Directive::Query(query) => format_query_lines(query, config),
92 Directive::Note(note) => format_note_lines(note, config),
93 Directive::Document(doc) => format_document_lines(doc, config),
94 Directive::Price(price) => format_price_lines(price, config),
95 Directive::Custom(custom) => format_custom_lines(custom, config),
96 }
97}
98
99#[must_use]
121pub fn format_directives<'a, I>(directives: I, config: &FormatConfig) -> String
122where
123 I: IntoIterator<Item = &'a Directive>,
124{
125 let mut lines: Vec<FormatLine> = Vec::new();
126 for directive in directives {
127 lines.extend(format_directive_lines(directive, config));
128 }
129 render_lines(&lines, &config.alignment)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::directives::{
135 format_balance, format_close, format_commodity, format_custom, format_document,
136 format_event, format_note, format_open, format_pad, format_price, format_query,
137 };
138 use super::transaction::{format_posting, format_transaction};
139 use super::*;
140 use crate::{
141 Amount, Balance, Close, Commodity, CostSpec, Custom, Directive, Document, Event,
142 IncompleteAmount, MetaValue, Metadata, NaiveDate, Note, Open, Pad, Posting, Price,
143 PriceAnnotation, Query, Transaction,
144 };
145 use rust_decimal_macros::dec;
146
147 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
148 crate::naive_date(year, month, day).unwrap()
149 }
150
151 #[test]
152 fn test_format_simple_transaction() {
153 let txn = Transaction::new(date(2024, 1, 15), "Morning coffee")
154 .with_flag('*')
155 .with_payee("Coffee Shop")
156 .with_synthesized_posting(Posting::new(
157 "Expenses:Food:Coffee",
158 Amount::new(dec!(5.00), "USD"),
159 ))
160 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-5.00), "USD")));
161
162 let config = FormatConfig::with_column(50);
163 let formatted = format_transaction(&txn, &config);
164
165 assert!(formatted.contains("2024-01-15 * \"Coffee Shop\" \"Morning coffee\""));
166 assert!(formatted.contains("Expenses:Food:Coffee"));
167 assert!(formatted.contains("5.00 USD"));
168 }
169
170 #[test]
171 fn test_format_balance() {
172 let bal = Balance::new(
173 date(2024, 1, 1),
174 "Assets:Bank",
175 Amount::new(dec!(1000.00), "USD"),
176 );
177 let config = FormatConfig::default();
178 let formatted = format_balance(&bal, &config);
179 assert_eq!(formatted, "2024-01-01 balance Assets:Bank 1000.00 USD\n");
182 }
183
184 #[test]
185 fn test_format_open() {
186 let open = Open {
187 date: date(2024, 1, 1),
188 account: "Assets:Bank:Checking".into(),
189 currencies: vec!["USD".into(), "EUR".into()],
190 booking: None,
191 meta: Default::default(),
192 };
193 let config = FormatConfig::default();
194 let formatted = format_open(&open, &config);
195 assert_eq!(formatted, "2024-01-01 open Assets:Bank:Checking USD,EUR\n");
196 }
197
198 #[test]
199 fn test_escape_string() {
200 assert_eq!(escape_string("hello"), "hello");
201 assert_eq!(escape_string("say \"hi\""), "say \\\"hi\\\"");
202 assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
203 }
204
205 #[test]
210 fn test_escape_string_combined() {
211 assert_eq!(
213 escape_string("path\\to\\file\n\"quoted\""),
214 "path\\\\to\\\\file\\n\\\"quoted\\\""
215 );
216 }
217
218 #[test]
219 fn test_escape_string_backslash_quote() {
220 assert_eq!(escape_string("\\\""), "\\\\\\\"");
222 }
223
224 #[test]
225 fn test_escape_string_empty() {
226 assert_eq!(escape_string(""), "");
227 }
228
229 #[test]
230 fn test_escape_string_unicode() {
231 assert_eq!(escape_string("café résumé"), "café résumé");
232 assert_eq!(escape_string("日本語"), "日本語");
233 assert_eq!(escape_string("emoji 🎉"), "emoji 🎉");
234 }
235
236 #[test]
237 fn test_format_meta_value_string() {
238 let val = MetaValue::String("hello world".to_string());
239 assert_eq!(format_meta_value(&val), "\"hello world\"");
240 }
241
242 #[test]
243 fn test_format_meta_value_string_with_quotes() {
244 let val = MetaValue::String("say \"hello\"".to_string());
245 assert_eq!(format_meta_value(&val), "\"say \\\"hello\\\"\"");
246 }
247
248 #[test]
249 fn test_format_meta_value_account() {
250 let val = MetaValue::Account("Assets:Bank:Checking".into());
251 assert_eq!(format_meta_value(&val), "Assets:Bank:Checking");
252 }
253
254 #[test]
255 fn test_format_meta_value_currency() {
256 let val = MetaValue::Currency("USD".into());
257 assert_eq!(format_meta_value(&val), "USD");
258 }
259
260 #[test]
261 fn test_format_meta_value_tag() {
262 let val = MetaValue::Tag("trip-2024".into());
263 assert_eq!(format_meta_value(&val), "#trip-2024");
264 }
265
266 #[test]
267 fn test_format_meta_value_link() {
268 let val = MetaValue::Link("invoice-123".into());
269 assert_eq!(format_meta_value(&val), "^invoice-123");
270 }
271
272 #[test]
273 fn test_format_meta_value_date() {
274 let val = MetaValue::Date(date(2024, 6, 15));
275 assert_eq!(format_meta_value(&val), "2024-06-15");
276 }
277
278 #[test]
279 fn test_format_meta_value_number() {
280 let val = MetaValue::Number(dec!(123.456));
281 assert_eq!(format_meta_value(&val), "123.456");
282 }
283
284 #[test]
285 fn test_format_meta_value_amount() {
286 let val = MetaValue::Amount(Amount::new(dec!(99.99), "USD"));
287 assert_eq!(format_meta_value(&val), "99.99 USD");
288 }
289
290 #[test]
291 fn test_format_meta_value_bool_true() {
292 let val = MetaValue::Bool(true);
293 assert_eq!(format_meta_value(&val), "TRUE");
294 }
295
296 #[test]
297 fn test_format_meta_value_bool_false() {
298 let val = MetaValue::Bool(false);
299 assert_eq!(format_meta_value(&val), "FALSE");
300 }
301
302 #[test]
303 fn test_format_meta_value_none() {
304 let val = MetaValue::None;
305 assert_eq!(format_meta_value(&val), "");
306 }
307
308 #[test]
309 fn test_format_cost_spec_per_unit() {
310 let spec = CostSpec {
311 number: Some(crate::CostNumber::PerUnit {
312 value: dec!(150.00),
313 }),
314 currency: Some("USD".into()),
315 date: None,
316 label: None,
317 merge: false,
318 };
319 assert_eq!(format_cost_spec(&spec), "{150.00 USD}");
320 }
321
322 #[test]
323 fn test_format_cost_spec_total() {
324 let spec = CostSpec {
325 number: Some(crate::CostNumber::Total {
326 value: dec!(1500.00),
327 }),
328 currency: Some("USD".into()),
329 date: None,
330 label: None,
331 merge: false,
332 };
333 assert_eq!(format_cost_spec(&spec), "{{1500.00 USD}}");
334 }
335
336 #[test]
337 fn test_format_cost_spec_with_date() {
338 let spec = CostSpec {
339 number: Some(crate::CostNumber::PerUnit {
340 value: dec!(150.00),
341 }),
342 currency: Some("USD".into()),
343 date: Some(date(2024, 1, 15)),
344 label: None,
345 merge: false,
346 };
347 assert_eq!(format_cost_spec(&spec), "{150.00 USD, 2024-01-15}");
348 }
349
350 #[test]
351 fn test_format_cost_spec_with_label() {
352 let spec = CostSpec {
353 number: Some(crate::CostNumber::PerUnit {
354 value: dec!(150.00),
355 }),
356 currency: Some("USD".into()),
357 date: None,
358 label: Some("lot-a".to_string()),
359 merge: false,
360 };
361 assert_eq!(format_cost_spec(&spec), "{150.00 USD, \"lot-a\"}");
362 }
363
364 #[test]
365 fn test_format_cost_spec_with_merge() {
366 let spec = CostSpec {
367 number: Some(crate::CostNumber::PerUnit {
368 value: dec!(150.00),
369 }),
370 currency: Some("USD".into()),
371 date: None,
372 label: None,
373 merge: true,
374 };
375 assert_eq!(format_cost_spec(&spec), "{150.00 USD, *}");
376 }
377
378 #[test]
379 fn test_format_cost_spec_all_fields() {
380 let spec = CostSpec {
381 number: Some(crate::CostNumber::PerUnit {
382 value: dec!(150.00),
383 }),
384 currency: Some("USD".into()),
385 date: Some(date(2024, 1, 15)),
386 label: Some("lot-a".to_string()),
387 merge: true,
388 };
389 assert_eq!(
390 format_cost_spec(&spec),
391 "{150.00 USD, 2024-01-15, \"lot-a\", *}"
392 );
393 }
394
395 #[test]
396 fn test_format_cost_spec_empty() {
397 let spec = CostSpec {
398 number: None,
399 currency: None,
400 date: None,
401 label: None,
402 merge: false,
403 };
404 assert_eq!(format_cost_spec(&spec), "{}");
405 }
406
407 #[test]
408 fn test_format_price_annotation_unit() {
409 let price = PriceAnnotation::unit(Amount::new(dec!(150.00), "USD"));
410 assert_eq!(format_price_annotation(&price), "@ 150.00 USD");
411 }
412
413 #[test]
414 fn test_format_price_annotation_total() {
415 let price = PriceAnnotation::total(Amount::new(dec!(1500.00), "USD"));
416 assert_eq!(format_price_annotation(&price), "@@ 1500.00 USD");
417 }
418
419 #[test]
420 fn test_format_price_annotation_unit_incomplete() {
421 let price = PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(150.00)));
422 assert_eq!(format_price_annotation(&price), "@ 150.00");
423 }
424
425 #[test]
426 fn test_format_price_annotation_total_incomplete() {
427 let price = PriceAnnotation::total_incomplete(IncompleteAmount::CurrencyOnly("USD".into()));
428 assert_eq!(format_price_annotation(&price), "@@ USD");
429 }
430
431 #[test]
432 fn test_format_price_annotation_unit_empty() {
433 let price = PriceAnnotation::unit_empty();
434 assert_eq!(format_price_annotation(&price), "@");
435 }
436
437 #[test]
438 fn test_format_price_annotation_total_empty() {
439 let price = PriceAnnotation::total_empty();
440 assert_eq!(format_price_annotation(&price), "@@");
441 }
442
443 #[test]
444 fn test_format_incomplete_amount_complete() {
445 let amount = IncompleteAmount::Complete(Amount::new(dec!(100.50), "EUR"));
446 assert_eq!(format_incomplete_amount(&amount), "100.50 EUR");
447 }
448
449 #[test]
450 fn test_format_incomplete_amount_number_only() {
451 let amount = IncompleteAmount::NumberOnly(dec!(42.00));
452 assert_eq!(format_incomplete_amount(&amount), "42.00");
453 }
454
455 #[test]
456 fn test_format_incomplete_amount_currency_only() {
457 let amount = IncompleteAmount::CurrencyOnly("BTC".into());
458 assert_eq!(format_incomplete_amount(&amount), "BTC");
459 }
460
461 #[test]
462 fn test_format_close() {
463 let close = Close {
464 date: date(2024, 12, 31),
465 account: "Assets:OldAccount".into(),
466 meta: Default::default(),
467 };
468 let config = FormatConfig::default();
469 let formatted = format_close(&close, &config);
470 assert_eq!(formatted, "2024-12-31 close Assets:OldAccount\n");
471 }
472
473 #[test]
474 fn test_format_commodity() {
475 let comm = Commodity {
476 date: date(2024, 1, 1),
477 currency: "BTC".into(),
478 meta: Default::default(),
479 };
480 let config = FormatConfig::default();
481 let formatted = format_commodity(&comm, &config);
482 assert_eq!(formatted, "2024-01-01 commodity BTC\n");
483 }
484
485 #[test]
486 fn test_format_pad() {
487 let pad = Pad {
488 date: date(2024, 1, 15),
489 account: "Assets:Checking".into(),
490 source_account: "Equity:Opening-Balances".into(),
491 meta: Default::default(),
492 };
493 let config = FormatConfig::default();
494 let formatted = format_pad(&pad, &config);
495 assert_eq!(
496 formatted,
497 "2024-01-15 pad Assets:Checking Equity:Opening-Balances\n"
498 );
499 }
500
501 #[test]
502 fn test_format_event() {
503 let event = Event {
504 date: date(2024, 6, 1),
505 event_type: "location".to_string(),
506 value: "New York".to_string(),
507 meta: Default::default(),
508 };
509 let config = FormatConfig::default();
510 let formatted = format_event(&event, &config);
511 assert_eq!(formatted, "2024-06-01 event \"location\" \"New York\"\n");
512 }
513
514 #[test]
515 fn test_format_event_with_quotes() {
516 let event = Event {
517 date: date(2024, 6, 1),
518 event_type: "quote".to_string(),
519 value: "He said \"hello\"".to_string(),
520 meta: Default::default(),
521 };
522 let config = FormatConfig::default();
523 let formatted = format_event(&event, &config);
524 assert_eq!(
525 formatted,
526 "2024-06-01 event \"quote\" \"He said \\\"hello\\\"\"\n"
527 );
528 }
529
530 #[test]
531 fn test_format_query() {
532 let query = Query {
533 date: date(2024, 1, 1),
534 name: "monthly_expenses".to_string(),
535 query: "SELECT account, sum(position) WHERE account ~ 'Expenses'".to_string(),
536 meta: Default::default(),
537 };
538 let config = FormatConfig::default();
539 let formatted = format_query(&query, &config);
540 assert!(formatted.contains("query \"monthly_expenses\""));
541 assert!(formatted.contains("SELECT account"));
542 }
543
544 #[test]
545 fn test_format_note() {
546 let note = Note {
547 date: date(2024, 3, 15),
548 account: "Assets:Bank".into(),
549 comment: "Called the bank about fee".to_string(),
550 meta: Default::default(),
551 };
552 let config = FormatConfig::default();
553 let formatted = format_note(¬e, &config);
554 assert_eq!(
555 formatted,
556 "2024-03-15 note Assets:Bank \"Called the bank about fee\"\n"
557 );
558 }
559
560 #[test]
561 fn test_format_document() {
562 let doc = Document {
563 date: date(2024, 2, 10),
564 account: "Assets:Bank".into(),
565 path: "/docs/statement-2024-02.pdf".to_string(),
566 tags: vec![],
567 links: vec![],
568 meta: Default::default(),
569 };
570 let config = FormatConfig::default();
571 let formatted = format_document(&doc, &config);
572 assert_eq!(
573 formatted,
574 "2024-02-10 document Assets:Bank \"/docs/statement-2024-02.pdf\"\n"
575 );
576 }
577
578 #[test]
579 fn test_format_price() {
580 let price = Price {
581 date: date(2024, 1, 15),
582 currency: "AAPL".into(),
583 amount: Amount::new(dec!(185.50), "USD"),
584 meta: Default::default(),
585 };
586 let config = FormatConfig::default();
587 let formatted = format_price(&price, &config);
588 assert_eq!(formatted, "2024-01-15 price AAPL 185.50 USD\n");
589 }
590
591 #[test]
592 fn test_format_custom() {
593 let custom = Custom {
594 date: date(2024, 1, 1),
595 custom_type: "budget".to_string(),
596 values: vec![],
597 meta: Default::default(),
598 };
599 let config = FormatConfig::default();
600 let formatted = format_custom(&custom, &config);
601 assert_eq!(formatted, "2024-01-01 custom \"budget\"\n");
602 }
603
604 #[test]
607 fn test_issue_573_format_custom_with_values() {
608 let custom = Custom {
610 date: date(2024, 1, 1),
611 custom_type: "fava-option".to_string(),
612 values: vec![
613 MetaValue::String("language".to_string()),
614 MetaValue::String("en".to_string()),
615 ],
616 meta: Default::default(),
617 };
618 let config = FormatConfig::default();
619 let formatted = format_custom(&custom, &config);
620 assert_eq!(
621 formatted,
622 "2024-01-01 custom \"fava-option\" \"language\" \"en\"\n"
623 );
624 }
625
626 #[test]
627 fn test_format_custom_with_mixed_values() {
628 let custom = Custom {
630 date: date(2024, 3, 15),
631 custom_type: "budget".to_string(),
632 values: vec![
633 MetaValue::Account("Expenses:Food".into()),
634 MetaValue::Amount(Amount::new(dec!(500), "USD")),
635 MetaValue::String("monthly".to_string()),
636 ],
637 meta: Default::default(),
638 };
639 let config = FormatConfig::default();
640 let formatted = format_custom(&custom, &config);
641 assert_eq!(
642 formatted,
643 "2024-03-15 custom \"budget\" Expenses:Food 500 USD \"monthly\"\n"
644 );
645 }
646
647 #[test]
648 fn test_format_open_with_booking() {
649 let open = Open {
650 date: date(2024, 1, 1),
651 account: "Assets:Brokerage".into(),
652 currencies: vec!["USD".into()],
653 booking: Some("FIFO".to_string()),
654 meta: Default::default(),
655 };
656 let config = FormatConfig::default();
657 let formatted = format_open(&open, &config);
658 assert_eq!(formatted, "2024-01-01 open Assets:Brokerage USD \"FIFO\"\n");
659 }
660
661 #[test]
662 fn test_format_open_no_currencies() {
663 let open = Open {
664 date: date(2024, 1, 1),
665 account: "Assets:Misc".into(),
666 currencies: vec![],
667 booking: None,
668 meta: Default::default(),
669 };
670 let config = FormatConfig::default();
671 let formatted = format_open(&open, &config);
672 assert_eq!(formatted, "2024-01-01 open Assets:Misc\n");
673 }
674
675 #[test]
676 fn test_format_balance_with_tolerance() {
677 let bal = Balance {
678 date: date(2024, 1, 1),
679 account: "Assets:Bank".into(),
680 amount: Amount::new(dec!(1000.00), "USD"),
681 tolerance: Some(dec!(0.01)),
682 meta: Default::default(),
683 };
684 let config = FormatConfig::default();
685 let formatted = format_balance(&bal, &config);
686 assert_eq!(
687 formatted,
688 "2024-01-01 balance Assets:Bank 1000.00 USD ~ 0.01\n"
689 );
690 }
691
692 #[test]
693 fn test_format_transaction_with_tags() {
694 let txn = Transaction::new(date(2024, 1, 15), "Dinner")
695 .with_flag('*')
696 .with_tag("trip-2024")
697 .with_tag("food")
698 .with_synthesized_posting(Posting::new(
699 "Expenses:Food",
700 Amount::new(dec!(50.00), "USD"),
701 ))
702 .with_synthesized_posting(Posting::new(
703 "Assets:Cash",
704 Amount::new(dec!(-50.00), "USD"),
705 ));
706
707 let config = FormatConfig::default();
708 let formatted = format_transaction(&txn, &config);
709
710 assert!(formatted.contains("#trip-2024"));
711 assert!(formatted.contains("#food"));
712 }
713
714 #[test]
715 fn test_format_transaction_with_links() {
716 let txn = Transaction::new(date(2024, 1, 15), "Invoice payment")
717 .with_flag('*')
718 .with_link("invoice-123")
719 .with_synthesized_posting(Posting::new(
720 "Income:Freelance",
721 Amount::new(dec!(-1000.00), "USD"),
722 ))
723 .with_synthesized_posting(Posting::new(
724 "Assets:Bank",
725 Amount::new(dec!(1000.00), "USD"),
726 ));
727
728 let config = FormatConfig::default();
729 let formatted = format_transaction(&txn, &config);
730
731 assert!(formatted.contains("^invoice-123"));
732 }
733
734 #[test]
735 fn test_format_transaction_with_metadata() {
736 let mut meta = Metadata::default();
737 meta.insert(
738 "filename".to_string(),
739 MetaValue::String("receipt.pdf".to_string()),
740 );
741 meta.insert("verified".to_string(), MetaValue::Bool(true));
742
743 let txn = Transaction {
744 date: date(2024, 1, 15),
745 flag: '*',
746 payee: None,
747 narration: "Purchase".into(),
748 tags: vec![],
749 links: vec![],
750 postings: vec![],
751 meta,
752 trailing_comments: Vec::new(),
753 };
754
755 let config = FormatConfig::default();
756 let formatted = format_transaction(&txn, &config);
757
758 assert!(formatted.contains("filename: \"receipt.pdf\""));
759 assert!(formatted.contains("verified: TRUE"));
760 }
761
762 #[test]
763 fn test_format_posting_with_flag() {
764 let mut posting = Posting::new("Expenses:Unknown", Amount::new(dec!(100.00), "USD"));
765 posting.flag = Some('!');
766
767 let config = FormatConfig::default();
768 let formatted = format_posting(&posting, &config);
769
770 assert!(formatted.contains("! Expenses:Unknown"));
771 }
772
773 #[test]
774 fn test_format_posting_no_units() {
775 let posting = Posting {
776 flag: None,
777 account: "Assets:Bank".into(),
778 units: None,
779 cost: None,
780 price: None,
781 meta: Default::default(),
782 comments: Vec::new(),
783 trailing_comments: Vec::new(),
784 };
785
786 let config = FormatConfig::default();
787 let formatted = format_posting(&posting, &config);
788
789 assert!(formatted.contains("Assets:Bank"));
790 assert!(!formatted.contains("USD"));
792 }
793
794 #[test]
795 fn test_format_config_with_column() {
796 let config = FormatConfig::with_column(80);
797 assert!(matches!(config.alignment, Alignment::CurrencyColumn(80)));
798 assert_eq!(config.indent, " ");
799 }
800
801 #[test]
802 fn test_format_config_with_indent() {
803 let config = FormatConfig::with_indent(4);
804 assert!(matches!(config.alignment, Alignment::Auto { .. }));
805 assert_eq!(config.indent, " ");
806 }
807
808 #[test]
809 fn test_format_config_new() {
810 let config = FormatConfig::new(70, 3);
811 assert!(matches!(config.alignment, Alignment::CurrencyColumn(70)));
812 assert_eq!(config.indent, " ");
813 }
814
815 #[test]
816 fn test_format_config_default_is_auto() {
817 let config = FormatConfig::default();
818 assert!(matches!(
819 config.alignment,
820 Alignment::Auto {
821 prefix_width: None,
822 num_width: None
823 }
824 ));
825 }
826
827 #[test]
828 fn test_format_posting_long_account_name() {
829 let posting = Posting::new(
830 "Assets:Bank:Checking:Primary:Joint:Savings:Emergency:Fund:Extra:Long",
831 Amount::new(dec!(100.00), "USD"),
832 );
833
834 let config = FormatConfig::with_column(50);
835 let formatted = format_posting(&posting, &config);
836
837 assert!(formatted.contains(" 100.00 USD"));
839 }
840
841 #[test]
842 fn test_format_posting_with_cost_and_price() {
843 let posting = Posting {
844 flag: None,
845 account: "Assets:Brokerage".into(),
846 units: Some(IncompleteAmount::Complete(Amount::new(dec!(10), "AAPL"))),
847 cost: Some(CostSpec {
848 number: Some(crate::CostNumber::PerUnit {
849 value: dec!(150.00),
850 }),
851 currency: Some("USD".into()),
852 date: Some(date(2024, 1, 15)),
853 label: None,
854 merge: false,
855 }),
856 price: Some(PriceAnnotation::unit(Amount::new(dec!(155.00), "USD"))),
857 meta: Default::default(),
858 comments: Vec::new(),
859 trailing_comments: Vec::new(),
860 };
861
862 let config = FormatConfig::default();
863 let formatted = format_posting(&posting, &config);
864
865 assert!(formatted.contains("10 AAPL"));
866 assert!(formatted.contains("{150.00 USD, 2024-01-15}"));
867 assert!(formatted.contains("@ 155.00 USD"));
868 }
869
870 #[test]
871 fn test_format_directives_all_types() {
872 let config = FormatConfig::default();
873
874 let txn = Transaction::new(date(2024, 1, 1), "Test")
876 .with_flag('*')
877 .with_synthesized_posting(Posting::new("Expenses:Test", Amount::new(dec!(1), "USD")))
878 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1), "USD")));
879 let formatted = format_directives([&Directive::Transaction(txn)], &config);
880 assert!(formatted.contains("2024-01-01"));
881
882 let bal = Balance::new(
884 date(2024, 1, 1),
885 "Assets:Bank",
886 Amount::new(dec!(100), "USD"),
887 );
888 let formatted = format_directives([&Directive::Balance(bal)], &config);
889 assert!(formatted.contains("balance"));
890
891 let open = Open {
893 date: date(2024, 1, 1),
894 account: "Assets:Test".into(),
895 currencies: vec![],
896 booking: None,
897 meta: Default::default(),
898 };
899 let formatted = format_directives([&Directive::Open(open)], &config);
900 assert!(formatted.contains("open"));
901
902 let close = Close {
904 date: date(2024, 1, 1),
905 account: "Assets:Test".into(),
906 meta: Default::default(),
907 };
908 let formatted = format_directives([&Directive::Close(close)], &config);
909 assert!(formatted.contains("close"));
910
911 let comm = Commodity {
913 date: date(2024, 1, 1),
914 currency: "BTC".into(),
915 meta: Default::default(),
916 };
917 let formatted = format_directives([&Directive::Commodity(comm)], &config);
918 assert!(formatted.contains("commodity"));
919
920 let pad = Pad {
922 date: date(2024, 1, 1),
923 account: "Assets:A".into(),
924 source_account: "Equity:B".into(),
925 meta: Default::default(),
926 };
927 let formatted = format_directives([&Directive::Pad(pad)], &config);
928 assert!(formatted.contains("pad"));
929
930 let event = Event {
932 date: date(2024, 1, 1),
933 event_type: "test".to_string(),
934 value: "value".to_string(),
935 meta: Default::default(),
936 };
937 let formatted = format_directives([&Directive::Event(event)], &config);
938 assert!(formatted.contains("event"));
939
940 let query = Query {
942 date: date(2024, 1, 1),
943 name: "test".to_string(),
944 query: "SELECT *".to_string(),
945 meta: Default::default(),
946 };
947 let formatted = format_directives([&Directive::Query(query)], &config);
948 assert!(formatted.contains("query"));
949
950 let note = Note {
952 date: date(2024, 1, 1),
953 account: "Assets:Bank".into(),
954 comment: "test".to_string(),
955 meta: Default::default(),
956 };
957 let formatted = format_directives([&Directive::Note(note)], &config);
958 assert!(formatted.contains("note"));
959
960 let doc = Document {
962 date: date(2024, 1, 1),
963 account: "Assets:Bank".into(),
964 path: "/path".to_string(),
965 tags: vec![],
966 links: vec![],
967 meta: Default::default(),
968 };
969 let formatted = format_directives([&Directive::Document(doc)], &config);
970 assert!(formatted.contains("document"));
971
972 let price = Price {
974 date: date(2024, 1, 1),
975 currency: "AAPL".into(),
976 amount: Amount::new(dec!(150), "USD"),
977 meta: Default::default(),
978 };
979 let formatted = format_directives([&Directive::Price(price)], &config);
980 assert!(formatted.contains("price"));
981
982 let custom = Custom {
984 date: date(2024, 1, 1),
985 custom_type: "test".to_string(),
986 values: vec![],
987 meta: Default::default(),
988 };
989 let formatted = format_directives([&Directive::Custom(custom)], &config);
990 assert!(formatted.contains("custom"));
991 }
992
993 #[test]
994 fn test_format_amount_negative() {
995 let amount = Amount::new(dec!(-100.50), "USD");
996 assert_eq!(format_amount(&amount), "-100.50 USD");
997 }
998
999 #[test]
1000 fn test_format_amount_zero() {
1001 let amount = Amount::new(dec!(0), "EUR");
1002 assert_eq!(format_amount(&amount), "0 EUR");
1003 }
1004
1005 #[test]
1006 fn test_format_amount_large_number() {
1007 let amount = Amount::new(dec!(1234567890.12), "USD");
1008 assert_eq!(format_amount(&amount), "1234567890.12 USD");
1009 }
1010
1011 #[test]
1012 fn test_format_amount_small_decimal() {
1013 let amount = Amount::new(dec!(0.00001), "BTC");
1014 assert_eq!(format_amount(&amount), "0.00001 BTC");
1015 }
1016
1017 #[test]
1018 fn test_format_transaction_with_inline_comment() {
1019 let config = FormatConfig::default();
1020
1021 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1023 posting.comments = vec!["; This is an inline comment".to_string()];
1024
1025 let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1026 .with_flag('*')
1027 .with_synthesized_posting(posting)
1028 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")));
1029
1030 let formatted = format_transaction(&txn, &config);
1031
1032 assert!(
1034 formatted.contains("; This is an inline comment"),
1035 "Formatted transaction should contain inline comment: {formatted}"
1036 );
1037 let comment_pos = formatted.find("; This is an inline comment").unwrap();
1039 let expenses_pos = formatted.find("Expenses:Food").unwrap();
1040 assert!(
1041 comment_pos < expenses_pos,
1042 "Comment should appear before the posting"
1043 );
1044 }
1045
1046 #[test]
1048 fn test_issue_364_format_all_comment_types() {
1049 let config = FormatConfig::default();
1050
1051 let mut posting1 = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1053 posting1.comments = vec!["; Pre-comment 1".to_string(), "; Pre-comment 2".to_string()];
1054 posting1.trailing_comments = vec!["; trailing on posting".to_string()];
1055
1056 let mut posting2 = Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD"));
1058 posting2.comments = vec!["; Comment before second posting".to_string()];
1059
1060 let mut txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1062 .with_flag('*')
1063 .with_synthesized_posting(posting1)
1064 .with_synthesized_posting(posting2);
1065 txn.trailing_comments = vec![
1066 "; Transaction trailing 1".to_string(),
1067 "; Transaction trailing 2".to_string(),
1068 ];
1069
1070 let formatted = format_transaction(&txn, &config);
1071
1072 let lines: Vec<&str> = formatted.lines().collect();
1074
1075 assert!(lines[0].contains("2024-01-15 * \"Test transaction\""));
1077
1078 assert_eq!(lines[1].trim(), "; Pre-comment 1");
1080 assert_eq!(lines[2].trim(), "; Pre-comment 2");
1081
1082 assert!(lines[3].contains("Expenses:Food"));
1084 assert!(lines[3].contains("; trailing on posting"));
1085
1086 assert_eq!(lines[4].trim(), "; Comment before second posting");
1088
1089 assert!(lines[5].contains("Assets:Bank"));
1091
1092 assert_eq!(lines[6].trim(), "; Transaction trailing 1");
1094 assert_eq!(lines[7].trim(), "; Transaction trailing 2");
1095 }
1096
1097 #[test]
1099 fn test_issue_364_trailing_comment_on_posting_line() {
1100 let config = FormatConfig::default();
1101
1102 let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1103 posting.trailing_comments = vec!["; This goes on same line".to_string()];
1104
1105 let txn = Transaction::new(date(2024, 1, 15), "Test")
1106 .with_flag('*')
1107 .with_synthesized_posting(posting)
1108 .with_synthesized_posting(Posting::auto("Assets:Bank"));
1109
1110 let formatted = format_transaction(&txn, &config);
1111
1112 for line in formatted.lines() {
1114 if line.contains("Expenses:Food") {
1115 assert!(
1116 line.contains("; This goes on same line"),
1117 "Trailing comment should be on same line as posting: {line}"
1118 );
1119 break;
1120 }
1121 }
1122 }
1123
1124 #[test]
1125 fn test_format_posting_metadata_issue_701() {
1126 let mut posting_meta = Metadata::default();
1128 posting_meta.insert(
1129 "note".to_string(),
1130 MetaValue::String("this note is lost".to_string()),
1131 );
1132
1133 let mut posting = Posting::new("Expenses:Expense", Amount::new(dec!(10), "USD"));
1134 posting.meta = posting_meta;
1135
1136 let txn = Transaction {
1137 date: date(2026, 4, 7),
1138 flag: '*',
1139 payee: None,
1140 narration: "my expense".into(),
1141 tags: vec![],
1142 links: vec![],
1143 postings: vec![
1144 crate::Spanned::synthesized(posting),
1145 crate::Spanned::synthesized(Posting::auto("Assets:Wallet")),
1146 ],
1147 meta: Metadata::default(),
1148 trailing_comments: Vec::new(),
1149 };
1150
1151 let config = FormatConfig::default();
1152 let formatted = format_transaction(&txn, &config);
1153
1154 assert!(
1155 formatted.contains("note: \"this note is lost\""),
1156 "posting metadata should be preserved in formatted output, got:\n{formatted}"
1157 );
1158 assert!(
1160 formatted.contains(" note:"),
1161 "posting metadata should have double indent (4 spaces), got:\n{formatted}"
1162 );
1163 }
1164}