Skip to main content

nested_text/
dumper.rs

1use crate::value::Value;
2
3/// Options for controlling NestedText output formatting.
4#[derive(Debug, Clone)]
5pub struct DumpOptions {
6    /// Number of spaces per indentation level (default: 4).
7    pub indent: usize,
8    /// Sort dictionary keys alphabetically (default: false).
9    pub sort_keys: bool,
10}
11
12impl Default for DumpOptions {
13    fn default() -> Self {
14        DumpOptions {
15            indent: 4,
16            sort_keys: false,
17        }
18    }
19}
20
21/// Serialize a Value to a NestedText string.
22pub fn dumps(value: &Value, options: &DumpOptions) -> String {
23    let mut lines = Vec::new();
24    render_value(value, 0, options, &mut lines);
25    if lines.is_empty() {
26        String::new()
27    } else {
28        lines.join("\n") + "\n"
29    }
30}
31
32/// Serialize a Value to a writer in NestedText format.
33pub fn dump<W: std::io::Write>(
34    value: &Value,
35    options: &DumpOptions,
36    mut writer: W,
37) -> Result<(), std::io::Error> {
38    let s = dumps(value, options);
39    writer.write_all(s.as_bytes())
40}
41
42fn render_value(value: &Value, depth: usize, options: &DumpOptions, lines: &mut Vec<String>) {
43    match value {
44        Value::String(s) => render_string(s, depth, lines),
45        Value::List(items) => render_list(items, depth, options, lines),
46        Value::Dict(pairs) => render_dict(pairs, depth, options, lines),
47    }
48}
49
50fn render_string(s: &str, depth: usize, lines: &mut Vec<String>) {
51    let indent = " ".repeat(depth);
52    if s.is_empty() {
53        // Empty string is represented as a single ">"
54        lines.push(format!("{}>", indent));
55    } else {
56        for line in s.split('\n') {
57            lines.push(format!("{}> {}", indent, line));
58        }
59    }
60}
61
62fn render_list(items: &[Value], depth: usize, options: &DumpOptions, lines: &mut Vec<String>) {
63    let indent = " ".repeat(depth);
64    if items.is_empty() {
65        lines.push(format!("{}[]", indent));
66        return;
67    }
68
69    for item in items {
70        match item {
71            Value::String(s) if s.is_empty() => {
72                lines.push(format!("{}-", indent));
73            }
74            Value::String(s) if !value_needs_multiline(s) => {
75                lines.push(format!("{}- {}", indent, s));
76            }
77            _ => {
78                lines.push(format!("{}-", indent));
79                render_value(item, depth + options.indent, options, lines);
80            }
81        }
82    }
83}
84
85fn render_dict(
86    pairs: &[(String, Value)],
87    depth: usize,
88    options: &DumpOptions,
89    lines: &mut Vec<String>,
90) {
91    let indent = " ".repeat(depth);
92    if pairs.is_empty() {
93        lines.push(format!("{}{{}}", indent));
94        return;
95    }
96
97    let pairs: Vec<&(String, Value)> = if options.sort_keys {
98        let mut sorted: Vec<&(String, Value)> = pairs.iter().collect();
99        sorted.sort_by(|a, b| a.0.cmp(&b.0));
100        sorted
101    } else {
102        pairs.iter().collect()
103    };
104
105    for (key, value) in pairs {
106        let key_needs_multiline = key_requires_multiline(key);
107
108        if key_needs_multiline {
109            // Multiline key using ": " prefix
110            for key_line in key.split('\n') {
111                if key_line.is_empty() {
112                    lines.push(format!("{}:", indent));
113                } else {
114                    lines.push(format!("{}: {}", indent, key_line));
115                }
116            }
117            // Value must be indented
118            render_value(value, depth + options.indent, options, lines);
119        } else {
120            match value {
121                Value::String(s) if s.is_empty() => {
122                    lines.push(format!("{}{}:", indent, key));
123                }
124                Value::String(s) if !value_needs_multiline(s) => {
125                    lines.push(format!("{}{}: {}", indent, key, s));
126                }
127                _ => {
128                    // Complex or multiline value: key on its own line, value indented
129                    lines.push(format!("{}{}:", indent, key));
130                    render_value(value, depth + options.indent, options, lines);
131                }
132            }
133        }
134    }
135}
136
137/// Check if a key requires multiline ": " syntax.
138fn key_requires_multiline(key: &str) -> bool {
139    if key.is_empty() || key.contains('\n') {
140        return true;
141    }
142    // Keys with leading or trailing whitespace
143    if key != key.trim() {
144        return true;
145    }
146    // Keys containing ": " would split wrong when re-parsed as a dict item
147    if key.contains(": ") || key.ends_with(':') {
148        return true;
149    }
150    // Keys starting with a character that would be interpreted as a tag
151    if key.starts_with("- ")
152        || key == "-"
153        || key.starts_with("> ")
154        || key == ">"
155        || key.starts_with(": ")
156        || key == ":"
157        || key.starts_with('#')
158        || key.starts_with('[')
159        || key.starts_with('{')
160    {
161        return true;
162    }
163    false
164}
165
166/// Check if a string value needs to be rendered as a multiline string (using "> ")
167/// rather than placed inline after "key: " or "- ".
168/// Values that start with tag-like patterns would be misinterpreted on re-parse
169/// if placed inline in certain contexts (e.g., as an indented value under a key).
170fn value_needs_multiline(s: &str) -> bool {
171    if s.contains('\n') {
172        return true;
173    }
174    // Values with leading or trailing whitespace
175    if !s.is_empty() && s != s.trim() {
176        return true;
177    }
178    false
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_dump_simple_string() {
187        let v = Value::String("hello".to_string());
188        assert_eq!(dumps(&v, &DumpOptions::default()), "> hello\n");
189    }
190
191    #[test]
192    fn test_dump_multiline_string() {
193        let v = Value::String("line 1\nline 2".to_string());
194        assert_eq!(dumps(&v, &DumpOptions::default()), "> line 1\n> line 2\n");
195    }
196
197    #[test]
198    fn test_dump_empty_string() {
199        let v = Value::String(String::new());
200        assert_eq!(dumps(&v, &DumpOptions::default()), ">\n");
201    }
202
203    #[test]
204    fn test_dump_simple_list() {
205        let v = Value::List(vec![
206            Value::String("a".to_string()),
207            Value::String("b".to_string()),
208        ]);
209        assert_eq!(dumps(&v, &DumpOptions::default()), "- a\n- b\n");
210    }
211
212    #[test]
213    fn test_dump_empty_list() {
214        let v = Value::List(vec![]);
215        assert_eq!(dumps(&v, &DumpOptions::default()), "[]\n");
216    }
217
218    #[test]
219    fn test_dump_simple_dict() {
220        let v = Value::Dict(vec![
221            ("name".to_string(), Value::String("John".to_string())),
222            ("age".to_string(), Value::String("30".to_string())),
223        ]);
224        assert_eq!(
225            dumps(&v, &DumpOptions::default()),
226            "name: John\nage: 30\n"
227        );
228    }
229
230    #[test]
231    fn test_dump_empty_dict() {
232        let v = Value::Dict(vec![]);
233        assert_eq!(dumps(&v, &DumpOptions::default()), "{}\n");
234    }
235
236    #[test]
237    fn test_dump_nested() {
238        let v = Value::Dict(vec![(
239            "items".to_string(),
240            Value::List(vec![
241                Value::String("a".to_string()),
242                Value::String("b".to_string()),
243            ]),
244        )]);
245        assert_eq!(
246            dumps(&v, &DumpOptions::default()),
247            "items:\n    - a\n    - b\n"
248        );
249    }
250
251    #[test]
252    fn test_dump_multiline_key() {
253        let v = Value::Dict(vec![(
254            "".to_string(),
255            Value::String("value".to_string()),
256        )]);
257        let result = dumps(&v, &DumpOptions::default());
258        assert_eq!(result, ":\n    > value\n");
259    }
260
261    #[test]
262    fn test_dump_sorted_keys() {
263        let v = Value::Dict(vec![
264            ("b".to_string(), Value::String("2".to_string())),
265            ("a".to_string(), Value::String("1".to_string())),
266        ]);
267        let opts = DumpOptions {
268            sort_keys: true,
269            ..Default::default()
270        };
271        assert_eq!(dumps(&v, &opts), "a: 1\nb: 2\n");
272    }
273
274    #[test]
275    fn test_roundtrip_simple() {
276        use crate::parser::{loads, Top};
277        let input = "name: John\nage: 30\n";
278        let v = loads(input, Top::Any).unwrap().unwrap();
279        let output = dumps(&v, &DumpOptions::default());
280        let v2 = loads(&output, Top::Any).unwrap().unwrap();
281        assert_eq!(v, v2);
282    }
283}