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