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