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