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