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    /// Regression test for issue #573: custom directive values were not formatted
551    /// <https://github.com/rustledger/rustledger/issues/573>
552    #[test]
553    fn test_issue_573_format_custom_with_values() {
554        // Test case from issue: fava-option with multiple string values
555        let custom = Custom {
556            date: date(2024, 1, 1),
557            custom_type: "fava-option".to_string(),
558            values: vec![
559                MetaValue::String("language".to_string()),
560                MetaValue::String("en".to_string()),
561            ],
562            meta: Default::default(),
563        };
564        let config = FormatConfig::default();
565        let formatted = format_custom(&custom, &config);
566        assert_eq!(
567            formatted,
568            "2024-01-01 custom \"fava-option\" \"language\" \"en\"\n"
569        );
570    }
571
572    #[test]
573    fn test_format_custom_with_mixed_values() {
574        // Test custom directive with various value types
575        let custom = Custom {
576            date: date(2024, 3, 15),
577            custom_type: "budget".to_string(),
578            values: vec![
579                MetaValue::Account("Expenses:Food".to_string()),
580                MetaValue::Amount(Amount::new(dec!(500), "USD")),
581                MetaValue::String("monthly".to_string()),
582            ],
583            meta: Default::default(),
584        };
585        let config = FormatConfig::default();
586        let formatted = format_custom(&custom, &config);
587        assert_eq!(
588            formatted,
589            "2024-03-15 custom \"budget\" Expenses:Food 500 USD \"monthly\"\n"
590        );
591    }
592
593    #[test]
594    fn test_format_open_with_booking() {
595        let open = Open {
596            date: date(2024, 1, 1),
597            account: "Assets:Brokerage".into(),
598            currencies: vec!["USD".into()],
599            booking: Some("FIFO".to_string()),
600            meta: Default::default(),
601        };
602        let config = FormatConfig::default();
603        let formatted = format_open(&open, &config);
604        assert_eq!(formatted, "2024-01-01 open Assets:Brokerage USD \"FIFO\"\n");
605    }
606
607    #[test]
608    fn test_format_open_no_currencies() {
609        let open = Open {
610            date: date(2024, 1, 1),
611            account: "Assets:Misc".into(),
612            currencies: vec![],
613            booking: None,
614            meta: Default::default(),
615        };
616        let config = FormatConfig::default();
617        let formatted = format_open(&open, &config);
618        assert_eq!(formatted, "2024-01-01 open Assets:Misc\n");
619    }
620
621    #[test]
622    fn test_format_balance_with_tolerance() {
623        let bal = Balance {
624            date: date(2024, 1, 1),
625            account: "Assets:Bank".into(),
626            amount: Amount::new(dec!(1000.00), "USD"),
627            tolerance: Some(dec!(0.01)),
628            meta: Default::default(),
629        };
630        let config = FormatConfig::default();
631        let formatted = format_balance(&bal, &config);
632        assert_eq!(
633            formatted,
634            "2024-01-01 balance Assets:Bank 1000.00 USD ~ 0.01\n"
635        );
636    }
637
638    #[test]
639    fn test_format_transaction_with_tags() {
640        let txn = Transaction::new(date(2024, 1, 15), "Dinner")
641            .with_flag('*')
642            .with_tag("trip-2024")
643            .with_tag("food")
644            .with_posting(Posting::new(
645                "Expenses:Food",
646                Amount::new(dec!(50.00), "USD"),
647            ))
648            .with_posting(Posting::new(
649                "Assets:Cash",
650                Amount::new(dec!(-50.00), "USD"),
651            ));
652
653        let config = FormatConfig::default();
654        let formatted = format_transaction(&txn, &config);
655
656        assert!(formatted.contains("#trip-2024"));
657        assert!(formatted.contains("#food"));
658    }
659
660    #[test]
661    fn test_format_transaction_with_links() {
662        let txn = Transaction::new(date(2024, 1, 15), "Invoice payment")
663            .with_flag('*')
664            .with_link("invoice-123")
665            .with_posting(Posting::new(
666                "Income:Freelance",
667                Amount::new(dec!(-1000.00), "USD"),
668            ))
669            .with_posting(Posting::new(
670                "Assets:Bank",
671                Amount::new(dec!(1000.00), "USD"),
672            ));
673
674        let config = FormatConfig::default();
675        let formatted = format_transaction(&txn, &config);
676
677        assert!(formatted.contains("^invoice-123"));
678    }
679
680    #[test]
681    fn test_format_transaction_with_metadata() {
682        let mut meta = Metadata::default();
683        meta.insert(
684            "filename".to_string(),
685            MetaValue::String("receipt.pdf".to_string()),
686        );
687        meta.insert("verified".to_string(), MetaValue::Bool(true));
688
689        let txn = Transaction {
690            date: date(2024, 1, 15),
691            flag: '*',
692            payee: None,
693            narration: "Purchase".into(),
694            tags: vec![],
695            links: vec![],
696            postings: vec![],
697            meta,
698            trailing_comments: Vec::new(),
699        };
700
701        let config = FormatConfig::default();
702        let formatted = format_transaction(&txn, &config);
703
704        assert!(formatted.contains("filename: \"receipt.pdf\""));
705        assert!(formatted.contains("verified: TRUE"));
706    }
707
708    #[test]
709    fn test_format_posting_with_flag() {
710        let mut posting = Posting::new("Expenses:Unknown", Amount::new(dec!(100.00), "USD"));
711        posting.flag = Some('!');
712
713        let config = FormatConfig::default();
714        let formatted = format_posting(&posting, &config);
715
716        assert!(formatted.contains("! Expenses:Unknown"));
717    }
718
719    #[test]
720    fn test_format_posting_no_units() {
721        let posting = Posting {
722            flag: None,
723            account: "Assets:Bank".into(),
724            units: None,
725            cost: None,
726            price: None,
727            meta: Default::default(),
728            comments: Vec::new(),
729            trailing_comments: Vec::new(),
730        };
731
732        let config = FormatConfig::default();
733        let formatted = format_posting(&posting, &config);
734
735        assert!(formatted.contains("Assets:Bank"));
736        // No amount should appear
737        assert!(!formatted.contains("USD"));
738    }
739
740    #[test]
741    fn test_format_config_with_column() {
742        let config = FormatConfig::with_column(80);
743        assert_eq!(config.amount_column, 80);
744        assert_eq!(config.indent, "  ");
745    }
746
747    #[test]
748    fn test_format_config_with_indent() {
749        let config = FormatConfig::with_indent(4);
750        assert_eq!(config.indent, "    ");
751    }
752
753    #[test]
754    fn test_format_config_new() {
755        let config = FormatConfig::new(70, 3);
756        assert_eq!(config.amount_column, 70);
757        assert_eq!(config.indent, "   ");
758    }
759
760    #[test]
761    fn test_format_posting_long_account_name() {
762        let posting = Posting::new(
763            "Assets:Bank:Checking:Primary:Joint:Savings:Emergency:Fund:Extra:Long",
764            Amount::new(dec!(100.00), "USD"),
765        );
766
767        let config = FormatConfig::with_column(50);
768        let formatted = format_posting(&posting, &config);
769
770        // Should have at least 2 spaces between account and amount
771        assert!(formatted.contains("  100.00 USD"));
772    }
773
774    #[test]
775    fn test_format_posting_with_cost_and_price() {
776        let posting = Posting {
777            flag: None,
778            account: "Assets:Brokerage".into(),
779            units: Some(IncompleteAmount::Complete(Amount::new(dec!(10), "AAPL"))),
780            cost: Some(CostSpec {
781                number_per: Some(dec!(150.00)),
782                number_total: None,
783                currency: Some("USD".into()),
784                date: Some(date(2024, 1, 15)),
785                label: None,
786                merge: false,
787            }),
788            price: Some(PriceAnnotation::Unit(Amount::new(dec!(155.00), "USD"))),
789            meta: Default::default(),
790            comments: Vec::new(),
791            trailing_comments: Vec::new(),
792        };
793
794        let config = FormatConfig::default();
795        let formatted = format_posting(&posting, &config);
796
797        assert!(formatted.contains("10 AAPL"));
798        assert!(formatted.contains("{150.00 USD, 2024-01-15}"));
799        assert!(formatted.contains("@ 155.00 USD"));
800    }
801
802    #[test]
803    fn test_format_directive_all_types() {
804        let config = FormatConfig::default();
805
806        // Transaction
807        let txn = Transaction::new(date(2024, 1, 1), "Test")
808            .with_flag('*')
809            .with_posting(Posting::new("Expenses:Test", Amount::new(dec!(1), "USD")))
810            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1), "USD")));
811        let formatted = format_directive(&Directive::Transaction(txn), &config);
812        assert!(formatted.contains("2024-01-01"));
813
814        // Balance
815        let bal = Balance::new(
816            date(2024, 1, 1),
817            "Assets:Bank",
818            Amount::new(dec!(100), "USD"),
819        );
820        let formatted = format_directive(&Directive::Balance(bal), &config);
821        assert!(formatted.contains("balance"));
822
823        // Open
824        let open = Open {
825            date: date(2024, 1, 1),
826            account: "Assets:Test".into(),
827            currencies: vec![],
828            booking: None,
829            meta: Default::default(),
830        };
831        let formatted = format_directive(&Directive::Open(open), &config);
832        assert!(formatted.contains("open"));
833
834        // Close
835        let close = Close {
836            date: date(2024, 1, 1),
837            account: "Assets:Test".into(),
838            meta: Default::default(),
839        };
840        let formatted = format_directive(&Directive::Close(close), &config);
841        assert!(formatted.contains("close"));
842
843        // Commodity
844        let comm = Commodity {
845            date: date(2024, 1, 1),
846            currency: "BTC".into(),
847            meta: Default::default(),
848        };
849        let formatted = format_directive(&Directive::Commodity(comm), &config);
850        assert!(formatted.contains("commodity"));
851
852        // Pad
853        let pad = Pad {
854            date: date(2024, 1, 1),
855            account: "Assets:A".into(),
856            source_account: "Equity:B".into(),
857            meta: Default::default(),
858        };
859        let formatted = format_directive(&Directive::Pad(pad), &config);
860        assert!(formatted.contains("pad"));
861
862        // Event
863        let event = Event {
864            date: date(2024, 1, 1),
865            event_type: "test".to_string(),
866            value: "value".to_string(),
867            meta: Default::default(),
868        };
869        let formatted = format_directive(&Directive::Event(event), &config);
870        assert!(formatted.contains("event"));
871
872        // Query
873        let query = Query {
874            date: date(2024, 1, 1),
875            name: "test".to_string(),
876            query: "SELECT *".to_string(),
877            meta: Default::default(),
878        };
879        let formatted = format_directive(&Directive::Query(query), &config);
880        assert!(formatted.contains("query"));
881
882        // Note
883        let note = Note {
884            date: date(2024, 1, 1),
885            account: "Assets:Bank".into(),
886            comment: "test".to_string(),
887            meta: Default::default(),
888        };
889        let formatted = format_directive(&Directive::Note(note), &config);
890        assert!(formatted.contains("note"));
891
892        // Document
893        let doc = Document {
894            date: date(2024, 1, 1),
895            account: "Assets:Bank".into(),
896            path: "/path".to_string(),
897            tags: vec![],
898            links: vec![],
899            meta: Default::default(),
900        };
901        let formatted = format_directive(&Directive::Document(doc), &config);
902        assert!(formatted.contains("document"));
903
904        // Price
905        let price = Price {
906            date: date(2024, 1, 1),
907            currency: "AAPL".into(),
908            amount: Amount::new(dec!(150), "USD"),
909            meta: Default::default(),
910        };
911        let formatted = format_directive(&Directive::Price(price), &config);
912        assert!(formatted.contains("price"));
913
914        // Custom
915        let custom = Custom {
916            date: date(2024, 1, 1),
917            custom_type: "test".to_string(),
918            values: vec![],
919            meta: Default::default(),
920        };
921        let formatted = format_directive(&Directive::Custom(custom), &config);
922        assert!(formatted.contains("custom"));
923    }
924
925    #[test]
926    fn test_format_amount_negative() {
927        let amount = Amount::new(dec!(-100.50), "USD");
928        assert_eq!(format_amount(&amount), "-100.50 USD");
929    }
930
931    #[test]
932    fn test_format_amount_zero() {
933        let amount = Amount::new(dec!(0), "EUR");
934        assert_eq!(format_amount(&amount), "0 EUR");
935    }
936
937    #[test]
938    fn test_format_amount_large_number() {
939        let amount = Amount::new(dec!(1234567890.12), "USD");
940        assert_eq!(format_amount(&amount), "1234567890.12 USD");
941    }
942
943    #[test]
944    fn test_format_amount_small_decimal() {
945        let amount = Amount::new(dec!(0.00001), "BTC");
946        assert_eq!(format_amount(&amount), "0.00001 BTC");
947    }
948
949    #[test]
950    fn test_format_transaction_with_inline_comment() {
951        let config = FormatConfig::default();
952
953        // Create a posting with an inline comment
954        let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
955        posting.comments = vec!["; This is an inline comment".to_string()];
956
957        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
958            .with_flag('*')
959            .with_posting(posting)
960            .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")));
961
962        let formatted = format_transaction(&txn, &config);
963
964        // The inline comment should appear before the first posting
965        assert!(
966            formatted.contains("; This is an inline comment"),
967            "Formatted transaction should contain inline comment: {formatted}"
968        );
969        // Comment should appear before Expenses:Food
970        let comment_pos = formatted.find("; This is an inline comment").unwrap();
971        let expenses_pos = formatted.find("Expenses:Food").unwrap();
972        assert!(
973            comment_pos < expenses_pos,
974            "Comment should appear before the posting"
975        );
976    }
977
978    // Issue #364: Comprehensive test for all comment positions in transactions
979    #[test]
980    fn test_issue_364_format_all_comment_types() {
981        let config = FormatConfig::default();
982
983        // Create first posting with pre-comments and trailing comment
984        let mut posting1 = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
985        posting1.comments = vec!["; Pre-comment 1".to_string(), "; Pre-comment 2".to_string()];
986        posting1.trailing_comments = vec!["; trailing on posting".to_string()];
987
988        // Create second posting with pre-comment
989        let mut posting2 = Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD"));
990        posting2.comments = vec!["; Comment before second posting".to_string()];
991
992        // Create transaction with trailing comments
993        let mut txn = Transaction::new(date(2024, 1, 15), "Test transaction")
994            .with_flag('*')
995            .with_posting(posting1)
996            .with_posting(posting2);
997        txn.trailing_comments = vec![
998            "; Transaction trailing 1".to_string(),
999            "; Transaction trailing 2".to_string(),
1000        ];
1001
1002        let formatted = format_transaction(&txn, &config);
1003
1004        // Verify all comments are present in correct order
1005        let lines: Vec<&str> = formatted.lines().collect();
1006
1007        // Line 0: transaction header
1008        assert!(lines[0].contains("2024-01-15 * \"Test transaction\""));
1009
1010        // Lines 1-2: pre-comments for first posting
1011        assert_eq!(lines[1].trim(), "; Pre-comment 1");
1012        assert_eq!(lines[2].trim(), "; Pre-comment 2");
1013
1014        // Line 3: first posting with trailing comment
1015        assert!(lines[3].contains("Expenses:Food"));
1016        assert!(lines[3].contains("; trailing on posting"));
1017
1018        // Line 4: pre-comment for second posting
1019        assert_eq!(lines[4].trim(), "; Comment before second posting");
1020
1021        // Line 5: second posting
1022        assert!(lines[5].contains("Assets:Bank"));
1023
1024        // Lines 6-7: transaction trailing comments
1025        assert_eq!(lines[6].trim(), "; Transaction trailing 1");
1026        assert_eq!(lines[7].trim(), "; Transaction trailing 2");
1027    }
1028
1029    // Issue #364: Verify trailing comments on posting line are formatted correctly
1030    #[test]
1031    fn test_issue_364_trailing_comment_on_posting_line() {
1032        let config = FormatConfig::default();
1033
1034        let mut posting = Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"));
1035        posting.trailing_comments = vec!["; This goes on same line".to_string()];
1036
1037        let txn = Transaction::new(date(2024, 1, 15), "Test")
1038            .with_flag('*')
1039            .with_posting(posting)
1040            .with_posting(Posting::auto("Assets:Bank"));
1041
1042        let formatted = format_transaction(&txn, &config);
1043
1044        // The trailing comment should be on the same line as the posting
1045        for line in formatted.lines() {
1046            if line.contains("Expenses:Food") {
1047                assert!(
1048                    line.contains("; This goes on same line"),
1049                    "Trailing comment should be on same line as posting: {line}"
1050                );
1051                break;
1052            }
1053        }
1054    }
1055}