rustledger_core/
format.rs

1//! Beancount file formatter.
2//!
3//! Provides pretty-printing for beancount directives with configurable
4//! amount alignment.
5
6use crate::{
7    Amount, Balance, Close, Commodity, CostSpec, Custom, Directive, Document, Event,
8    IncompleteAmount, MetaValue, Note, Open, Pad, Posting, Price, PriceAnnotation, Query,
9    Transaction,
10};
11use std::fmt::Write;
12
13/// Formatter configuration.
14#[derive(Debug, Clone)]
15pub struct FormatConfig {
16    /// Column to align amounts to (default: 60).
17    pub amount_column: usize,
18    /// Indentation for postings.
19    pub indent: String,
20    /// Indentation for metadata.
21    pub meta_indent: String,
22}
23
24impl Default for FormatConfig {
25    fn default() -> Self {
26        Self {
27            amount_column: 60,
28            indent: "  ".to_string(),
29            meta_indent: "    ".to_string(),
30        }
31    }
32}
33
34impl FormatConfig {
35    /// Create a new config with the specified amount column.
36    #[must_use]
37    pub fn with_column(column: usize) -> Self {
38        Self {
39            amount_column: column,
40            ..Default::default()
41        }
42    }
43
44    /// Create a new config with the specified indent width.
45    #[must_use]
46    pub fn with_indent(indent_width: usize) -> Self {
47        let indent = " ".repeat(indent_width);
48        let meta_indent = " ".repeat(indent_width * 2);
49        Self {
50            indent,
51            meta_indent,
52            ..Default::default()
53        }
54    }
55
56    /// Create a new config with both column and indent settings.
57    #[must_use]
58    pub fn new(column: usize, indent_width: usize) -> Self {
59        let indent = " ".repeat(indent_width);
60        let meta_indent = " ".repeat(indent_width * 2);
61        Self {
62            amount_column: column,
63            indent,
64            meta_indent,
65        }
66    }
67}
68
69/// Format a directive to a string.
70pub fn format_directive(directive: &Directive, config: &FormatConfig) -> String {
71    match directive {
72        Directive::Transaction(txn) => format_transaction(txn, config),
73        Directive::Balance(bal) => format_balance(bal),
74        Directive::Open(open) => format_open(open),
75        Directive::Close(close) => format_close(close),
76        Directive::Commodity(comm) => format_commodity(comm),
77        Directive::Pad(pad) => format_pad(pad),
78        Directive::Event(event) => format_event(event),
79        Directive::Query(query) => format_query(query),
80        Directive::Note(note) => format_note(note),
81        Directive::Document(doc) => format_document(doc),
82        Directive::Price(price) => format_price(price),
83        Directive::Custom(custom) => format_custom(custom),
84    }
85}
86
87/// Format a transaction.
88fn format_transaction(txn: &Transaction, config: &FormatConfig) -> String {
89    let mut out = String::new();
90
91    // Date and flag
92    write!(out, "{} {}", txn.date, txn.flag).unwrap();
93
94    // Payee and narration
95    if let Some(payee) = &txn.payee {
96        write!(out, " \"{}\"", escape_string(payee)).unwrap();
97    }
98    write!(out, " \"{}\"", escape_string(&txn.narration)).unwrap();
99
100    // Tags
101    for tag in &txn.tags {
102        write!(out, " #{tag}").unwrap();
103    }
104
105    // Links
106    for link in &txn.links {
107        write!(out, " ^{link}").unwrap();
108    }
109
110    out.push('\n');
111
112    // Transaction-level metadata
113    for (key, value) in &txn.meta {
114        writeln!(
115            out,
116            "{}{}: {}",
117            &config.indent,
118            key,
119            format_meta_value(value)
120        )
121        .unwrap();
122    }
123
124    // Postings
125    for posting in &txn.postings {
126        out.push_str(&format_posting(posting, config));
127        out.push('\n');
128    }
129
130    out
131}
132
133/// Format a posting with amount alignment.
134fn format_posting(posting: &Posting, config: &FormatConfig) -> String {
135    let mut line = String::new();
136    line.push_str(&config.indent);
137
138    // Flag (if present)
139    if let Some(flag) = posting.flag {
140        write!(line, "{flag} ").unwrap();
141    }
142
143    // Account
144    line.push_str(&posting.account);
145
146    // Units, cost, price
147    if let Some(incomplete_amount) = &posting.units {
148        // Calculate padding to align amount
149        let current_len = line.len();
150        let amount_str = format_incomplete_amount(incomplete_amount);
151        let amount_with_extras =
152            format_posting_incomplete_amount(incomplete_amount, &posting.cost, &posting.price);
153
154        // Pad to align the number at the configured column
155        let target_col = config.amount_column.saturating_sub(amount_str.len());
156        if current_len < target_col {
157            let padding = target_col - current_len;
158            for _ in 0..padding {
159                line.push(' ');
160            }
161        } else {
162            line.push_str("  "); // Minimum 2 spaces
163        }
164
165        line.push_str(&amount_with_extras);
166    }
167
168    line
169}
170
171/// Format an incomplete amount.
172fn format_incomplete_amount(amount: &IncompleteAmount) -> String {
173    match amount {
174        IncompleteAmount::Complete(a) => format!("{} {}", a.number, a.currency),
175        IncompleteAmount::NumberOnly(n) => n.to_string(),
176        IncompleteAmount::CurrencyOnly(c) => c.clone(),
177    }
178}
179
180/// Format the amount part of a posting with incomplete amount support.
181fn format_posting_incomplete_amount(
182    units: &IncompleteAmount,
183    cost: &Option<CostSpec>,
184    price: &Option<PriceAnnotation>,
185) -> String {
186    let mut out = format_incomplete_amount(units);
187
188    // Cost spec
189    if let Some(cost_spec) = cost {
190        out.push(' ');
191        out.push_str(&format_cost_spec(cost_spec));
192    }
193
194    // Price annotation
195    if let Some(price_ann) = price {
196        out.push(' ');
197        out.push_str(&format_price_annotation(price_ann));
198    }
199
200    out
201}
202
203/// Format the amount part of a posting (units + cost + price).
204#[allow(dead_code)]
205fn format_posting_amount(
206    units: &Amount,
207    cost: &Option<CostSpec>,
208    price: &Option<PriceAnnotation>,
209) -> String {
210    let mut out = format_amount(units);
211
212    // Cost spec
213    if let Some(cost_spec) = cost {
214        out.push(' ');
215        out.push_str(&format_cost_spec(cost_spec));
216    }
217
218    // Price annotation
219    if let Some(price_ann) = price {
220        out.push(' ');
221        out.push_str(&format_price_annotation(price_ann));
222    }
223
224    out
225}
226
227/// Format an amount.
228fn format_amount(amount: &Amount) -> String {
229    format!("{} {}", amount.number, amount.currency)
230}
231
232/// Format a cost specification.
233fn format_cost_spec(spec: &CostSpec) -> String {
234    let mut parts = Vec::new();
235
236    // Amount (per-unit or total)
237    if let (Some(num), Some(curr)) = (&spec.number_per, &spec.currency) {
238        parts.push(format!("{num} {curr}"));
239    } else if let (Some(num), Some(curr)) = (&spec.number_total, &spec.currency) {
240        // Total cost uses double braces
241        return format!("{{{{{num} {curr}}}}}");
242    }
243
244    // Date
245    if let Some(date) = spec.date {
246        parts.push(date.to_string());
247    }
248
249    // Label
250    if let Some(label) = &spec.label {
251        parts.push(format!("\"{}\"", escape_string(label)));
252    }
253
254    // Merge marker
255    if spec.merge {
256        parts.push("*".to_string());
257    }
258
259    format!("{{{}}}", parts.join(", "))
260}
261
262/// Format a price annotation.
263fn format_price_annotation(price: &PriceAnnotation) -> String {
264    match price {
265        PriceAnnotation::Unit(amount) => format!("@ {}", format_amount(amount)),
266        PriceAnnotation::Total(amount) => format!("@@ {}", format_amount(amount)),
267        PriceAnnotation::UnitIncomplete(inc) => format!("@ {}", format_incomplete_amount(inc)),
268        PriceAnnotation::TotalIncomplete(inc) => format!("@@ {}", format_incomplete_amount(inc)),
269        PriceAnnotation::UnitEmpty => "@".to_string(),
270        PriceAnnotation::TotalEmpty => "@@".to_string(),
271    }
272}
273
274/// Format a metadata value.
275fn format_meta_value(value: &MetaValue) -> String {
276    match value {
277        MetaValue::String(s) => format!("\"{}\"", escape_string(s)),
278        MetaValue::Account(a) => a.clone(),
279        MetaValue::Currency(c) => c.clone(),
280        MetaValue::Tag(t) => format!("#{t}"),
281        MetaValue::Link(l) => format!("^{l}"),
282        MetaValue::Date(d) => d.to_string(),
283        MetaValue::Number(n) => n.to_string(),
284        MetaValue::Amount(a) => format_amount(a),
285        MetaValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
286        MetaValue::None => String::new(),
287    }
288}
289
290/// Format a balance directive.
291fn format_balance(bal: &Balance) -> String {
292    let mut out = format!(
293        "{} balance {} {}",
294        bal.date,
295        bal.account,
296        format_amount(&bal.amount)
297    );
298    if let Some(tol) = &bal.tolerance {
299        write!(out, " ~ {tol}").unwrap();
300    }
301    out.push('\n');
302    out
303}
304
305/// Format an open directive.
306fn format_open(open: &Open) -> String {
307    let mut out = format!("{} open {}", open.date, open.account);
308    if !open.currencies.is_empty() {
309        write!(out, " {}", open.currencies.join(",")).unwrap();
310    }
311    if let Some(booking) = &open.booking {
312        write!(out, " \"{booking}\"").unwrap();
313    }
314    out.push('\n');
315    out
316}
317
318/// Format a close directive.
319fn format_close(close: &Close) -> String {
320    format!("{} close {}\n", close.date, close.account)
321}
322
323/// Format a commodity directive.
324fn format_commodity(comm: &Commodity) -> String {
325    format!("{} commodity {}\n", comm.date, comm.currency)
326}
327
328/// Format a pad directive.
329fn format_pad(pad: &Pad) -> String {
330    format!("{} pad {} {}\n", pad.date, pad.account, pad.source_account)
331}
332
333/// Format an event directive.
334fn format_event(event: &Event) -> String {
335    format!(
336        "{} event \"{}\" \"{}\"\n",
337        event.date,
338        escape_string(&event.event_type),
339        escape_string(&event.value)
340    )
341}
342
343/// Format a query directive.
344fn format_query(query: &Query) -> String {
345    format!(
346        "{} query \"{}\" \"{}\"\n",
347        query.date,
348        escape_string(&query.name),
349        escape_string(&query.query)
350    )
351}
352
353/// Format a note directive.
354fn format_note(note: &Note) -> String {
355    format!(
356        "{} note {} \"{}\"\n",
357        note.date,
358        note.account,
359        escape_string(&note.comment)
360    )
361}
362
363/// Format a document directive.
364fn format_document(doc: &Document) -> String {
365    format!(
366        "{} document {} \"{}\"\n",
367        doc.date,
368        doc.account,
369        escape_string(&doc.path)
370    )
371}
372
373/// Format a price directive.
374fn format_price(price: &Price) -> String {
375    format!(
376        "{} price {} {}\n",
377        price.date,
378        price.currency,
379        format_amount(&price.amount)
380    )
381}
382
383/// Format a custom directive.
384fn format_custom(custom: &Custom) -> String {
385    format!(
386        "{} custom \"{}\"\n",
387        custom.date,
388        escape_string(&custom.custom_type)
389    )
390}
391
392/// Escape a string for output (handle quotes and backslashes).
393fn escape_string(s: &str) -> String {
394    let mut out = String::with_capacity(s.len());
395    for c in s.chars() {
396        match c {
397            '"' => out.push_str("\\\""),
398            '\\' => out.push_str("\\\\"),
399            '\n' => out.push_str("\\n"),
400            _ => out.push(c),
401        }
402    }
403    out
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use rust_decimal_macros::dec;
410    use crate::NaiveDate;
411
412    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
413        NaiveDate::from_ymd_opt(year, month, day).unwrap()
414    }
415
416    #[test]
417    fn test_format_simple_transaction() {
418        let txn = Transaction::new(date(2024, 1, 15), "Morning coffee")
419            .with_flag('*')
420            .with_payee("Coffee Shop")
421            .with_posting(Posting::new(
422                "Expenses:Food:Coffee",
423                Amount::new(dec!(5.00), "USD"),
424            ))
425            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-5.00), "USD")));
426
427        let config = FormatConfig::with_column(50);
428        let formatted = format_transaction(&txn, &config);
429
430        assert!(formatted.contains("2024-01-15 * \"Coffee Shop\" \"Morning coffee\""));
431        assert!(formatted.contains("Expenses:Food:Coffee"));
432        assert!(formatted.contains("5.00 USD"));
433    }
434
435    #[test]
436    fn test_format_balance() {
437        let bal = Balance::new(
438            date(2024, 1, 1),
439            "Assets:Bank",
440            Amount::new(dec!(1000.00), "USD"),
441        );
442        let formatted = format_balance(&bal);
443        assert_eq!(formatted, "2024-01-01 balance Assets:Bank 1000.00 USD\n");
444    }
445
446    #[test]
447    fn test_format_open() {
448        let open = Open {
449            date: date(2024, 1, 1),
450            account: "Assets:Bank:Checking".to_string(),
451            currencies: vec!["USD".to_string(), "EUR".to_string()],
452            booking: None,
453            meta: Default::default(),
454        };
455        let formatted = format_open(&open);
456        assert_eq!(formatted, "2024-01-01 open Assets:Bank:Checking USD,EUR\n");
457    }
458
459    #[test]
460    fn test_escape_string() {
461        assert_eq!(escape_string("hello"), "hello");
462        assert_eq!(escape_string("say \"hi\""), "say \\\"hi\\\"");
463        assert_eq!(escape_string("line1\nline2"), "line1\\nline2");
464    }
465}