1use 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#[derive(Debug, Clone)]
15pub struct FormatConfig {
16 pub amount_column: usize,
18 pub indent: String,
20 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 #[must_use]
37 pub fn with_column(column: usize) -> Self {
38 Self {
39 amount_column: column,
40 ..Default::default()
41 }
42 }
43
44 #[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 #[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
69pub 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
87fn format_transaction(txn: &Transaction, config: &FormatConfig) -> String {
89 let mut out = String::new();
90
91 write!(out, "{} {}", txn.date, txn.flag).unwrap();
93
94 if let Some(payee) = &txn.payee {
96 write!(out, " \"{}\"", escape_string(payee)).unwrap();
97 }
98 write!(out, " \"{}\"", escape_string(&txn.narration)).unwrap();
99
100 for tag in &txn.tags {
102 write!(out, " #{tag}").unwrap();
103 }
104
105 for link in &txn.links {
107 write!(out, " ^{link}").unwrap();
108 }
109
110 out.push('\n');
111
112 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 for posting in &txn.postings {
126 out.push_str(&format_posting(posting, config));
127 out.push('\n');
128 }
129
130 out
131}
132
133fn format_posting(posting: &Posting, config: &FormatConfig) -> String {
135 let mut line = String::new();
136 line.push_str(&config.indent);
137
138 if let Some(flag) = posting.flag {
140 write!(line, "{flag} ").unwrap();
141 }
142
143 line.push_str(&posting.account);
145
146 if let Some(incomplete_amount) = &posting.units {
148 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 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(" "); }
164
165 line.push_str(&amount_with_extras);
166 }
167
168 line
169}
170
171fn 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
180fn 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 if let Some(cost_spec) = cost {
190 out.push(' ');
191 out.push_str(&format_cost_spec(cost_spec));
192 }
193
194 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#[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 if let Some(cost_spec) = cost {
214 out.push(' ');
215 out.push_str(&format_cost_spec(cost_spec));
216 }
217
218 if let Some(price_ann) = price {
220 out.push(' ');
221 out.push_str(&format_price_annotation(price_ann));
222 }
223
224 out
225}
226
227fn format_amount(amount: &Amount) -> String {
229 format!("{} {}", amount.number, amount.currency)
230}
231
232fn format_cost_spec(spec: &CostSpec) -> String {
234 let mut parts = Vec::new();
235
236 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 return format!("{{{{{num} {curr}}}}}");
242 }
243
244 if let Some(date) = spec.date {
246 parts.push(date.to_string());
247 }
248
249 if let Some(label) = &spec.label {
251 parts.push(format!("\"{}\"", escape_string(label)));
252 }
253
254 if spec.merge {
256 parts.push("*".to_string());
257 }
258
259 format!("{{{}}}", parts.join(", "))
260}
261
262fn 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
274fn 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
290fn 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
305fn 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
318fn format_close(close: &Close) -> String {
320 format!("{} close {}\n", close.date, close.account)
321}
322
323fn format_commodity(comm: &Commodity) -> String {
325 format!("{} commodity {}\n", comm.date, comm.currency)
326}
327
328fn format_pad(pad: &Pad) -> String {
330 format!("{} pad {} {}\n", pad.date, pad.account, pad.source_account)
331}
332
333fn 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
343fn 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
353fn format_note(note: &Note) -> String {
355 format!(
356 "{} note {} \"{}\"\n",
357 note.date,
358 note.account,
359 escape_string(¬e.comment)
360 )
361}
362
363fn format_document(doc: &Document) -> String {
365 format!(
366 "{} document {} \"{}\"\n",
367 doc.date,
368 doc.account,
369 escape_string(&doc.path)
370 )
371}
372
373fn format_price(price: &Price) -> String {
375 format!(
376 "{} price {} {}\n",
377 price.date,
378 price.currency,
379 format_amount(&price.amount)
380 )
381}
382
383fn format_custom(custom: &Custom) -> String {
385 format!(
386 "{} custom \"{}\"\n",
387 custom.date,
388 escape_string(&custom.custom_type)
389 )
390}
391
392fn 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}