Skip to main content

rustledger_core/format/
mod.rs

1//! Beancount file formatter.
2//!
3//! Provides pretty-printing for beancount directives with configurable
4//! amount alignment.
5
6mod amount;
7mod directives;
8mod helpers;
9mod transaction;
10
11pub(crate) use amount::{format_amount, format_cost_spec, format_price_annotation};
12pub(crate) use directives::{
13    format_balance, format_close, format_commodity, format_custom, format_document, format_event,
14    format_note, format_open, format_pad, format_price, format_query,
15};
16pub use helpers::escape_string;
17pub(crate) use helpers::format_meta_value;
18pub(crate) use transaction::{format_incomplete_amount, format_transaction};
19
20use crate::Directive;
21
22/// Formatter configuration.
23#[derive(Debug, Clone)]
24pub struct FormatConfig {
25    /// Column to align amounts to (default: 60).
26    pub amount_column: usize,
27    /// Indentation for postings and metadata (default: 2 spaces).
28    pub indent: String,
29}
30
31impl Default for FormatConfig {
32    fn default() -> Self {
33        Self {
34            amount_column: 60,
35            indent: "  ".to_string(),
36        }
37    }
38}
39
40impl FormatConfig {
41    /// Create a new config with the specified amount column.
42    #[must_use]
43    pub fn with_column(column: usize) -> Self {
44        Self {
45            amount_column: column,
46            ..Default::default()
47        }
48    }
49
50    /// Create a new config with the specified indent width.
51    #[must_use]
52    pub fn with_indent(indent_width: usize) -> Self {
53        let indent = " ".repeat(indent_width);
54        Self {
55            indent,
56            ..Default::default()
57        }
58    }
59
60    /// Create a new config with both column and indent settings.
61    #[must_use]
62    pub fn new(column: usize, indent_width: usize) -> Self {
63        let indent = " ".repeat(indent_width);
64        Self {
65            amount_column: column,
66            indent,
67        }
68    }
69}
70
71/// Format a directive to a string.
72pub fn format_directive(directive: &Directive, config: &FormatConfig) -> String {
73    match directive {
74        Directive::Transaction(txn) => format_transaction(txn, config),
75        Directive::Balance(bal) => format_balance(bal, config),
76        Directive::Open(open) => format_open(open, config),
77        Directive::Close(close) => format_close(close, config),
78        Directive::Commodity(comm) => format_commodity(comm, config),
79        Directive::Pad(pad) => format_pad(pad, config),
80        Directive::Event(event) => format_event(event, config),
81        Directive::Query(query) => format_query(query, config),
82        Directive::Note(note) => format_note(note, config),
83        Directive::Document(doc) => format_document(doc, config),
84        Directive::Price(price) => format_price(price, config),
85        Directive::Custom(custom) => format_custom(custom, config),
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::transaction::format_posting;
92    use super::*;
93    use crate::{
94        Amount, Balance, Close, Commodity, CostSpec, Custom, Directive, Document, Event,
95        IncompleteAmount, MetaValue, Metadata, NaiveDate, Note, Open, Pad, Posting, Price,
96        PriceAnnotation, Query, Transaction,
97    };
98    use rust_decimal_macros::dec;
99
100    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
101        NaiveDate::from_ymd_opt(year, month, day).unwrap()
102    }
103
104    #[test]
105    fn test_format_simple_transaction() {
106        let txn = Transaction::new(date(2024, 1, 15), "Morning coffee")
107            .with_flag('*')
108            .with_payee("Coffee Shop")
109            .with_posting(Posting::new(
110                "Expenses:Food:Coffee",
111                Amount::new(dec!(5.00), "USD"),
112            ))
113            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-5.00), "USD")));
114
115        let config = FormatConfig::with_column(50);
116        let formatted = format_transaction(&txn, &config);
117
118        assert!(formatted.contains("2024-01-15 * \"Coffee Shop\" \"Morning coffee\""));
119        assert!(formatted.contains("Expenses:Food:Coffee"));
120        assert!(formatted.contains("5.00 USD"));
121    }
122
123    #[test]
124    fn test_format_balance() {
125        let bal = Balance::new(
126            date(2024, 1, 1),
127            "Assets:Bank",
128            Amount::new(dec!(1000.00), "USD"),
129        );
130        let config = FormatConfig::default();
131        let formatted = format_balance(&bal, &config);
132        assert_eq!(formatted, "2024-01-01 balance Assets:Bank 1000.00 USD\n");
133    }
134
135    #[test]
136    fn test_format_open() {
137        let open = Open {
138            date: date(2024, 1, 1),
139            account: "Assets:Bank:Checking".into(),
140            currencies: vec!["USD".into(), "EUR".into()],
141            booking: None,
142            meta: Default::default(),
143        };
144        let config = FormatConfig::default();
145        let formatted = format_open(&open, &config);
146        assert_eq!(formatted, "2024-01-01 open Assets:Bank:Checking USD,EUR\n");
147    }
148
149    #[test]
150    fn test_escape_string() {
151        assert_eq!(escape_string("hello"), "hello");
152        assert_eq!(escape_string("say \"hi\""), "say \\\"hi\\\"");
153        assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
154    }
155
156    // ====================================================================
157    // Phase 2: Additional Coverage Tests for Format Functions
158    // ====================================================================
159
160    #[test]
161    fn test_escape_string_combined() {
162        // Test escaping with quotes + backslash + newline combined
163        assert_eq!(
164            escape_string("path\\to\\file\n\"quoted\""),
165            "path\\\\to\\\\file\\n\\\"quoted\\\""
166        );
167    }
168
169    #[test]
170    fn test_escape_string_backslash_quote() {
171        // Backslash followed by quote
172        assert_eq!(escape_string("\\\""), "\\\\\\\"");
173    }
174
175    #[test]
176    fn test_escape_string_empty() {
177        assert_eq!(escape_string(""), "");
178    }
179
180    #[test]
181    fn test_escape_string_unicode() {
182        assert_eq!(escape_string("café résumé"), "café résumé");
183        assert_eq!(escape_string("日本語"), "日本語");
184        assert_eq!(escape_string("emoji 🎉"), "emoji 🎉");
185    }
186
187    #[test]
188    fn test_format_meta_value_string() {
189        let val = MetaValue::String("hello world".to_string());
190        assert_eq!(format_meta_value(&val), "\"hello world\"");
191    }
192
193    #[test]
194    fn test_format_meta_value_string_with_quotes() {
195        let val = MetaValue::String("say \"hello\"".to_string());
196        assert_eq!(format_meta_value(&val), "\"say \\\"hello\\\"\"");
197    }
198
199    #[test]
200    fn test_format_meta_value_account() {
201        let val = MetaValue::Account("Assets:Bank:Checking".to_string());
202        assert_eq!(format_meta_value(&val), "Assets:Bank:Checking");
203    }
204
205    #[test]
206    fn test_format_meta_value_currency() {
207        let val = MetaValue::Currency("USD".to_string());
208        assert_eq!(format_meta_value(&val), "USD");
209    }
210
211    #[test]
212    fn test_format_meta_value_tag() {
213        let val = MetaValue::Tag("trip-2024".to_string());
214        assert_eq!(format_meta_value(&val), "#trip-2024");
215    }
216
217    #[test]
218    fn test_format_meta_value_link() {
219        let val = MetaValue::Link("invoice-123".to_string());
220        assert_eq!(format_meta_value(&val), "^invoice-123");
221    }
222
223    #[test]
224    fn test_format_meta_value_date() {
225        let val = MetaValue::Date(date(2024, 6, 15));
226        assert_eq!(format_meta_value(&val), "2024-06-15");
227    }
228
229    #[test]
230    fn test_format_meta_value_number() {
231        let val = MetaValue::Number(dec!(123.456));
232        assert_eq!(format_meta_value(&val), "123.456");
233    }
234
235    #[test]
236    fn test_format_meta_value_amount() {
237        let val = MetaValue::Amount(Amount::new(dec!(99.99), "USD"));
238        assert_eq!(format_meta_value(&val), "99.99 USD");
239    }
240
241    #[test]
242    fn test_format_meta_value_bool_true() {
243        let val = MetaValue::Bool(true);
244        assert_eq!(format_meta_value(&val), "TRUE");
245    }
246
247    #[test]
248    fn test_format_meta_value_bool_false() {
249        let val = MetaValue::Bool(false);
250        assert_eq!(format_meta_value(&val), "FALSE");
251    }
252
253    #[test]
254    fn test_format_meta_value_none() {
255        let val = MetaValue::None;
256        assert_eq!(format_meta_value(&val), "");
257    }
258
259    #[test]
260    fn test_format_cost_spec_per_unit() {
261        let spec = CostSpec {
262            number_per: Some(dec!(150.00)),
263            number_total: None,
264            currency: Some("USD".into()),
265            date: None,
266            label: None,
267            merge: false,
268        };
269        assert_eq!(format_cost_spec(&spec), "{150.00 USD}");
270    }
271
272    #[test]
273    fn test_format_cost_spec_total() {
274        let spec = CostSpec {
275            number_per: None,
276            number_total: Some(dec!(1500.00)),
277            currency: Some("USD".into()),
278            date: None,
279            label: None,
280            merge: false,
281        };
282        assert_eq!(format_cost_spec(&spec), "{{1500.00 USD}}");
283    }
284
285    #[test]
286    fn test_format_cost_spec_with_date() {
287        let spec = CostSpec {
288            number_per: Some(dec!(150.00)),
289            number_total: None,
290            currency: Some("USD".into()),
291            date: Some(date(2024, 1, 15)),
292            label: None,
293            merge: false,
294        };
295        assert_eq!(format_cost_spec(&spec), "{150.00 USD, 2024-01-15}");
296    }
297
298    #[test]
299    fn test_format_cost_spec_with_label() {
300        let spec = CostSpec {
301            number_per: Some(dec!(150.00)),
302            number_total: None,
303            currency: Some("USD".into()),
304            date: None,
305            label: Some("lot-a".to_string()),
306            merge: false,
307        };
308        assert_eq!(format_cost_spec(&spec), "{150.00 USD, \"lot-a\"}");
309    }
310
311    #[test]
312    fn test_format_cost_spec_with_merge() {
313        let spec = CostSpec {
314            number_per: Some(dec!(150.00)),
315            number_total: None,
316            currency: Some("USD".into()),
317            date: None,
318            label: None,
319            merge: true,
320        };
321        assert_eq!(format_cost_spec(&spec), "{150.00 USD, *}");
322    }
323
324    #[test]
325    fn test_format_cost_spec_all_fields() {
326        let spec = CostSpec {
327            number_per: Some(dec!(150.00)),
328            number_total: None,
329            currency: Some("USD".into()),
330            date: Some(date(2024, 1, 15)),
331            label: Some("lot-a".to_string()),
332            merge: true,
333        };
334        assert_eq!(
335            format_cost_spec(&spec),
336            "{150.00 USD, 2024-01-15, \"lot-a\", *}"
337        );
338    }
339
340    #[test]
341    fn test_format_cost_spec_empty() {
342        let spec = CostSpec {
343            number_per: None,
344            number_total: None,
345            currency: None,
346            date: None,
347            label: None,
348            merge: false,
349        };
350        assert_eq!(format_cost_spec(&spec), "{}");
351    }
352
353    #[test]
354    fn test_format_price_annotation_unit() {
355        let price = PriceAnnotation::Unit(Amount::new(dec!(150.00), "USD"));
356        assert_eq!(format_price_annotation(&price), "@ 150.00 USD");
357    }
358
359    #[test]
360    fn test_format_price_annotation_total() {
361        let price = PriceAnnotation::Total(Amount::new(dec!(1500.00), "USD"));
362        assert_eq!(format_price_annotation(&price), "@@ 1500.00 USD");
363    }
364
365    #[test]
366    fn test_format_price_annotation_unit_incomplete() {
367        let price = PriceAnnotation::UnitIncomplete(IncompleteAmount::NumberOnly(dec!(150.00)));
368        assert_eq!(format_price_annotation(&price), "@ 150.00");
369    }
370
371    #[test]
372    fn test_format_price_annotation_total_incomplete() {
373        let price = PriceAnnotation::TotalIncomplete(IncompleteAmount::CurrencyOnly("USD".into()));
374        assert_eq!(format_price_annotation(&price), "@@ USD");
375    }
376
377    #[test]
378    fn test_format_price_annotation_unit_empty() {
379        let price = PriceAnnotation::UnitEmpty;
380        assert_eq!(format_price_annotation(&price), "@");
381    }
382
383    #[test]
384    fn test_format_price_annotation_total_empty() {
385        let price = PriceAnnotation::TotalEmpty;
386        assert_eq!(format_price_annotation(&price), "@@");
387    }
388
389    #[test]
390    fn test_format_incomplete_amount_complete() {
391        let amount = IncompleteAmount::Complete(Amount::new(dec!(100.50), "EUR"));
392        assert_eq!(format_incomplete_amount(&amount), "100.50 EUR");
393    }
394
395    #[test]
396    fn test_format_incomplete_amount_number_only() {
397        let amount = IncompleteAmount::NumberOnly(dec!(42.00));
398        assert_eq!(format_incomplete_amount(&amount), "42.00");
399    }
400
401    #[test]
402    fn test_format_incomplete_amount_currency_only() {
403        let amount = IncompleteAmount::CurrencyOnly("BTC".into());
404        assert_eq!(format_incomplete_amount(&amount), "BTC");
405    }
406
407    #[test]
408    fn test_format_close() {
409        let close = Close {
410            date: date(2024, 12, 31),
411            account: "Assets:OldAccount".into(),
412            meta: Default::default(),
413        };
414        let config = FormatConfig::default();
415        let formatted = format_close(&close, &config);
416        assert_eq!(formatted, "2024-12-31 close Assets:OldAccount\n");
417    }
418
419    #[test]
420    fn test_format_commodity() {
421        let comm = Commodity {
422            date: date(2024, 1, 1),
423            currency: "BTC".into(),
424            meta: Default::default(),
425        };
426        let config = FormatConfig::default();
427        let formatted = format_commodity(&comm, &config);
428        assert_eq!(formatted, "2024-01-01 commodity BTC\n");
429    }
430
431    #[test]
432    fn test_format_pad() {
433        let pad = Pad {
434            date: date(2024, 1, 15),
435            account: "Assets:Checking".into(),
436            source_account: "Equity:Opening-Balances".into(),
437            meta: Default::default(),
438        };
439        let config = FormatConfig::default();
440        let formatted = format_pad(&pad, &config);
441        assert_eq!(
442            formatted,
443            "2024-01-15 pad Assets:Checking Equity:Opening-Balances\n"
444        );
445    }
446
447    #[test]
448    fn test_format_event() {
449        let event = Event {
450            date: date(2024, 6, 1),
451            event_type: "location".to_string(),
452            value: "New York".to_string(),
453            meta: Default::default(),
454        };
455        let config = FormatConfig::default();
456        let formatted = format_event(&event, &config);
457        assert_eq!(formatted, "2024-06-01 event \"location\" \"New York\"\n");
458    }
459
460    #[test]
461    fn test_format_event_with_quotes() {
462        let event = Event {
463            date: date(2024, 6, 1),
464            event_type: "quote".to_string(),
465            value: "He said \"hello\"".to_string(),
466            meta: Default::default(),
467        };
468        let config = FormatConfig::default();
469        let formatted = format_event(&event, &config);
470        assert_eq!(
471            formatted,
472            "2024-06-01 event \"quote\" \"He said \\\"hello\\\"\"\n"
473        );
474    }
475
476    #[test]
477    fn test_format_query() {
478        let query = Query {
479            date: date(2024, 1, 1),
480            name: "monthly_expenses".to_string(),
481            query: "SELECT account, sum(position) WHERE account ~ 'Expenses'".to_string(),
482            meta: Default::default(),
483        };
484        let config = FormatConfig::default();
485        let formatted = format_query(&query, &config);
486        assert!(formatted.contains("query \"monthly_expenses\""));
487        assert!(formatted.contains("SELECT account"));
488    }
489
490    #[test]
491    fn test_format_note() {
492        let note = Note {
493            date: date(2024, 3, 15),
494            account: "Assets:Bank".into(),
495            comment: "Called the bank about fee".to_string(),
496            meta: Default::default(),
497        };
498        let config = FormatConfig::default();
499        let formatted = format_note(&note, &config);
500        assert_eq!(
501            formatted,
502            "2024-03-15 note Assets:Bank \"Called the bank about fee\"\n"
503        );
504    }
505
506    #[test]
507    fn test_format_document() {
508        let doc = Document {
509            date: date(2024, 2, 10),
510            account: "Assets:Bank".into(),
511            path: "/docs/statement-2024-02.pdf".to_string(),
512            tags: vec![],
513            links: vec![],
514            meta: Default::default(),
515        };
516        let config = FormatConfig::default();
517        let formatted = format_document(&doc, &config);
518        assert_eq!(
519            formatted,
520            "2024-02-10 document Assets:Bank \"/docs/statement-2024-02.pdf\"\n"
521        );
522    }
523
524    #[test]
525    fn test_format_price() {
526        let price = Price {
527            date: date(2024, 1, 15),
528            currency: "AAPL".into(),
529            amount: Amount::new(dec!(185.50), "USD"),
530            meta: Default::default(),
531        };
532        let config = FormatConfig::default();
533        let formatted = format_price(&price, &config);
534        assert_eq!(formatted, "2024-01-15 price AAPL 185.50 USD\n");
535    }
536
537    #[test]
538    fn test_format_custom() {
539        let custom = Custom {
540            date: date(2024, 1, 1),
541            custom_type: "budget".to_string(),
542            values: vec![],
543            meta: Default::default(),
544        };
545        let config = FormatConfig::default();
546        let formatted = format_custom(&custom, &config);
547        assert_eq!(formatted, "2024-01-01 custom \"budget\"\n");
548    }
549
550    #[test]
551    fn test_format_open_with_booking() {
552        let open = Open {
553            date: date(2024, 1, 1),
554            account: "Assets:Brokerage".into(),
555            currencies: vec!["USD".into()],
556            booking: Some("FIFO".to_string()),
557            meta: Default::default(),
558        };
559        let config = FormatConfig::default();
560        let formatted = format_open(&open, &config);
561        assert_eq!(formatted, "2024-01-01 open Assets:Brokerage USD \"FIFO\"\n");
562    }
563
564    #[test]
565    fn test_format_open_no_currencies() {
566        let open = Open {
567            date: date(2024, 1, 1),
568            account: "Assets:Misc".into(),
569            currencies: vec![],
570            booking: None,
571            meta: Default::default(),
572        };
573        let config = FormatConfig::default();
574        let formatted = format_open(&open, &config);
575        assert_eq!(formatted, "2024-01-01 open Assets:Misc\n");
576    }
577
578    #[test]
579    fn test_format_balance_with_tolerance() {
580        let bal = Balance {
581            date: date(2024, 1, 1),
582            account: "Assets:Bank".into(),
583            amount: Amount::new(dec!(1000.00), "USD"),
584            tolerance: Some(dec!(0.01)),
585            meta: Default::default(),
586        };
587        let config = FormatConfig::default();
588        let formatted = format_balance(&bal, &config);
589        assert_eq!(
590            formatted,
591            "2024-01-01 balance Assets:Bank 1000.00 USD ~ 0.01\n"
592        );
593    }
594
595    #[test]
596    fn test_format_transaction_with_tags() {
597        let txn = Transaction::new(date(2024, 1, 15), "Dinner")
598            .with_flag('*')
599            .with_tag("trip-2024")
600            .with_tag("food")
601            .with_posting(Posting::new(
602                "Expenses:Food",
603                Amount::new(dec!(50.00), "USD"),
604            ))
605            .with_posting(Posting::new(
606                "Assets:Cash",
607                Amount::new(dec!(-50.00), "USD"),
608            ));
609
610        let config = FormatConfig::default();
611        let formatted = format_transaction(&txn, &config);
612
613        assert!(formatted.contains("#trip-2024"));
614        assert!(formatted.contains("#food"));
615    }
616
617    #[test]
618    fn test_format_transaction_with_links() {
619        let txn = Transaction::new(date(2024, 1, 15), "Invoice payment")
620            .with_flag('*')
621            .with_link("invoice-123")
622            .with_posting(Posting::new(
623                "Income:Freelance",
624                Amount::new(dec!(-1000.00), "USD"),
625            ))
626            .with_posting(Posting::new(
627                "Assets:Bank",
628                Amount::new(dec!(1000.00), "USD"),
629            ));
630
631        let config = FormatConfig::default();
632        let formatted = format_transaction(&txn, &config);
633
634        assert!(formatted.contains("^invoice-123"));
635    }
636
637    #[test]
638    fn test_format_transaction_with_metadata() {
639        let mut meta = Metadata::default();
640        meta.insert(
641            "filename".to_string(),
642            MetaValue::String("receipt.pdf".to_string()),
643        );
644        meta.insert("verified".to_string(), MetaValue::Bool(true));
645
646        let txn = Transaction {
647            date: date(2024, 1, 15),
648            flag: '*',
649            payee: None,
650            narration: "Purchase".into(),
651            tags: vec![],
652            links: vec![],
653            postings: vec![],
654            meta,
655            trailing_comments: Vec::new(),
656        };
657
658        let config = FormatConfig::default();
659        let formatted = format_transaction(&txn, &config);
660
661        assert!(formatted.contains("filename: \"receipt.pdf\""));
662        assert!(formatted.contains("verified: TRUE"));
663    }
664
665    #[test]
666    fn test_format_posting_with_flag() {
667        let mut posting = Posting::new("Expenses:Unknown", Amount::new(dec!(100.00), "USD"));
668        posting.flag = Some('!');
669
670        let config = FormatConfig::default();
671        let formatted = format_posting(&posting, &config);
672
673        assert!(formatted.contains("! Expenses:Unknown"));
674    }
675
676    #[test]
677    fn test_format_posting_no_units() {
678        let posting = Posting {
679            flag: None,
680            account: "Assets:Bank".into(),
681            units: None,
682            cost: None,
683            price: None,
684            meta: Default::default(),
685            comments: Vec::new(),
686            trailing_comments: Vec::new(),
687        };
688
689        let config = FormatConfig::default();
690        let formatted = format_posting(&posting, &config);
691
692        assert!(formatted.contains("Assets:Bank"));
693        // No amount should appear
694        assert!(!formatted.contains("USD"));
695    }
696
697    #[test]
698    fn test_format_config_with_column() {
699        let config = FormatConfig::with_column(80);
700        assert_eq!(config.amount_column, 80);
701        assert_eq!(config.indent, "  ");
702    }
703
704    #[test]
705    fn test_format_config_with_indent() {
706        let config = FormatConfig::with_indent(4);
707        assert_eq!(config.indent, "    ");
708    }
709
710    #[test]
711    fn test_format_config_new() {
712        let config = FormatConfig::new(70, 3);
713        assert_eq!(config.amount_column, 70);
714        assert_eq!(config.indent, "   ");
715    }
716
717    #[test]
718    fn test_format_posting_long_account_name() {
719        let posting = Posting::new(
720            "Assets:Bank:Checking:Primary:Joint:Savings:Emergency:Fund:Extra:Long",
721            Amount::new(dec!(100.00), "USD"),
722        );
723
724        let config = FormatConfig::with_column(50);
725        let formatted = format_posting(&posting, &config);
726
727        // Should have at least 2 spaces between account and amount
728        assert!(formatted.contains("  100.00 USD"));
729    }
730
731    #[test]
732    fn test_format_posting_with_cost_and_price() {
733        let posting = Posting {
734            flag: None,
735            account: "Assets:Brokerage".into(),
736            units: Some(IncompleteAmount::Complete(Amount::new(dec!(10), "AAPL"))),
737            cost: Some(CostSpec {
738                number_per: Some(dec!(150.00)),
739                number_total: None,
740                currency: Some("USD".into()),
741                date: Some(date(2024, 1, 15)),
742                label: None,
743                merge: false,
744            }),
745            price: Some(PriceAnnotation::Unit(Amount::new(dec!(155.00), "USD"))),
746            meta: Default::default(),
747            comments: Vec::new(),
748            trailing_comments: Vec::new(),
749        };
750
751        let config = FormatConfig::default();
752        let formatted = format_posting(&posting, &config);
753
754        assert!(formatted.contains("10 AAPL"));
755        assert!(formatted.contains("{150.00 USD, 2024-01-15}"));
756        assert!(formatted.contains("@ 155.00 USD"));
757    }
758
759    #[test]
760    fn test_format_directive_all_types() {
761        let config = FormatConfig::default();
762
763        // Transaction
764        let txn = Transaction::new(date(2024, 1, 1), "Test")
765            .with_flag('*')
766            .with_posting(Posting::new("Expenses:Test", Amount::new(dec!(1), "USD")))
767            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1), "USD")));
768        let formatted = format_directive(&Directive::Transaction(txn), &config);
769        assert!(formatted.contains("2024-01-01"));
770
771        // Balance
772        let bal = Balance::new(
773            date(2024, 1, 1),
774            "Assets:Bank",
775            Amount::new(dec!(100), "USD"),
776        );
777        let formatted = format_directive(&Directive::Balance(bal), &config);
778        assert!(formatted.contains("balance"));
779
780        // Open
781        let open = Open {
782            date: date(2024, 1, 1),
783            account: "Assets:Test".into(),
784            currencies: vec![],
785            booking: None,
786            meta: Default::default(),
787        };
788        let formatted = format_directive(&Directive::Open(open), &config);
789        assert!(formatted.contains("open"));
790
791        // Close
792        let close = Close {
793            date: date(2024, 1, 1),
794            account: "Assets:Test".into(),
795            meta: Default::default(),
796        };
797        let formatted = format_directive(&Directive::Close(close), &config);
798        assert!(formatted.contains("close"));
799
800        // Commodity
801        let comm = Commodity {
802            date: date(2024, 1, 1),
803            currency: "BTC".into(),
804            meta: Default::default(),
805        };
806        let formatted = format_directive(&Directive::Commodity(comm), &config);
807        assert!(formatted.contains("commodity"));
808
809        // Pad
810        let pad = Pad {
811            date: date(2024, 1, 1),
812            account: "Assets:A".into(),
813            source_account: "Equity:B".into(),
814            meta: Default::default(),
815        };
816        let formatted = format_directive(&Directive::Pad(pad), &config);
817        assert!(formatted.contains("pad"));
818
819        // Event
820        let event = Event {
821            date: date(2024, 1, 1),
822            event_type: "test".to_string(),
823            value: "value".to_string(),
824            meta: Default::default(),
825        };
826        let formatted = format_directive(&Directive::Event(event), &config);
827        assert!(formatted.contains("event"));
828
829        // Query
830        let query = Query {
831            date: date(2024, 1, 1),
832            name: "test".to_string(),
833            query: "SELECT *".to_string(),
834            meta: Default::default(),
835        };
836        let formatted = format_directive(&Directive::Query(query), &config);
837        assert!(formatted.contains("query"));
838
839        // Note
840        let note = Note {
841            date: date(2024, 1, 1),
842            account: "Assets:Bank".into(),
843            comment: "test".to_string(),
844            meta: Default::default(),
845        };
846        let formatted = format_directive(&Directive::Note(note), &config);
847        assert!(formatted.contains("note"));
848
849        // Document
850        let doc = Document {
851            date: date(2024, 1, 1),
852            account: "Assets:Bank".into(),
853            path: "/path".to_string(),
854            tags: vec![],
855            links: vec![],
856            meta: Default::default(),
857        };
858        let formatted = format_directive(&Directive::Document(doc), &config);
859        assert!(formatted.contains("document"));
860
861        // Price
862        let price = Price {
863            date: date(2024, 1, 1),
864            currency: "AAPL".into(),
865            amount: Amount::new(dec!(150), "USD"),
866            meta: Default::default(),
867        };
868        let formatted = format_directive(&Directive::Price(price), &config);
869        assert!(formatted.contains("price"));
870
871        // Custom
872        let custom = Custom {
873            date: date(2024, 1, 1),
874            custom_type: "test".to_string(),
875            values: vec![],
876            meta: Default::default(),
877        };
878        let formatted = format_directive(&Directive::Custom(custom), &config);
879        assert!(formatted.contains("custom"));
880    }
881
882    #[test]
883    fn test_format_amount_negative() {
884        let amount = Amount::new(dec!(-100.50), "USD");
885        assert_eq!(format_amount(&amount), "-100.50 USD");
886    }
887
888    #[test]
889    fn test_format_amount_zero() {
890        let amount = Amount::new(dec!(0), "EUR");
891        assert_eq!(format_amount(&amount), "0 EUR");
892    }
893
894    #[test]
895    fn test_format_amount_large_number() {
896        let amount = Amount::new(dec!(1234567890.12), "USD");
897        assert_eq!(format_amount(&amount), "1234567890.12 USD");
898    }
899
900    #[test]
901    fn test_format_amount_small_decimal() {
902        let amount = Amount::new(dec!(0.00001), "BTC");
903        assert_eq!(format_amount(&amount), "0.00001 BTC");
904    }
905
906    #[test]
907    fn test_format_transaction_with_inline_comment() {
908        let config = FormatConfig::default();
909
910        // Create a posting with an inline comment
911        let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
912        posting.comments = vec!["; This is an inline comment".to_string()];
913
914        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
915            .with_flag('*')
916            .with_posting(posting)
917            .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")));
918
919        let formatted = format_transaction(&txn, &config);
920
921        // The inline comment should appear before the first posting
922        assert!(
923            formatted.contains("; This is an inline comment"),
924            "Formatted transaction should contain inline comment: {formatted}"
925        );
926        // Comment should appear before Expenses:Food
927        let comment_pos = formatted.find("; This is an inline comment").unwrap();
928        let expenses_pos = formatted.find("Expenses:Food").unwrap();
929        assert!(
930            comment_pos < expenses_pos,
931            "Comment should appear before the posting"
932        );
933    }
934
935    // Issue #364: Comprehensive test for all comment positions in transactions
936    #[test]
937    fn test_issue_364_format_all_comment_types() {
938        let config = FormatConfig::default();
939
940        // Create first posting with pre-comments and trailing comment
941        let mut posting1 = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
942        posting1.comments = vec!["; Pre-comment 1".to_string(), "; Pre-comment 2".to_string()];
943        posting1.trailing_comments = vec!["; trailing on posting".to_string()];
944
945        // Create second posting with pre-comment
946        let mut posting2 = Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD"));
947        posting2.comments = vec!["; Comment before second posting".to_string()];
948
949        // Create transaction with trailing comments
950        let mut txn = Transaction::new(date(2024, 1, 15), "Test transaction")
951            .with_flag('*')
952            .with_posting(posting1)
953            .with_posting(posting2);
954        txn.trailing_comments = vec![
955            "; Transaction trailing 1".to_string(),
956            "; Transaction trailing 2".to_string(),
957        ];
958
959        let formatted = format_transaction(&txn, &config);
960
961        // Verify all comments are present in correct order
962        let lines: Vec<&str> = formatted.lines().collect();
963
964        // Line 0: transaction header
965        assert!(lines[0].contains("2024-01-15 * \"Test transaction\""));
966
967        // Lines 1-2: pre-comments for first posting
968        assert_eq!(lines[1].trim(), "; Pre-comment 1");
969        assert_eq!(lines[2].trim(), "; Pre-comment 2");
970
971        // Line 3: first posting with trailing comment
972        assert!(lines[3].contains("Expenses:Food"));
973        assert!(lines[3].contains("; trailing on posting"));
974
975        // Line 4: pre-comment for second posting
976        assert_eq!(lines[4].trim(), "; Comment before second posting");
977
978        // Line 5: second posting
979        assert!(lines[5].contains("Assets:Bank"));
980
981        // Lines 6-7: transaction trailing comments
982        assert_eq!(lines[6].trim(), "; Transaction trailing 1");
983        assert_eq!(lines[7].trim(), "; Transaction trailing 2");
984    }
985
986    // Issue #364: Verify trailing comments on posting line are formatted correctly
987    #[test]
988    fn test_issue_364_trailing_comment_on_posting_line() {
989        let config = FormatConfig::default();
990
991        let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
992        posting.trailing_comments = vec!["; This goes on same line".to_string()];
993
994        let txn = Transaction::new(date(2024, 1, 15), "Test")
995            .with_flag('*')
996            .with_posting(posting)
997            .with_posting(Posting::auto("Assets:Bank"));
998
999        let formatted = format_transaction(&txn, &config);
1000
1001        // The trailing comment should be on the same line as the posting
1002        for line in formatted.lines() {
1003            if line.contains("Expenses:Food") {
1004                assert!(
1005                    line.contains("; This goes on same line"),
1006                    "Trailing comment should be on same line as posting: {line}"
1007                );
1008                break;
1009            }
1010        }
1011    }
1012}