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