Skip to main content

rustledger_core/format/
helpers.rs

1//! Shared helper functions for formatting.
2
3use crate::MetaValue;
4
5/// Format a metadata value.
6pub fn format_meta_value(value: &MetaValue, config: &super::FormatConfig) -> String {
7    match value {
8        MetaValue::String(s) => format!("\"{}\"", escape_string(s)),
9        MetaValue::Account(a) => a.to_string(),
10        MetaValue::Currency(c) => c.to_string(),
11        MetaValue::Tag(t) => format!("#{t}"),
12        MetaValue::Link(l) => format!("^{l}"),
13        MetaValue::Date(d) => d.to_string(),
14        // Bare numbers have no currency to look precision up under —
15        // they keep their own scale (same rule as interpolation
16        // targets in posting rendering, #1766).
17        MetaValue::Number(n) => n.to_string(),
18        MetaValue::Amount(a) => super::format_amount_with(a, config),
19        MetaValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
20        MetaValue::None => String::new(),
21        MetaValue::Int(i) => i.to_string(),
22    }
23}
24
25/// Escape a string for CSV output (RFC-4180 style).
26///
27/// Values containing a comma, double quote, or line feed (`\n` — carriage
28/// returns do NOT trigger quoting, matching the prior copies
29/// byte-for-byte) are wrapped in double quotes with inner quotes doubled;
30/// everything else passes through unchanged.
31///
32/// The single implementation behind every CSV surface (`rledger report
33/// --format csv`, BQL CSV output) — these previously carried byte-identical
34/// private copies.
35#[must_use]
36pub fn escape_csv(s: &str) -> String {
37    if s.contains(',') || s.contains('"') || s.contains('\n') {
38        format!("\"{}\"", s.replace('"', "\"\""))
39    } else {
40        s.to_string()
41    }
42}
43
44/// Escape a string as a JSON string body (RFC 8259).
45///
46/// Handles the required escapes (`"`, `\`, and every C0 control character —
47/// `\n`/`\t`/`\r`/`\b`/`\f` by name, the rest as `\uXXXX`), so the result is
48/// always valid between JSON double quotes. Unlike [`escape_string`] (which
49/// targets beancount source and leaves control bytes other than `\n`/`\t`/`\r`
50/// raw), this never emits a bare control character — use it for JSON egress of
51/// user-controlled text (e.g. metadata-derived labels), which may carry an
52/// arbitrary control byte the parser preserved.
53#[must_use]
54pub fn escape_json(s: &str) -> String {
55    use std::fmt::Write;
56    let mut out = String::with_capacity(s.len());
57    for c in s.chars() {
58        match c {
59            '"' => out.push_str("\\\""),
60            '\\' => out.push_str("\\\\"),
61            '\n' => out.push_str("\\n"),
62            '\t' => out.push_str("\\t"),
63            '\r' => out.push_str("\\r"),
64            '\u{08}' => out.push_str("\\b"),
65            '\u{0c}' => out.push_str("\\f"),
66            c if (c as u32) < 0x20 => {
67                let _ = write!(out, "\\u{:04x}", c as u32);
68            }
69            c => out.push(c),
70        }
71    }
72    out
73}
74
75/// Escape a string for output (handle quotes and backslashes).
76pub fn escape_string(s: &str) -> String {
77    let mut out = String::with_capacity(s.len());
78    for c in s.chars() {
79        match c {
80            '"' => out.push_str("\\\""),
81            '\\' => out.push_str("\\\\"),
82            '\n' => out.push_str("\\n"),
83            // The parser decodes `\t`/`\r` into literal tab/CR, so re-escape
84            // them here rather than emitting raw control bytes inside quotes
85            // (hostile to terminals/logs, and not round-trippable).
86            '\t' => out.push_str("\\t"),
87            '\r' => out.push_str("\\r"),
88            _ => out.push(c),
89        }
90    }
91    out
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{escape_json, escape_string};
97
98    #[test]
99    fn escape_json_produces_valid_json_for_control_chars() {
100        // The named escapes.
101        assert_eq!(escape_json("a\"\\b"), "a\\\"\\\\b");
102        assert_eq!(escape_json("x\ny\tz\r"), "x\\ny\\tz\\r");
103        assert_eq!(escape_json("\u{08}\u{0c}"), "\\b\\f");
104        // Other C0 control chars must become \uXXXX (escape_string leaves these
105        // raw, which is invalid JSON) — this is the bug escape_json fixes.
106        assert_eq!(escape_json("A\u{1b}B"), "A\\u001bB");
107        assert_eq!(escape_json("\u{00}"), "\\u0000");
108        // Plain text (incl. non-control unicode) is untouched.
109        assert_eq!(escape_json("投資 123"), "投資 123");
110    }
111
112    #[test]
113    fn escapes_quote_backslash_and_controls() {
114        assert_eq!(escape_string("a\"b"), "a\\\"b");
115        assert_eq!(escape_string("a\\b"), "a\\\\b");
116        assert_eq!(escape_string("a\nb"), "a\\nb");
117        // The parser decodes `\t`/`\r` to literal tab/CR; Display must re-escape
118        // them rather than emit raw control bytes inside the quotes.
119        assert_eq!(escape_string("a\tb"), "a\\tb");
120        assert_eq!(escape_string("a\rb"), "a\\rb");
121    }
122
123    #[test]
124    fn leaves_plain_text_untouched() {
125        assert_eq!(escape_string("plain text 123"), "plain text 123");
126    }
127}