Skip to main content

cli_engine/output/
toon.rs

1use serde_json::{Map, Value};
2
3use crate::Result;
4
5use super::Envelope;
6
7/// Renders an envelope in the TOON migration format.
8pub fn render_toon(envelope: &Envelope) -> Result<String> {
9    envelope.serialization_result()?;
10    let clean = serde_json::to_value(envelope)?;
11    Ok(encode_value(&clean))
12}
13
14fn encode_value(value: &Value) -> String {
15    if is_primitive(value) {
16        return encode_primitive(value);
17    }
18    let mut lines = Vec::new();
19    match value {
20        Value::Array(items) => encode_array("", items, &mut lines, 0),
21        Value::Object(map) => encode_object(map, &mut lines, 0),
22        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
23    }
24    lines.join("\n")
25}
26
27fn encode_object(map: &Map<String, Value>, lines: &mut Vec<String>, depth: usize) {
28    let mut keys = map.keys().collect::<Vec<_>>();
29    keys.sort();
30    for key in keys {
31        encode_key_value_pair(key, &map[key], lines, depth);
32    }
33}
34
35fn encode_key_value_pair(key: &str, value: &Value, lines: &mut Vec<String>, depth: usize) {
36    let encoded_key = encode_key(key);
37    match value {
38        value if is_primitive(value) => push_line(
39            lines,
40            depth,
41            format!("{encoded_key}: {}", encode_primitive(value)),
42        ),
43        Value::Array(items) => encode_array(key, items, lines, depth),
44        Value::Object(map) if map.is_empty() => push_line(lines, depth, format!("{encoded_key}:")),
45        Value::Object(map) => {
46            push_line(lines, depth, format!("{encoded_key}:"));
47            encode_object(map, lines, depth + 1);
48        }
49        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
50    }
51}
52
53fn encode_array(key: &str, items: &[Value], lines: &mut Vec<String>, depth: usize) {
54    if items.is_empty() {
55        push_line(lines, depth, format_header(key, 0, &[]));
56        return;
57    }
58
59    if items.iter().all(is_primitive) {
60        push_line(lines, depth, format_inline_array(key, items));
61        return;
62    }
63
64    if items
65        .iter()
66        .all(|item| matches!(item, Value::Array(values) if values.iter().all(is_primitive)))
67    {
68        push_line(lines, depth, format_header(key, items.len(), &[]));
69        for item in items {
70            if let Value::Array(values) = item {
71                push_line(
72                    lines,
73                    depth + 1,
74                    format!("- {}", format_inline_array("", values)),
75                );
76            }
77        }
78        return;
79    }
80
81    if let Some(header) = detect_tabular_header(items) {
82        push_line(lines, depth, format_header(key, items.len(), &header));
83        for item in items {
84            let Value::Object(map) = item else {
85                continue;
86            };
87            let values = header.iter().map(|key| &map[key]).collect::<Vec<_>>();
88            push_line(lines, depth + 1, join_encoded_values(&values));
89        }
90        return;
91    }
92
93    push_line(lines, depth, format_header(key, items.len(), &[]));
94    for item in items {
95        match item {
96            value if is_primitive(value) => {
97                push_line(lines, depth + 1, format!("- {}", encode_primitive(value)));
98            }
99            Value::Array(values) if values.iter().all(is_primitive) => {
100                push_line(
101                    lines,
102                    depth + 1,
103                    format!("- {}", format_inline_array("", values)),
104                );
105            }
106            Value::Object(map) => encode_object_as_list_item(map, lines, depth + 1),
107            Value::Array(_)
108            | Value::Null
109            | Value::Bool(_)
110            | Value::Number(_)
111            | Value::String(_) => {}
112        }
113    }
114}
115
116fn detect_tabular_header(items: &[Value]) -> Option<Vec<String>> {
117    let Value::Object(first) = items.first()? else {
118        return None;
119    };
120    if first.is_empty() {
121        return None;
122    }
123    let mut header = first.keys().cloned().collect::<Vec<_>>();
124    header.sort();
125    for item in items {
126        let Value::Object(map) = item else {
127            return None;
128        };
129        if map.len() != header.len() {
130            return None;
131        }
132        for key in &header {
133            if !map.get(key).is_some_and(is_primitive) {
134                return None;
135            }
136        }
137    }
138    Some(header)
139}
140
141fn encode_object_as_list_item(map: &Map<String, Value>, lines: &mut Vec<String>, depth: usize) {
142    let mut keys = map.keys().collect::<Vec<_>>();
143    keys.sort();
144    let Some(first_key) = keys.first() else {
145        push_line(lines, depth, "-".to_owned());
146        return;
147    };
148    let first_value = &map[*first_key];
149    match first_value {
150        value if is_primitive(value) => push_line(
151            lines,
152            depth,
153            format!("- {}: {}", encode_key(first_key), encode_primitive(value)),
154        ),
155        Value::Array(values) if values.iter().all(is_primitive) => push_line(
156            lines,
157            depth,
158            format!("- {}", format_inline_array(first_key, values)),
159        ),
160        Value::Object(nested) if nested.is_empty() => {
161            push_line(lines, depth, format!("- {}:", encode_key(first_key)));
162        }
163        Value::Object(nested) => {
164            push_line(lines, depth, format!("- {}:", encode_key(first_key)));
165            encode_object(nested, lines, depth + 2);
166        }
167        Value::Array(values) => {
168            push_line(
169                lines,
170                depth,
171                format!("- {}[{}]:", encode_key(first_key), values.len()),
172            );
173            for item in values {
174                match item {
175                    value if is_primitive(value) => {
176                        push_line(lines, depth + 1, format!("- {}", encode_primitive(value)));
177                    }
178                    Value::Array(nested) if nested.iter().all(is_primitive) => {
179                        push_line(
180                            lines,
181                            depth + 1,
182                            format!("- {}", format_inline_array("", nested)),
183                        );
184                    }
185                    Value::Object(nested) => encode_object_as_list_item(nested, lines, depth + 1),
186                    Value::Array(_)
187                    | Value::Null
188                    | Value::Bool(_)
189                    | Value::Number(_)
190                    | Value::String(_) => {}
191                }
192            }
193        }
194        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
195    }
196
197    for key in keys.into_iter().skip(1) {
198        encode_key_value_pair(key, &map[key], lines, depth + 1);
199    }
200}
201
202fn format_header(key: &str, len: usize, fields: &[String]) -> String {
203    let mut out = String::new();
204    if !key.is_empty() {
205        out.push_str(&encode_key(key));
206    }
207    out.push('[');
208    out.push_str(&len.to_string());
209    out.push(']');
210    if !fields.is_empty() {
211        out.push('{');
212        out.push_str(
213            &fields
214                .iter()
215                .map(|field| encode_key(field))
216                .collect::<Vec<_>>()
217                .join(","),
218        );
219        out.push('}');
220    }
221    out.push(':');
222    out
223}
224
225fn format_inline_array(key: &str, values: &[Value]) -> String {
226    let header = format_header(key, values.len(), &[]);
227    if values.is_empty() {
228        header
229    } else {
230        let refs = values.iter().collect::<Vec<_>>();
231        format!("{header} {}", join_encoded_values(&refs))
232    }
233}
234
235fn join_encoded_values(values: &[&Value]) -> String {
236    values
237        .iter()
238        .map(|value| encode_primitive(value))
239        .collect::<Vec<_>>()
240        .join(",")
241}
242
243fn encode_primitive(value: &Value) -> String {
244    match value {
245        Value::Bool(true) => "true".to_owned(),
246        Value::Bool(false) => "false".to_owned(),
247        Value::Number(number) => number.to_string(),
248        Value::String(value) => encode_string_literal(value),
249        Value::Null | Value::Array(_) | Value::Object(_) => "null".to_owned(),
250    }
251}
252
253fn encode_string_literal(value: &str) -> String {
254    if is_safe_unquoted(value) {
255        value.to_owned()
256    } else {
257        format!("\"{}\"", escape_string(value))
258    }
259}
260
261fn encode_key(key: &str) -> String {
262    if is_valid_unquoted_key(key) {
263        key.to_owned()
264    } else {
265        format!("\"{}\"", escape_string(key))
266    }
267}
268
269fn is_primitive(value: &Value) -> bool {
270    matches!(
271        value,
272        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
273    )
274}
275
276fn is_safe_unquoted(value: &str) -> bool {
277    !value.is_empty()
278        && value.trim() == value
279        && !matches!(value, "true" | "false" | "null")
280        && !is_numeric_like(value)
281        && !value.contains(':')
282        && !value.contains('"')
283        && !value.contains('\\')
284        && !value.contains(',')
285        && !value.contains(['[', ']', '{', '}'])
286        && !value.contains(['\n', '\r', '\t'])
287        && !value.starts_with('-')
288}
289
290fn is_numeric_like(value: &str) -> bool {
291    if value.starts_with('0') && value.len() > 1 && value.chars().all(|ch| ch.is_ascii_digit()) {
292        return true;
293    }
294    value.parse::<f64>().is_ok()
295}
296
297fn is_valid_unquoted_key(key: &str) -> bool {
298    let mut chars = key.chars();
299    let Some(first) = chars.next() else {
300        return false;
301    };
302    (first.is_ascii_alphabetic() || first == '_')
303        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
304}
305
306fn escape_string(value: &str) -> String {
307    value
308        .replace('\\', "\\\\")
309        .replace('"', "\\\"")
310        .replace('\n', "\\n")
311        .replace('\r', "\\r")
312        .replace('\t', "\\t")
313}
314
315fn push_line(lines: &mut Vec<String>, depth: usize, line: String) {
316    lines.push(format!("{}{line}", "  ".repeat(depth)));
317}