Skip to main content

_etoon/
toon.rs

1//! TOON encoder core (sonic-rs backend).
2//!
3//! Input: JSON bytes (from orjson.dumps on Python side).
4//! Output: TOON string, matching TOON spec v3.1.
5//!
6//! Delimiter is monomorphized via const generics (`DELIM: u8`) so the
7//! byte-match inner loops fold away when emitting default-comma output.
8
9use sonic_rs::{Array, JsonContainerTrait, JsonType, JsonValueTrait, Object, Value};
10use std::fmt::Write as _;
11
12/// Encoder configuration matching TOON spec v3.1 options.
13#[derive(Clone, Copy)]
14pub struct Config {
15    /// Delimiter between array/tabular values. Must be `,`, `\t`, or `|`.
16    pub delimiter: u8,
17    /// If true, fold single-key object chains into dot-notation keys (safe mode).
18    pub key_folding: bool,
19    /// Max fold depth (segments). None = unlimited. 0 disables folding.
20    pub flatten_depth: Option<usize>,
21    /// If true (v3.1), emit empty arrays as canonical `[]` / `key: []`
22    /// instead of the legacy `[0]:` / `key[0]:` length-marker form.
23    pub empty_array_bare: bool,
24    /// If true (v3.1), escape control chars U+0000–U+001F (except the named
25    /// `\n` `\r` `\t`) as `\uXXXX` with lowercase hex.
26    pub escape_controls: bool,
27}
28
29impl Default for Config {
30    fn default() -> Self {
31        Self {
32            delimiter: b',',
33            key_folding: false,
34            flatten_depth: None,
35            empty_array_bare: true,
36            escape_controls: true,
37        }
38    }
39}
40
41pub fn encode(json_bytes: &[u8]) -> Result<String, String> {
42    encode_with(json_bytes, &Config::default())
43}
44
45pub fn encode_with(json_bytes: &[u8], cfg: &Config) -> Result<String, String> {
46    let value: Value =
47        sonic_rs::from_slice(json_bytes).map_err(|e| format!("JSON parse error: {}", e))?;
48    let mut out = String::with_capacity(json_bytes.len());
49    match cfg.delimiter {
50        b',' => write_root::<b','>(&value, cfg, &mut out),
51        b'\t' => write_root::<b'\t'>(&value, cfg, &mut out),
52        b'|' => write_root::<b'|'>(&value, cfg, &mut out),
53        _ => return Err("delimiter must be ',', '\\t', or '|'".to_string()),
54    }
55    Ok(out)
56}
57
58fn write_root<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
59    match v.get_type() {
60        JsonType::Object => {
61            let m = v.as_object().unwrap();
62            if !m.is_empty() {
63                // Folding is attempted at the top-level object; nested object
64                // bodies re-apply it via write_value_after_key (spec §13.4).
65                write_object_body::<DELIM>(m, 0, cfg, cfg.key_folding, out);
66            }
67        }
68        JsonType::Array => {
69            let arr = v.as_array().unwrap();
70            // Root empty array: v3.1 canonical bare `[]` (no leading colon).
71            if arr.is_empty() && cfg.empty_array_bare {
72                out.push_str("[]");
73            } else {
74                write_array_suffix::<DELIM>(arr, 0, cfg, out);
75            }
76        }
77        _ => write_scalar::<DELIM>(v, cfg, out),
78    }
79}
80
81fn write_object_body<const DELIM: u8>(
82    m: &Object,
83    indent: usize,
84    cfg: &Config,
85    allow_fold: bool,
86    out: &mut String,
87) {
88    let mut first = true;
89    for (k, v) in m.iter() {
90        if !first {
91            out.push('\n');
92        }
93        first = false;
94        write_indent(indent, out);
95
96        if allow_fold {
97            if let Some((joined, final_v)) = try_fold(k, v, cfg, m) {
98                write_key(&joined, cfg, out);
99                write_value_after_key::<DELIM>(final_v, indent, cfg, out);
100                continue;
101            }
102        }
103
104        write_key(k, cfg, out);
105        write_value_after_key::<DELIM>(v, indent, cfg, out);
106    }
107}
108
109fn try_fold<'a>(k: &'a str, v: &'a Value, cfg: &Config, m: &Object) -> Option<(String, &'a Value)> {
110    let max_depth = cfg.flatten_depth.unwrap_or(usize::MAX);
111    if max_depth < 2 {
112        return None;
113    }
114
115    // Key segments must match TOON identifier pattern (safe mode).
116    if key_needs_quoting(k) {
117        return None;
118    }
119
120    let mut cur_v = v;
121    let mut path: Vec<&'a str> = vec![k];
122
123    loop {
124        if path.len() >= max_depth {
125            break;
126        }
127        let obj = match cur_v.get_type() {
128            JsonType::Object => cur_v.as_object().unwrap(),
129            _ => break,
130        };
131        if obj.len() != 1 {
132            break;
133        }
134        let (nk, nv) = obj.iter().next().unwrap();
135        if key_needs_quoting(nk) {
136            break;
137        }
138        path.push(nk);
139        cur_v = nv;
140    }
141
142    if path.len() < 2 {
143        return None;
144    }
145
146    let joined: String = path.join(".");
147
148    if m.get(&joined).is_some() {
149        return None;
150    }
151
152    Some((joined, cur_v))
153}
154
155fn write_value_after_key<const DELIM: u8>(
156    v: &Value,
157    key_indent: usize,
158    cfg: &Config,
159    out: &mut String,
160) {
161    match v.get_type() {
162        JsonType::Object => {
163            let child = v.as_object().unwrap();
164            if child.is_empty() {
165                out.push(':');
166            } else {
167                out.push_str(":\n");
168                // Folding restarts only at a branch point (multi-key object).
169                // A single-key body is part of a chain whose fold decision was
170                // already made by the parent's try_fold — re-folding it would
171                // wrongly bypass collision/flattenDepth stops (spec §13.4).
172                let allow = cfg.key_folding && child.len() > 1;
173                write_object_body::<DELIM>(child, key_indent + 1, cfg, allow, out);
174            }
175        }
176        JsonType::Array => {
177            let arr = v.as_array().unwrap();
178            // Object value: v3.1 canonical `key: []`; legacy `key[0]:` otherwise.
179            if arr.is_empty() && cfg.empty_array_bare {
180                out.push_str(": []");
181            } else {
182                write_array_suffix::<DELIM>(arr, key_indent, cfg, out);
183            }
184        }
185        _ => {
186            out.push_str(": ");
187            write_scalar::<DELIM>(v, cfg, out);
188        }
189    }
190}
191
192/// Emit the legacy empty-array header `[0<delim?>]:` at the current position.
193/// Used in list-item context, where v3.1 keeps this form (SPEC §9.2).
194fn write_empty_array_legacy<const DELIM: u8>(out: &mut String) {
195    out.push_str("[0");
196    if DELIM != b',' {
197        out.push(DELIM as char);
198    }
199    out.push_str("]:");
200}
201
202fn write_array_suffix<const DELIM: u8>(arr: &Array, indent: usize, cfg: &Config, out: &mut String) {
203    let _ = cfg;
204    if arr.is_empty() {
205        write_empty_array_legacy::<DELIM>(out);
206        return;
207    }
208
209    write!(out, "[{}", arr.len()).unwrap();
210    if DELIM != b',' {
211        out.push(DELIM as char);
212    }
213    out.push(']');
214
215    if arr.iter().all(is_scalar) {
216        out.push_str(": ");
217        let mut first = true;
218        for v in arr.iter() {
219            if !first {
220                out.push(DELIM as char);
221            }
222            first = false;
223            write_scalar::<DELIM>(v, cfg, out);
224        }
225        return;
226    }
227
228    if let Some((keys, uniform_order)) = table_keys(arr) {
229        out.push('{');
230        for (i, k) in keys.iter().enumerate() {
231            if i > 0 {
232                out.push(DELIM as char);
233            }
234            write_key(k, cfg, out);
235        }
236        out.push_str("}:");
237        if uniform_order {
238            for item in arr.iter() {
239                let m = item.as_object().unwrap();
240                out.push('\n');
241                write_indent(indent + 1, out);
242                let mut first = true;
243                for (_, v) in m.iter() {
244                    if !first {
245                        out.push(DELIM as char);
246                    }
247                    first = false;
248                    write_scalar::<DELIM>(v, cfg, out);
249                }
250            }
251        } else {
252            for item in arr.iter() {
253                let m = item.as_object().unwrap();
254                out.push('\n');
255                write_indent(indent + 1, out);
256                let mut first = true;
257                for k in &keys {
258                    if !first {
259                        out.push(DELIM as char);
260                    }
261                    first = false;
262                    write_scalar::<DELIM>(m.get(k).unwrap(), cfg, out);
263                }
264            }
265        }
266        return;
267    }
268
269    out.push(':');
270    for item in arr.iter() {
271        out.push('\n');
272        write_indent(indent + 1, out);
273        out.push('-');
274        write_list_item::<DELIM>(item, indent + 1, cfg, out);
275    }
276}
277
278fn write_list_item<const DELIM: u8>(v: &Value, l: usize, cfg: &Config, out: &mut String) {
279    match v.get_type() {
280        JsonType::Object => {
281            let m = v.as_object().unwrap();
282            if !m.is_empty() {
283                out.push(' ');
284                write_list_item_object::<DELIM>(m, l, cfg, out);
285            }
286        }
287        JsonType::Array => {
288            out.push(' ');
289            write_array_suffix::<DELIM>(v.as_array().unwrap(), l, cfg, out);
290        }
291        _ => {
292            out.push(' ');
293            write_scalar::<DELIM>(v, cfg, out);
294        }
295    }
296}
297
298fn write_list_item_object<const DELIM: u8>(m: &Object, l: usize, cfg: &Config, out: &mut String) {
299    let mut first = true;
300    for (k, v) in m.iter() {
301        if !first {
302            out.push('\n');
303            write_indent(l + 1, out);
304        }
305        first = false;
306        write_key(k, cfg, out);
307        write_value_after_key::<DELIM>(v, l + 1, cfg, out);
308    }
309}
310
311// ==================== Helpers ====================
312
313const INDENTS: [&str; 9] = [
314    "",
315    "  ",
316    "    ",
317    "      ",
318    "        ",
319    "          ",
320    "            ",
321    "              ",
322    "                ",
323];
324
325#[inline]
326fn write_indent(level: usize, out: &mut String) {
327    if level < INDENTS.len() {
328        out.push_str(INDENTS[level]);
329    } else {
330        for _ in 0..(level * 2) {
331            out.push(' ');
332        }
333    }
334}
335
336fn is_scalar(v: &Value) -> bool {
337    !matches!(v.get_type(), JsonType::Object | JsonType::Array)
338}
339
340fn table_keys<'a>(arr: &'a Array) -> Option<(Vec<&'a str>, bool)> {
341    let first_v = arr.iter().next()?;
342    let first = first_v.as_object()?;
343    if first.is_empty() {
344        return None;
345    }
346    if !first.iter().all(|(_, v)| is_scalar(v)) {
347        return None;
348    }
349    let keys: Vec<&'a str> = first.iter().map(|(k, _)| k).collect();
350    let mut uniform_order = true;
351
352    for item in arr.iter().skip(1) {
353        let m = item.as_object()?;
354        if m.len() != keys.len() {
355            return None;
356        }
357        let mut row_iter = m.iter();
358        for k in &keys {
359            let (ik, iv) = row_iter.next()?;
360            if !is_scalar(iv) {
361                return None;
362            }
363            if ik != *k {
364                uniform_order = false;
365            }
366        }
367        if !uniform_order {
368            for k in &keys {
369                match m.get(k) {
370                    Some(v) if is_scalar(v) => {}
371                    _ => return None,
372                }
373            }
374        }
375    }
376    Some((keys, uniform_order))
377}
378
379// ==================== Scalar ====================
380
381#[inline]
382fn write_scalar<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
383    match v.get_type() {
384        JsonType::Null => out.push_str("null"),
385        JsonType::Boolean => out.push_str(if v.as_bool().unwrap() {
386            "true"
387        } else {
388            "false"
389        }),
390        JsonType::Number => write_number(v, out),
391        JsonType::String => write_string_value::<DELIM>(v.as_str().unwrap(), cfg, out),
392        _ => unreachable!("write_scalar on non-scalar"),
393    }
394}
395
396fn write_number(v: &Value, out: &mut String) {
397    if let Some(i) = v.as_i64() {
398        let mut buf = itoa::Buffer::new();
399        out.push_str(buf.format(i));
400        return;
401    }
402    if let Some(u) = v.as_u64() {
403        let mut buf = itoa::Buffer::new();
404        out.push_str(buf.format(u));
405        return;
406    }
407    // Non-integer or beyond u64: format once via write_float. (The old code
408    // also called v.to_string() first just to probe for a decimal point,
409    // formatting floats twice — dropping that probe is ~3x faster here.)
410    if let Some(f) = v.as_f64() {
411        write_float(f, out);
412    } else {
413        out.push_str("null");
414    }
415}
416
417fn write_float(f: f64, out: &mut String) {
418    if !f.is_finite() {
419        out.push_str("null");
420        return;
421    }
422    if f == 0.0 {
423        out.push('0');
424        return;
425    }
426    // Integer-valued float in i64 range: itoa is faster than float formatting.
427    if f.fract() == 0.0 && f.abs() < 1e16 {
428        let mut buf = itoa::Buffer::new();
429        out.push_str(buf.format(f as i64));
430        return;
431    }
432    // std Display gives spec-canonical decimals (expanded, no trailing zeros).
433    write!(out, "{}", f).unwrap();
434}
435
436// ==================== String ====================
437
438#[inline]
439fn write_string_value<const DELIM: u8>(s: &str, cfg: &Config, out: &mut String) {
440    if value_needs_quoting::<DELIM>(s, cfg.escape_controls) {
441        write_quoted(s, cfg.escape_controls, out);
442    } else {
443        out.push_str(s);
444    }
445}
446
447fn write_key(k: &str, cfg: &Config, out: &mut String) {
448    if key_needs_quoting(k) {
449        write_quoted(k, cfg.escape_controls, out);
450    } else {
451        out.push_str(k);
452    }
453}
454
455/// Keys must match TOON identifier pattern: `[@$#a-zA-Z_][a-zA-Z0-9_.]*`.
456/// Sigil prefixes `@`, `$`, `#` are allowed for ecosystem compatibility:
457/// - `@` : AWS CloudWatch, Elasticsearch, Serilog, XML→JSON
458/// - `$` : MongoDB, JSON Schema, AWS CloudFormation
459/// - `#` : JSON-LD, Azure Resource Manager
460#[inline]
461fn key_needs_quoting(s: &str) -> bool {
462    if s.is_empty() {
463        return true;
464    }
465    let bytes = s.as_bytes();
466    let start = match bytes[0] {
467        b'@' | b'$' | b'#' => {
468            if bytes.len() < 2 {
469                return true; // bare sigil needs quoting
470            }
471            1
472        }
473        _ => 0,
474    };
475    let first = bytes[start];
476    if !(first.is_ascii_alphabetic() || first == b'_') {
477        return true;
478    }
479    for &b in &bytes[start + 1..] {
480        if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.') {
481            return true;
482        }
483    }
484    false
485}
486
487#[inline]
488fn value_needs_quoting<const DELIM: u8>(s: &str, escape_controls: bool) -> bool {
489    if s.is_empty() {
490        return true;
491    }
492    let bytes = s.as_bytes();
493    match bytes[0] {
494        b'-' | b'[' | b'{' | b'"' | b'#' | b' ' | b'\t' => return true,
495        _ => {}
496    }
497    match bytes[bytes.len() - 1] {
498        b' ' | b'\t' => return true,
499        _ => {}
500    }
501    // DELIM is a compile-time constant, so this match collapses into the
502    // single match arm below when DELIM is in {',', '\t'} (already included),
503    // and stays as a separate branch only for DELIM = '|'.
504    for &b in bytes {
505        match b {
506            b':' | b'\n' | b'\r' | b'\t' | b'"' | b'\\' => return true,
507            // Other U+0000–U+001F controls force quoting so write_quoted can
508            // emit `\u00XX` (TOON spec v3.1); only when the option is on.
509            _ if escape_controls && b < 0x20 => return true,
510            _ if b == DELIM => return true,
511            _ => {}
512        }
513    }
514    if matches!(s, "true" | "false" | "null") {
515        return true;
516    }
517    looks_like_number(bytes)
518}
519
520fn looks_like_number(bytes: &[u8]) -> bool {
521    let mut i = 0;
522    if bytes[0] == b'-' {
523        i = 1;
524        if i == bytes.len() {
525            return false;
526        }
527    }
528    let mut has_digit = false;
529    while i < bytes.len() && bytes[i].is_ascii_digit() {
530        has_digit = true;
531        i += 1;
532    }
533    if !has_digit {
534        return false;
535    }
536    if i < bytes.len() && bytes[i] == b'.' {
537        i += 1;
538        let mut has_frac = false;
539        while i < bytes.len() && bytes[i].is_ascii_digit() {
540            has_frac = true;
541            i += 1;
542        }
543        if !has_frac {
544            return false;
545        }
546    }
547    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
548        i += 1;
549        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
550            i += 1;
551        }
552        let mut has_exp_digit = false;
553        while i < bytes.len() && bytes[i].is_ascii_digit() {
554            has_exp_digit = true;
555            i += 1;
556        }
557        if !has_exp_digit {
558            return false;
559        }
560    }
561    i == bytes.len()
562}
563
564/// Lowercase hex digit for nibble `n` (0–15).
565#[inline]
566fn hex_lower(n: u8) -> u8 {
567    match n {
568        0..=9 => b'0' + n,
569        _ => b'a' + (n - 10),
570    }
571}
572
573fn write_quoted(s: &str, escape_controls: bool, out: &mut String) {
574    out.push('"');
575    let bytes = s.as_bytes();
576    let mut start = 0;
577    for (i, &b) in bytes.iter().enumerate() {
578        // Named escapes always apply; other U+0000–U+001F controls become
579        // `\u00XX` only when escape_controls is on (TOON spec v3.1).
580        let named = matches!(b, b'\\' | b'"' | b'\n' | b'\r' | b'\t');
581        let other_control = escape_controls && b < 0x20;
582        if named || other_control {
583            if start < i {
584                out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) });
585            }
586            match b {
587                b'\\' => out.push_str("\\\\"),
588                b'"' => out.push_str("\\\""),
589                b'\n' => out.push_str("\\n"),
590                b'\r' => out.push_str("\\r"),
591                b'\t' => out.push_str("\\t"),
592                _ => {
593                    // \u00XX, lowercase hex (b < 0x20 so high nibble is 0 or 1)
594                    out.push_str("\\u00");
595                    out.push(hex_lower(b >> 4) as char);
596                    out.push(hex_lower(b & 0x0f) as char);
597                }
598            }
599            start = i + 1;
600        }
601    }
602    if start < bytes.len() {
603        out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) });
604    }
605    out.push('"');
606}
607
608#[cfg(test)]
609mod tests {
610    use super::{encode, encode_with, Config};
611
612    fn enc(json: &str) -> String {
613        encode(json.as_bytes()).unwrap()
614    }
615
616    fn enc_with(json: &str, cfg: &Config) -> String {
617        encode_with(json.as_bytes(), cfg).unwrap()
618    }
619
620    // ── Number formatting (JSON → Rust path; no Python repr to lean on) ──
621    // Pin the decimal canonicalization the to_string-probe removal relies on:
622    // std Display must expand small exponents and drop integer-valued `.0`.
623
624    #[test]
625    fn test_write_number_small_exponent_expands_to_decimal() {
626        assert_eq!(enc(r#"{"n":1e-6}"#), "n: 0.000001");
627        assert_eq!(enc(r#"{"n":1e-7}"#), "n: 0.0000001");
628    }
629
630    #[test]
631    fn test_write_number_integer_valued_float_drops_fraction() {
632        assert_eq!(enc(r#"{"n":100.0}"#), "n: 100");
633        assert_eq!(enc(r#"{"n":-2.0}"#), "n: -2");
634    }
635
636    #[test]
637    fn test_write_number_decimal_full_precision_preserved() {
638        assert_eq!(enc(r#"{"n":3.14}"#), "n: 3.14");
639        assert_eq!(enc(r#"{"n":0.3333333333333333}"#), "n: 0.3333333333333333");
640        assert_eq!(enc(r#"{"n":1234567.89}"#), "n: 1234567.89");
641    }
642
643    #[test]
644    fn test_write_number_large_magnitude_float_expands_no_exponent() {
645        assert_eq!(enc(r#"{"n":1e21}"#), "n: 1000000000000000000000");
646    }
647
648    #[test]
649    fn test_write_number_i64_and_u64_fast_paths() {
650        assert_eq!(enc(r#"{"n":42}"#), "n: 42");
651        assert_eq!(
652            enc(r#"{"n":-9223372036854775808}"#),
653            "n: -9223372036854775808"
654        );
655        assert_eq!(
656            enc(r#"{"n":18446744073709551615}"#),
657            "n: 18446744073709551615"
658        );
659    }
660
661    #[test]
662    fn test_write_number_beyond_u64_keeps_expanded_form() {
663        // sonic-rs parses this through f64 (precision lost at parse time), but
664        // the output must stay an expanded integer string, not an exponent.
665        assert_eq!(enc(r#"{"n":1e30}"#), "n: 1000000000000000000000000000000");
666    }
667
668    // ── Empty arrays (spec v3.1 canonical, default on) ──
669
670    #[test]
671    fn test_empty_array_root_is_bare_brackets() {
672        assert_eq!(enc("[]"), "[]");
673    }
674
675    #[test]
676    fn test_empty_array_object_field_is_key_bracket() {
677        assert_eq!(enc(r#"{"a":[]}"#), "a: []");
678        assert_eq!(enc(r#"{"x":{"a":[]}}"#), "x:\n  a: []");
679    }
680
681    #[test]
682    fn test_empty_array_as_array_element_keeps_legacy_header() {
683        // SPEC §9.2: a bare array element that is itself empty stays `- [0]:`.
684        assert_eq!(enc(r#"{"pairs":[[],[]]}"#), "pairs[2]:\n  - [0]:\n  - [0]:");
685    }
686
687    #[test]
688    fn test_empty_array_legacy_form_when_option_off() {
689        let cfg = Config {
690            empty_array_bare: false,
691            ..Config::default()
692        };
693        assert_eq!(enc_with("[]", &cfg), "[0]:");
694        assert_eq!(enc_with(r#"{"a":[]}"#, &cfg), "a[0]:");
695    }
696
697    // ── Control-character escaping (spec v3.1, default on) ──
698
699    // Control chars can only enter via JSON `\uXXXX` escapes — strict JSON
700    // (sonic-rs) rejects raw control bytes in string literals. This mirrors the
701    // Python path: orjson escapes them before they reach the Rust encoder.
702    #[test]
703    fn test_escape_controls_emits_lowercase_u_escape() {
704        // Control chars enter only via JSON \uXXXX escapes; strict JSON
705        // (sonic-rs) rejects raw control bytes. Mirrors the Python path where
706        // orjson escapes them before they reach the Rust encoder.
707        assert_eq!(enc("{\"s\":\"a\\u001fb\"}"), "s: \"a\\u001fb\"");
708        assert_eq!(enc("{\"s\":\"a\\u0000b\"}"), "s: \"a\\u0000b\"");
709        assert_eq!(enc("{\"s\":\"\\u0004\"}"), "s: \"\\u0004\"");
710    }
711
712    #[test]
713    fn test_escape_controls_keeps_named_escapes() {
714        assert_eq!(enc(r#"{"s":"a\nb"}"#), "s: \"a\\nb\"");
715        assert_eq!(enc(r#"{"s":"a\tb"}"#), "s: \"a\\tb\"");
716        assert_eq!(enc(r#"{"s":"a\rb"}"#), "s: \"a\\rb\"");
717    }
718
719    #[test]
720    fn test_escape_controls_off_passes_raw_byte() {
721        let cfg = Config {
722            escape_controls: false,
723            ..Config::default()
724        };
725        assert_eq!(enc_with("{\"s\":\"a\\u001fb\"}", &cfg), "s: a\u{1f}b");
726    }
727
728    // ── Key folding at depth (spec §13.4) ──
729
730    #[test]
731    fn test_fold_keys_root_chain() {
732        let cfg = Config {
733            key_folding: true,
734            ..Config::default()
735        };
736        assert_eq!(enc_with(r#"{"a":{"b":{"c":1}}}"#, &cfg), "a.b.c: 1");
737    }
738
739    #[test]
740    fn test_fold_keys_restarts_in_multikey_object_body() {
741        // The single-key chain nested→b→c sits inside multi-key object `a`, so
742        // folding restarts there and produces `nested.b.c`.
743        let cfg = Config {
744            key_folding: true,
745            ..Config::default()
746        };
747        assert_eq!(
748            enc_with(r#"{"a":{"x":1,"nested":{"b":{"c":2}}}}"#, &cfg),
749            "a:\n  x: 1\n  nested.b.c: 2"
750        );
751    }
752
753    #[test]
754    fn test_fold_keys_does_not_refold_past_flatten_depth() {
755        let cfg = Config {
756            key_folding: true,
757            flatten_depth: Some(2),
758            ..Config::default()
759        };
760        assert_eq!(
761            enc_with(r#"{"a":{"b":{"c":{"d":1}}}}"#, &cfg),
762            "a.b:\n  c:\n    d: 1"
763        );
764    }
765
766    #[test]
767    fn test_fold_keys_skips_sibling_collision_at_any_depth() {
768        // A top-level literal `data.meta.items` blocks folding the whole chain.
769        let cfg = Config {
770            key_folding: true,
771            ..Config::default()
772        };
773        assert_eq!(
774            enc_with(
775                r#"{"data":{"meta":{"items":[1,2]}},"data.meta.items":"literal"}"#,
776                &cfg
777            ),
778            "data:\n  meta:\n    items[2]: 1,2\ndata.meta.items: literal"
779        );
780    }
781}