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    /// Max JSON nesting depth. Input deeper than this is rejected before
28    /// parsing, so neither the sonic-rs DOM parser nor the recursive emitter
29    /// can overflow the stack (both crash the host process near depth ~50k).
30    /// 0 disables the check — use only when the input's depth is already
31    /// bounded by the producer (e.g. orjson output, capped by CPython's
32    /// recursion limit), since the pre-scan is then redundant.
33    pub max_depth: usize,
34    /// Max input size in bytes. 0 disables the check (default). A caller that
35    /// encodes untrusted input can set this to bound peak memory.
36    pub max_input_bytes: usize,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        Self {
42            delimiter: b',',
43            key_folding: false,
44            flatten_depth: None,
45            empty_array_bare: true,
46            escape_controls: true,
47            max_depth: 1000,
48            max_input_bytes: 0,
49        }
50    }
51}
52
53pub fn encode(json_bytes: &[u8]) -> Result<String, String> {
54    encode_with(json_bytes, &Config::default())
55}
56
57pub fn encode_with(json_bytes: &[u8], cfg: &Config) -> Result<String, String> {
58    if cfg.max_input_bytes != 0 && json_bytes.len() > cfg.max_input_bytes {
59        return Err(format!(
60            "input exceeds max_input_bytes ({} > {})",
61            json_bytes.len(),
62            cfg.max_input_bytes
63        ));
64    }
65    // Reject over-deep input up front: the sonic-rs DOM parser and this
66    // emitter both recurse per nesting level and overflow the stack on
67    // deeply-nested input. This O(n) pre-scan caps depth before either runs.
68    // max_depth == 0 skips it (caller guarantees depth is already bounded).
69    if cfg.max_depth != 0 {
70        if let Some(depth) = scan_exceeds_depth(json_bytes, cfg.max_depth) {
71            return Err(format!(
72                "input exceeds max_depth ({} > {})",
73                depth, cfg.max_depth
74            ));
75        }
76    }
77    let value: Value =
78        sonic_rs::from_slice(json_bytes).map_err(|e| format!("JSON parse error: {}", e))?;
79    let mut out = String::with_capacity(json_bytes.len());
80    match cfg.delimiter {
81        b',' => write_root::<b','>(&value, cfg, &mut out),
82        b'\t' => write_root::<b'\t'>(&value, cfg, &mut out),
83        b'|' => write_root::<b'|'>(&value, cfg, &mut out),
84        _ => return Err("delimiter must be ',', '\\t', or '|'".to_string()),
85    }
86    Ok(out)
87}
88
89fn write_root<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
90    match v.get_type() {
91        JsonType::Object => {
92            let m = v.as_object().unwrap();
93            if !m.is_empty() {
94                // Folding is attempted at the top-level object; nested object
95                // bodies re-apply it via write_value_after_key (spec §13.4).
96                write_object_body::<DELIM>(m, 0, cfg, cfg.key_folding, out);
97            }
98        }
99        JsonType::Array => {
100            let arr = v.as_array().unwrap();
101            // Root empty array: v3.1 canonical bare `[]` (no leading colon).
102            if arr.is_empty() && cfg.empty_array_bare {
103                out.push_str("[]");
104            } else {
105                write_array_suffix::<DELIM>(arr, 0, cfg, out);
106            }
107        }
108        _ => write_scalar::<DELIM>(v, cfg, out),
109    }
110}
111
112fn write_object_body<const DELIM: u8>(
113    m: &Object,
114    indent: usize,
115    cfg: &Config,
116    allow_fold: bool,
117    out: &mut String,
118) {
119    let mut first = true;
120    for (k, v) in m.iter() {
121        if !first {
122            out.push('\n');
123        }
124        first = false;
125        write_indent(indent, out);
126
127        if allow_fold {
128            if let Some((joined, final_v)) = try_fold(k, v, cfg, m) {
129                write_key(&joined, cfg, out);
130                write_value_after_key::<DELIM>(final_v, indent, cfg, out);
131                continue;
132            }
133        }
134
135        write_key(k, cfg, out);
136        write_value_after_key::<DELIM>(v, indent, cfg, out);
137    }
138}
139
140fn try_fold<'a>(k: &'a str, v: &'a Value, cfg: &Config, m: &Object) -> Option<(String, &'a Value)> {
141    let max_depth = cfg.flatten_depth.unwrap_or(usize::MAX);
142    if max_depth < 2 {
143        return None;
144    }
145
146    // Key segments must match TOON identifier pattern (safe mode).
147    if key_needs_quoting(k) {
148        return None;
149    }
150
151    let mut cur_v = v;
152    let mut path: Vec<&'a str> = vec![k];
153
154    loop {
155        if path.len() >= max_depth {
156            break;
157        }
158        let obj = match cur_v.get_type() {
159            JsonType::Object => cur_v.as_object().unwrap(),
160            _ => break,
161        };
162        if obj.len() != 1 {
163            break;
164        }
165        let (nk, nv) = obj.iter().next().unwrap();
166        if key_needs_quoting(nk) {
167            break;
168        }
169        path.push(nk);
170        cur_v = nv;
171    }
172
173    if path.len() < 2 {
174        return None;
175    }
176
177    let joined: String = path.join(".");
178
179    if m.get(&joined).is_some() {
180        return None;
181    }
182
183    Some((joined, cur_v))
184}
185
186fn write_value_after_key<const DELIM: u8>(
187    v: &Value,
188    key_indent: usize,
189    cfg: &Config,
190    out: &mut String,
191) {
192    match v.get_type() {
193        JsonType::Object => {
194            let child = v.as_object().unwrap();
195            if child.is_empty() {
196                out.push(':');
197            } else {
198                out.push_str(":\n");
199                // Folding restarts only at a branch point (multi-key object).
200                // A single-key body is part of a chain whose fold decision was
201                // already made by the parent's try_fold — re-folding it would
202                // wrongly bypass collision/flattenDepth stops (spec §13.4).
203                let allow = cfg.key_folding && child.len() > 1;
204                write_object_body::<DELIM>(child, key_indent + 1, cfg, allow, out);
205            }
206        }
207        JsonType::Array => {
208            let arr = v.as_array().unwrap();
209            // Object value: v3.1 canonical `key: []`; legacy `key[0]:` otherwise.
210            if arr.is_empty() && cfg.empty_array_bare {
211                out.push_str(": []");
212            } else {
213                write_array_suffix::<DELIM>(arr, key_indent, cfg, out);
214            }
215        }
216        _ => {
217            out.push_str(": ");
218            write_scalar::<DELIM>(v, cfg, out);
219        }
220    }
221}
222
223/// Emit the legacy empty-array header `[0<delim?>]:` at the current position.
224/// Used in list-item context, where v3.1 keeps this form (SPEC §9.2).
225fn write_empty_array_legacy<const DELIM: u8>(out: &mut String) {
226    out.push_str("[0");
227    if DELIM != b',' {
228        out.push(DELIM as char);
229    }
230    out.push_str("]:");
231}
232
233fn write_array_suffix<const DELIM: u8>(arr: &Array, indent: usize, cfg: &Config, out: &mut String) {
234    let _ = cfg;
235    if arr.is_empty() {
236        write_empty_array_legacy::<DELIM>(out);
237        return;
238    }
239
240    out.push('[');
241    let mut len_buf = itoa::Buffer::new();
242    out.push_str(len_buf.format(arr.len()));
243    if DELIM != b',' {
244        out.push(DELIM as char);
245    }
246    out.push(']');
247
248    if arr.iter().all(is_scalar) {
249        out.push_str(": ");
250        let mut first = true;
251        for v in arr.iter() {
252            if !first {
253                out.push(DELIM as char);
254            }
255            first = false;
256            write_scalar::<DELIM>(v, cfg, out);
257        }
258        return;
259    }
260
261    if let Some((keys, uniform_order)) = table_keys(arr) {
262        out.push('{');
263        for (i, k) in keys.iter().enumerate() {
264            if i > 0 {
265                out.push(DELIM as char);
266            }
267            write_key(k, cfg, out);
268        }
269        out.push_str("}:");
270        if uniform_order {
271            for item in arr.iter() {
272                let m = item.as_object().unwrap();
273                out.push('\n');
274                write_indent(indent + 1, out);
275                let mut first = true;
276                for (_, v) in m.iter() {
277                    if !first {
278                        out.push(DELIM as char);
279                    }
280                    first = false;
281                    write_scalar::<DELIM>(v, cfg, out);
282                }
283            }
284        } else {
285            for item in arr.iter() {
286                let m = item.as_object().unwrap();
287                out.push('\n');
288                write_indent(indent + 1, out);
289                let mut first = true;
290                for k in &keys {
291                    if !first {
292                        out.push(DELIM as char);
293                    }
294                    first = false;
295                    write_scalar::<DELIM>(m.get(k).unwrap(), cfg, out);
296                }
297            }
298        }
299        return;
300    }
301
302    out.push(':');
303    for item in arr.iter() {
304        out.push('\n');
305        write_indent(indent + 1, out);
306        out.push('-');
307        write_list_item::<DELIM>(item, indent + 1, cfg, out);
308    }
309}
310
311fn write_list_item<const DELIM: u8>(v: &Value, l: usize, cfg: &Config, out: &mut String) {
312    match v.get_type() {
313        JsonType::Object => {
314            let m = v.as_object().unwrap();
315            if !m.is_empty() {
316                out.push(' ');
317                write_list_item_object::<DELIM>(m, l, cfg, out);
318            }
319        }
320        JsonType::Array => {
321            out.push(' ');
322            write_array_suffix::<DELIM>(v.as_array().unwrap(), l, cfg, out);
323        }
324        _ => {
325            out.push(' ');
326            write_scalar::<DELIM>(v, cfg, out);
327        }
328    }
329}
330
331fn write_list_item_object<const DELIM: u8>(m: &Object, l: usize, cfg: &Config, out: &mut String) {
332    let mut first = true;
333    for (k, v) in m.iter() {
334        if !first {
335            out.push('\n');
336            write_indent(l + 1, out);
337        }
338        first = false;
339        write_key(k, cfg, out);
340        write_value_after_key::<DELIM>(v, l + 1, cfg, out);
341    }
342}
343
344// ==================== Depth guard ====================
345
346/// Per-byte structural class for the depth scanner. Most bytes are `Other`
347/// (digits, whitespace, separators, string content) and cost a single table
348/// lookup + skip, so the scan stays close to memory bandwidth.
349const OPEN: u8 = 1;
350const CLOSE: u8 = 2;
351const QUOTE: u8 = 3;
352
353const CLASS: [u8; 256] = {
354    let mut t = [0u8; 256];
355    t[b'{' as usize] = OPEN;
356    t[b'[' as usize] = OPEN;
357    t[b'}' as usize] = CLOSE;
358    t[b']' as usize] = CLOSE;
359    t[b'"' as usize] = QUOTE;
360    t
361};
362
363/// Single linear pass over the raw JSON bytes tracking `{`/`[` nesting depth,
364/// skipping brackets inside string literals. Returns `Some(depth)` with the
365/// first depth that exceeds `max_depth`, or `None` if the input stays within
366/// bounds. No allocation; bails out as soon as the limit is crossed.
367///
368/// String interiors are skipped with `memchr` (SIMD), so quoted content — the
369/// bulk of typical payloads — costs near-zero, and the scalar loop only sees
370/// structural bytes.
371fn scan_exceeds_depth(bytes: &[u8], max_depth: usize) -> Option<usize> {
372    let mut depth: usize = 0;
373    let mut i = 0;
374    let n = bytes.len();
375    while i < n {
376        match CLASS[bytes[i] as usize] {
377            OPEN => {
378                depth += 1;
379                if depth > max_depth {
380                    return Some(depth);
381                }
382                i += 1;
383            }
384            CLOSE => {
385                depth = depth.saturating_sub(1);
386                i += 1;
387            }
388            QUOTE => {
389                // Skip to the closing quote, honoring backslash escapes. Each
390                // memchr2 jumps straight to the next `"` or `\`.
391                i += 1;
392                loop {
393                    match memchr::memchr2(b'"', b'\\', &bytes[i..]) {
394                        Some(p) => {
395                            if bytes[i + p] == b'"' {
396                                i += p + 1;
397                                break;
398                            }
399                            // backslash: skip the escaped byte
400                            i += p + 2;
401                            if i >= n {
402                                return None;
403                            }
404                        }
405                        None => return None, // unterminated string
406                    }
407                }
408            }
409            _ => i += 1,
410        }
411    }
412    None
413}
414
415// ==================== Helpers ====================
416
417const INDENTS: [&str; 9] = [
418    "",
419    "  ",
420    "    ",
421    "      ",
422    "        ",
423    "          ",
424    "            ",
425    "              ",
426    "                ",
427];
428
429#[inline]
430fn write_indent(level: usize, out: &mut String) {
431    if level < INDENTS.len() {
432        out.push_str(INDENTS[level]);
433    } else {
434        for _ in 0..(level * 2) {
435            out.push(' ');
436        }
437    }
438}
439
440fn is_scalar(v: &Value) -> bool {
441    !matches!(v.get_type(), JsonType::Object | JsonType::Array)
442}
443
444fn table_keys<'a>(arr: &'a Array) -> Option<(Vec<&'a str>, bool)> {
445    let first_v = arr.iter().next()?;
446    let first = first_v.as_object()?;
447    if first.is_empty() {
448        return None;
449    }
450    if !first.iter().all(|(_, v)| is_scalar(v)) {
451        return None;
452    }
453    let keys: Vec<&'a str> = first.iter().map(|(k, _)| k).collect();
454    let mut uniform_order = true;
455
456    for item in arr.iter().skip(1) {
457        let m = item.as_object()?;
458        if m.len() != keys.len() {
459            return None;
460        }
461        let mut row_iter = m.iter();
462        for k in &keys {
463            let (ik, iv) = row_iter.next()?;
464            if !is_scalar(iv) {
465                return None;
466            }
467            if ik != *k {
468                uniform_order = false;
469            }
470        }
471        if !uniform_order {
472            for k in &keys {
473                match m.get(k) {
474                    Some(v) if is_scalar(v) => {}
475                    _ => return None,
476                }
477            }
478        }
479    }
480    Some((keys, uniform_order))
481}
482
483// ==================== Scalar ====================
484
485#[inline]
486fn write_scalar<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
487    match v.get_type() {
488        JsonType::Null => out.push_str("null"),
489        JsonType::Boolean => out.push_str(if v.as_bool().unwrap() {
490            "true"
491        } else {
492            "false"
493        }),
494        JsonType::Number => write_number(v, out),
495        JsonType::String => write_string_value::<DELIM>(v.as_str().unwrap(), cfg, out),
496        _ => unreachable!("write_scalar on non-scalar"),
497    }
498}
499
500fn write_number(v: &Value, out: &mut String) {
501    if let Some(i) = v.as_i64() {
502        let mut buf = itoa::Buffer::new();
503        out.push_str(buf.format(i));
504        return;
505    }
506    if let Some(u) = v.as_u64() {
507        let mut buf = itoa::Buffer::new();
508        out.push_str(buf.format(u));
509        return;
510    }
511    // Non-integer or beyond u64: format once via write_float. (The old code
512    // also called v.to_string() first just to probe for a decimal point,
513    // formatting floats twice — dropping that probe is ~3x faster here.)
514    if let Some(f) = v.as_f64() {
515        write_float(f, out);
516    } else {
517        out.push_str("null");
518    }
519}
520
521fn write_float(f: f64, out: &mut String) {
522    if !f.is_finite() {
523        out.push_str("null");
524        return;
525    }
526    if f == 0.0 {
527        out.push('0');
528        return;
529    }
530    // Integer-valued float in i64 range: itoa is faster than float formatting.
531    if f.fract() == 0.0 && f.abs() < 1e16 {
532        let mut buf = itoa::Buffer::new();
533        out.push_str(buf.format(f as i64));
534        return;
535    }
536    // ryu is ~2.4x faster than std Display for non-integer floats, but emits
537    // scientific notation for very small/large magnitudes (1e-6, 1e21) which
538    // violates TOON's expanded-decimal form. Use ryu when its output has no
539    // exponent (the common LLM-payload case); otherwise fall back to std
540    // Display, which always expands.
541    let mut buf = ryu::Buffer::new();
542    let s = buf.format_finite(f);
543    if s.as_bytes().contains(&b'e') {
544        // std Display gives spec-canonical decimals (expanded, no trailing zeros).
545        write!(out, "{}", f).unwrap();
546    } else {
547        out.push_str(s);
548    }
549}
550
551// ==================== String ====================
552
553#[inline]
554fn write_string_value<const DELIM: u8>(s: &str, cfg: &Config, out: &mut String) {
555    if value_needs_quoting::<DELIM>(s, cfg.escape_controls) {
556        write_quoted(s, cfg.escape_controls, out);
557    } else {
558        out.push_str(s);
559    }
560}
561
562fn write_key(k: &str, cfg: &Config, out: &mut String) {
563    if key_needs_quoting(k) {
564        write_quoted(k, cfg.escape_controls, out);
565    } else {
566        out.push_str(k);
567    }
568}
569
570/// Keys must match TOON identifier pattern: `[@$#a-zA-Z_][a-zA-Z0-9_.]*`.
571/// Sigil prefixes `@`, `$`, `#` are allowed for ecosystem compatibility:
572/// - `@` : AWS CloudWatch, Elasticsearch, Serilog, XML→JSON
573/// - `$` : MongoDB, JSON Schema, AWS CloudFormation
574/// - `#` : JSON-LD, Azure Resource Manager
575#[inline]
576fn key_needs_quoting(s: &str) -> bool {
577    if s.is_empty() {
578        return true;
579    }
580    let bytes = s.as_bytes();
581    let start = match bytes[0] {
582        b'@' | b'$' | b'#' => {
583            if bytes.len() < 2 {
584                return true; // bare sigil needs quoting
585            }
586            1
587        }
588        _ => 0,
589    };
590    let first = bytes[start];
591    if !(first.is_ascii_alphabetic() || first == b'_') {
592        return true;
593    }
594    for &b in &bytes[start + 1..] {
595        if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.') {
596            return true;
597        }
598    }
599    false
600}
601
602#[inline]
603fn value_needs_quoting<const DELIM: u8>(s: &str, escape_controls: bool) -> bool {
604    if s.is_empty() {
605        return true;
606    }
607    let bytes = s.as_bytes();
608    match bytes[0] {
609        b'-' | b'[' | b'{' | b'"' | b'#' | b' ' | b'\t' => return true,
610        _ => {}
611    }
612    match bytes[bytes.len() - 1] {
613        b' ' | b'\t' => return true,
614        _ => {}
615    }
616    // DELIM is a compile-time constant, so this match collapses into the
617    // single match arm below when DELIM is in {',', '\t'} (already included),
618    // and stays as a separate branch only for DELIM = '|'.
619    for &b in bytes {
620        match b {
621            b':' | b'\n' | b'\r' | b'\t' | b'"' | b'\\' => return true,
622            // Other U+0000–U+001F controls force quoting so write_quoted can
623            // emit `\u00XX` (TOON spec v3.1); only when the option is on.
624            _ if escape_controls && b < 0x20 => return true,
625            _ if b == DELIM => return true,
626            _ => {}
627        }
628    }
629    if matches!(s, "true" | "false" | "null") {
630        return true;
631    }
632    looks_like_number(bytes)
633}
634
635fn looks_like_number(bytes: &[u8]) -> bool {
636    let mut i = 0;
637    if bytes[0] == b'-' {
638        i = 1;
639        if i == bytes.len() {
640            return false;
641        }
642    }
643    let mut has_digit = false;
644    while i < bytes.len() && bytes[i].is_ascii_digit() {
645        has_digit = true;
646        i += 1;
647    }
648    if !has_digit {
649        return false;
650    }
651    if i < bytes.len() && bytes[i] == b'.' {
652        i += 1;
653        let mut has_frac = false;
654        while i < bytes.len() && bytes[i].is_ascii_digit() {
655            has_frac = true;
656            i += 1;
657        }
658        if !has_frac {
659            return false;
660        }
661    }
662    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
663        i += 1;
664        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
665            i += 1;
666        }
667        let mut has_exp_digit = false;
668        while i < bytes.len() && bytes[i].is_ascii_digit() {
669            has_exp_digit = true;
670            i += 1;
671        }
672        if !has_exp_digit {
673            return false;
674        }
675    }
676    i == bytes.len()
677}
678
679/// Lowercase hex digit for nibble `n` (0–15).
680#[inline]
681fn hex_lower(n: u8) -> u8 {
682    match n {
683        0..=9 => b'0' + n,
684        _ => b'a' + (n - 10),
685    }
686}
687
688fn write_quoted(s: &str, escape_controls: bool, out: &mut String) {
689    out.push('"');
690    let bytes = s.as_bytes();
691    let mut start = 0;
692    for (i, &b) in bytes.iter().enumerate() {
693        // Named escapes always apply; other U+0000–U+001F controls become
694        // `\u00XX` only when escape_controls is on (TOON spec v3.1).
695        let named = matches!(b, b'\\' | b'"' | b'\n' | b'\r' | b'\t');
696        let other_control = escape_controls && b < 0x20;
697        if named || other_control {
698            if start < i {
699                out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) });
700            }
701            match b {
702                b'\\' => out.push_str("\\\\"),
703                b'"' => out.push_str("\\\""),
704                b'\n' => out.push_str("\\n"),
705                b'\r' => out.push_str("\\r"),
706                b'\t' => out.push_str("\\t"),
707                _ => {
708                    // \u00XX, lowercase hex (b < 0x20 so high nibble is 0 or 1)
709                    out.push_str("\\u00");
710                    out.push(hex_lower(b >> 4) as char);
711                    out.push(hex_lower(b & 0x0f) as char);
712                }
713            }
714            start = i + 1;
715        }
716    }
717    if start < bytes.len() {
718        out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) });
719    }
720    out.push('"');
721}
722
723#[cfg(test)]
724mod tests {
725    use super::{encode, encode_with, Config};
726
727    fn enc(json: &str) -> String {
728        encode(json.as_bytes()).unwrap()
729    }
730
731    fn enc_with(json: &str, cfg: &Config) -> String {
732        encode_with(json.as_bytes(), cfg).unwrap()
733    }
734
735    // ── Number formatting (JSON → Rust path; no Python repr to lean on) ──
736    // Pin the decimal canonicalization the to_string-probe removal relies on:
737    // std Display must expand small exponents and drop integer-valued `.0`.
738
739    #[test]
740    fn test_write_number_small_exponent_expands_to_decimal() {
741        assert_eq!(enc(r#"{"n":1e-6}"#), "n: 0.000001");
742        assert_eq!(enc(r#"{"n":1e-7}"#), "n: 0.0000001");
743    }
744
745    #[test]
746    fn test_write_number_integer_valued_float_drops_fraction() {
747        assert_eq!(enc(r#"{"n":100.0}"#), "n: 100");
748        assert_eq!(enc(r#"{"n":-2.0}"#), "n: -2");
749    }
750
751    #[test]
752    fn test_write_number_decimal_full_precision_preserved() {
753        assert_eq!(enc(r#"{"n":3.14}"#), "n: 3.14");
754        assert_eq!(enc(r#"{"n":0.3333333333333333}"#), "n: 0.3333333333333333");
755        assert_eq!(enc(r#"{"n":1234567.89}"#), "n: 1234567.89");
756    }
757
758    #[test]
759    fn test_write_number_large_magnitude_float_expands_no_exponent() {
760        assert_eq!(enc(r#"{"n":1e21}"#), "n: 1000000000000000000000");
761    }
762
763    #[test]
764    fn test_write_number_i64_and_u64_fast_paths() {
765        assert_eq!(enc(r#"{"n":42}"#), "n: 42");
766        assert_eq!(
767            enc(r#"{"n":-9223372036854775808}"#),
768            "n: -9223372036854775808"
769        );
770        assert_eq!(
771            enc(r#"{"n":18446744073709551615}"#),
772            "n: 18446744073709551615"
773        );
774    }
775
776    #[test]
777    fn test_write_number_beyond_u64_keeps_expanded_form() {
778        // sonic-rs parses this through f64 (precision lost at parse time), but
779        // the output must stay an expanded integer string, not an exponent.
780        assert_eq!(enc(r#"{"n":1e30}"#), "n: 1000000000000000000000000000000");
781    }
782
783    // ── Empty arrays (spec v3.1 canonical, default on) ──
784
785    #[test]
786    fn test_empty_array_root_is_bare_brackets() {
787        assert_eq!(enc("[]"), "[]");
788    }
789
790    #[test]
791    fn test_empty_array_object_field_is_key_bracket() {
792        assert_eq!(enc(r#"{"a":[]}"#), "a: []");
793        assert_eq!(enc(r#"{"x":{"a":[]}}"#), "x:\n  a: []");
794    }
795
796    #[test]
797    fn test_empty_array_as_array_element_keeps_legacy_header() {
798        // SPEC §9.2: a bare array element that is itself empty stays `- [0]:`.
799        assert_eq!(enc(r#"{"pairs":[[],[]]}"#), "pairs[2]:\n  - [0]:\n  - [0]:");
800    }
801
802    #[test]
803    fn test_empty_array_legacy_form_when_option_off() {
804        let cfg = Config {
805            empty_array_bare: false,
806            ..Config::default()
807        };
808        assert_eq!(enc_with("[]", &cfg), "[0]:");
809        assert_eq!(enc_with(r#"{"a":[]}"#, &cfg), "a[0]:");
810    }
811
812    // ── Control-character escaping (spec v3.1, default on) ──
813
814    // Control chars can only enter via JSON `\uXXXX` escapes — strict JSON
815    // (sonic-rs) rejects raw control bytes in string literals. This mirrors the
816    // Python path: orjson escapes them before they reach the Rust encoder.
817    #[test]
818    fn test_escape_controls_emits_lowercase_u_escape() {
819        // Control chars enter only via JSON \uXXXX escapes; strict JSON
820        // (sonic-rs) rejects raw control bytes. Mirrors the Python path where
821        // orjson escapes them before they reach the Rust encoder.
822        assert_eq!(enc("{\"s\":\"a\\u001fb\"}"), "s: \"a\\u001fb\"");
823        assert_eq!(enc("{\"s\":\"a\\u0000b\"}"), "s: \"a\\u0000b\"");
824        assert_eq!(enc("{\"s\":\"\\u0004\"}"), "s: \"\\u0004\"");
825    }
826
827    #[test]
828    fn test_escape_controls_keeps_named_escapes() {
829        assert_eq!(enc(r#"{"s":"a\nb"}"#), "s: \"a\\nb\"");
830        assert_eq!(enc(r#"{"s":"a\tb"}"#), "s: \"a\\tb\"");
831        assert_eq!(enc(r#"{"s":"a\rb"}"#), "s: \"a\\rb\"");
832    }
833
834    #[test]
835    fn test_escape_controls_off_passes_raw_byte() {
836        let cfg = Config {
837            escape_controls: false,
838            ..Config::default()
839        };
840        assert_eq!(enc_with("{\"s\":\"a\\u001fb\"}", &cfg), "s: a\u{1f}b");
841    }
842
843    // ── Key folding at depth (spec §13.4) ──
844
845    #[test]
846    fn test_fold_keys_root_chain() {
847        let cfg = Config {
848            key_folding: true,
849            ..Config::default()
850        };
851        assert_eq!(enc_with(r#"{"a":{"b":{"c":1}}}"#, &cfg), "a.b.c: 1");
852    }
853
854    #[test]
855    fn test_fold_keys_restarts_in_multikey_object_body() {
856        // The single-key chain nested→b→c sits inside multi-key object `a`, so
857        // folding restarts there and produces `nested.b.c`.
858        let cfg = Config {
859            key_folding: true,
860            ..Config::default()
861        };
862        assert_eq!(
863            enc_with(r#"{"a":{"x":1,"nested":{"b":{"c":2}}}}"#, &cfg),
864            "a:\n  x: 1\n  nested.b.c: 2"
865        );
866    }
867
868    #[test]
869    fn test_fold_keys_does_not_refold_past_flatten_depth() {
870        let cfg = Config {
871            key_folding: true,
872            flatten_depth: Some(2),
873            ..Config::default()
874        };
875        assert_eq!(
876            enc_with(r#"{"a":{"b":{"c":{"d":1}}}}"#, &cfg),
877            "a.b:\n  c:\n    d: 1"
878        );
879    }
880
881    #[test]
882    fn test_fold_keys_skips_sibling_collision_at_any_depth() {
883        // A top-level literal `data.meta.items` blocks folding the whole chain.
884        let cfg = Config {
885            key_folding: true,
886            ..Config::default()
887        };
888        assert_eq!(
889            enc_with(
890                r#"{"data":{"meta":{"items":[1,2]}},"data.meta.items":"literal"}"#,
891                &cfg
892            ),
893            "data:\n  meta:\n    items[2]: 1,2\ndata.meta.items: literal"
894        );
895    }
896
897    // ── Depth guard (P0: prevents sonic-rs/emitter stack overflow) ──
898
899    #[test]
900    fn test_max_depth_rejects_overdeep_input_before_parse() {
901        // Depth far below the ~50k crash threshold, but past a small limit:
902        // must return Err, never overflow the stack.
903        let deep: Vec<u8> = b"["
904            .iter()
905            .cycle()
906            .take(100)
907            .chain(b"1".iter())
908            .chain(b"]".iter().cycle().take(100))
909            .copied()
910            .collect();
911        let cfg = Config {
912            max_depth: 10,
913            ..Config::default()
914        };
915        let err = encode_with(&deep, &cfg).unwrap_err();
916        assert!(err.contains("max_depth"), "got: {err}");
917    }
918
919    #[test]
920    fn test_max_depth_default_allows_normal_nesting() {
921        // Ordinary nesting (well under default 1000) encodes fine.
922        assert_eq!(enc(r#"{"a":{"b":{"c":1}}}"#), "a:\n  b:\n    c: 1");
923    }
924
925    #[test]
926    fn test_max_depth_ignores_brackets_inside_strings() {
927        // Brackets in string literals must not count toward depth.
928        let cfg = Config {
929            max_depth: 2,
930            ..Config::default()
931        };
932        assert_eq!(
933            enc_with(r#"{"s":"[[[[[deep]]]]]"}"#, &cfg),
934            r#"s: "[[[[[deep]]]]]""#
935        );
936    }
937
938    // ── Input-size guard (P1: OOM protection, off by default) ──
939
940    #[test]
941    fn test_max_input_bytes_rejects_oversize_input() {
942        let cfg = Config {
943            max_input_bytes: 4,
944            ..Config::default()
945        };
946        let err = encode_with(br#"{"a":1}"#, &cfg).unwrap_err();
947        assert!(err.contains("max_input_bytes"), "got: {err}");
948    }
949
950    #[test]
951    fn test_max_input_bytes_zero_disables_check() {
952        // Default (0) imposes no limit.
953        assert_eq!(enc(r#"{"a":1}"#), "a: 1");
954    }
955
956    // ── ryu float fast path keeps spec-canonical output ──
957
958    #[test]
959    fn test_ryu_regular_floats_match_spec_form() {
960        // Common-range floats go through ryu (no exponent) and stay expanded.
961        assert_eq!(enc(r#"{"n":2.5}"#), "n: 2.5");
962        assert_eq!(enc(r#"{"n":99.99}"#), "n: 99.99");
963        assert_eq!(enc(r#"{"n":0.1}"#), "n: 0.1");
964        assert_eq!(enc(r#"{"n":-0.0625}"#), "n: -0.0625");
965    }
966}