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 v4.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;
11use std::fmt::Write as _;
12
13/// Encoder configuration. The spec's only encoder options are `delimiter` and
14/// `indentSize` (§13); the rest are etoon extensions or resource guards.
15#[derive(Clone, Copy)]
16pub struct Config {
17    /// Delimiter between array/tabular values. Must be `,`, `\t`, or `|`.
18    pub delimiter: u8,
19    /// If true, fold single-key object chains into dot-notation keys (safe
20    /// mode). An etoon extension: the spec removed key folding in v4.0, so
21    /// nothing re-nests the output.
22    pub key_folding: bool,
23    /// Max fold depth (segments). None = unlimited. 0 disables folding.
24    pub flatten_depth: Option<usize>,
25    /// If true, emit empty arrays as canonical `[]` / `key: []` instead of the
26    /// legacy `[0]:` / `key[0]:` length-marker form. False emits output the
27    /// spec has forbidden since v3.1.
28    pub empty_array_bare: bool,
29    /// If true, escape control chars U+0000–U+001F (except the named `\n` `\r`
30    /// `\t`) as `\uXXXX` with lowercase hex. False emits output the spec has
31    /// forbidden since v3.1.
32    pub escape_controls: bool,
33    /// Max JSON nesting depth. Input deeper than this is rejected before
34    /// parsing, so neither the sonic-rs DOM parser nor the recursive emitter
35    /// can overflow the stack (both crash the host process near depth ~50k).
36    /// 0 disables the check — use only when the input's depth is already
37    /// bounded by the producer (e.g. orjson output, capped by CPython's
38    /// recursion limit), since the pre-scan is then redundant.
39    pub max_depth: usize,
40    /// Max input size in bytes. 0 disables the check (default). A caller that
41    /// encodes untrusted input can set this to bound peak memory.
42    pub max_input_bytes: usize,
43}
44
45impl Default for Config {
46    fn default() -> Self {
47        Self {
48            delimiter: b',',
49            key_folding: false,
50            flatten_depth: None,
51            empty_array_bare: true,
52            escape_controls: true,
53            max_depth: 1000,
54            max_input_bytes: 0,
55        }
56    }
57}
58
59/// Why [`encode_with`] refused input. `Display` renders the exact message
60/// texts callers match on today; treat wording as part of the API.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum EncodeError {
63    /// Input is larger than `Config::max_input_bytes`.
64    MaxInputBytes { size: usize, limit: usize },
65    /// Input nests deeper than `Config::max_depth`.
66    MaxDepth { depth: usize, limit: usize },
67    /// The input is not valid JSON; carries the parser's message.
68    JsonParse(String),
69    /// `Config::delimiter` is not `,`, `\t`, or `|`.
70    Delimiter,
71}
72
73impl fmt::Display for EncodeError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::MaxInputBytes { size, limit } => {
77                write!(f, "input exceeds max_input_bytes ({size} > {limit})")
78            }
79            Self::MaxDepth { depth, limit } => {
80                write!(f, "input exceeds max_depth ({depth} > {limit})")
81            }
82            Self::JsonParse(msg) => write!(f, "JSON parse error: {msg}"),
83            Self::Delimiter => write!(f, "delimiter must be ',', '\\t', or '|'"),
84        }
85    }
86}
87
88impl std::error::Error for EncodeError {}
89
90// Compile-time pin: downstream code lifting this error into Box<dyn Error>
91// needs the trait, and 0.7.2's String errors provided it implicitly.
92#[allow(dead_code)]
93fn _assert_encode_error_is_std_error() {
94    fn requires<E: std::error::Error>(_: &E) {}
95    requires(&EncodeError::Delimiter);
96}
97
98pub fn encode(json_bytes: &[u8]) -> Result<String, EncodeError> {
99    encode_with(json_bytes, &Config::default())
100}
101
102pub fn encode_with(json_bytes: &[u8], cfg: &Config) -> Result<String, EncodeError> {
103    if cfg.max_input_bytes != 0 && json_bytes.len() > cfg.max_input_bytes {
104        return Err(EncodeError::MaxInputBytes {
105            size: json_bytes.len(),
106            limit: cfg.max_input_bytes,
107        });
108    }
109    // Reject over-deep input up front: the sonic-rs DOM parser and this
110    // emitter both recurse per nesting level and overflow the stack on
111    // deeply-nested input. This O(n) pre-scan caps depth before either runs.
112    // max_depth == 0 skips it (caller guarantees depth is already bounded).
113    if cfg.max_depth != 0 {
114        if let Some(depth) = scan_exceeds_depth(json_bytes, cfg.max_depth) {
115            return Err(EncodeError::MaxDepth {
116                depth,
117                limit: cfg.max_depth,
118            });
119        }
120    }
121    let value: Value =
122        sonic_rs::from_slice(json_bytes).map_err(|e| EncodeError::JsonParse(e.to_string()))?;
123    let mut out = String::with_capacity(json_bytes.len());
124    match cfg.delimiter {
125        b',' => write_root::<b','>(&value, cfg, &mut out),
126        b'\t' => write_root::<b'\t'>(&value, cfg, &mut out),
127        b'|' => write_root::<b'|'>(&value, cfg, &mut out),
128        _ => return Err(EncodeError::Delimiter),
129    }
130    Ok(out)
131}
132
133fn write_root<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
134    match v.get_type() {
135        JsonType::Object => {
136            let m = v.as_object().unwrap();
137            if let Some(fields) = keyed_fields(m) {
138                // Root keyed tabular header is keyless: `[N:]{fields}:` (§9.5).
139                write_keyed_table::<DELIM>(m, &fields, 0, cfg, out);
140            } else if !m.is_empty() {
141                // Folding is attempted at the top-level object; nested object
142                // bodies re-apply it via write_value_after_key (spec §13.4).
143                write_object_body::<DELIM>(m, 0, cfg, cfg.key_folding, out);
144            }
145        }
146        JsonType::Array => {
147            let arr = v.as_array().unwrap();
148            // Root empty array: canonical bare `[]` (no leading colon).
149            if arr.is_empty() && cfg.empty_array_bare {
150                out.push_str("[]");
151            } else {
152                write_array_suffix::<DELIM>(arr, 0, cfg, true, out);
153            }
154        }
155        _ => write_scalar::<DELIM>(v, cfg, out),
156    }
157}
158
159fn write_object_body<const DELIM: u8>(
160    m: &Object,
161    indent: usize,
162    cfg: &Config,
163    allow_fold: bool,
164    out: &mut String,
165) {
166    let mut first = true;
167    for (k, v) in m.iter() {
168        if !first {
169            out.push('\n');
170        }
171        first = false;
172        write_indent(indent, out);
173
174        if allow_fold {
175            if let Some((joined, final_v)) = try_fold(k, v, cfg, m) {
176                write_key(&joined, cfg, out);
177                write_value_after_key::<DELIM>(final_v, indent, cfg, out);
178                continue;
179            }
180        }
181
182        write_key(k, cfg, out);
183        write_value_after_key::<DELIM>(v, indent, cfg, out);
184    }
185}
186
187fn try_fold<'a>(k: &'a str, v: &'a Value, cfg: &Config, m: &Object) -> Option<(String, &'a Value)> {
188    let max_depth = cfg.flatten_depth.unwrap_or(usize::MAX);
189    if max_depth < 2 {
190        return None;
191    }
192
193    // Key segments must match TOON identifier pattern (safe mode).
194    if key_needs_quoting(k) {
195        return None;
196    }
197
198    let mut cur_v = v;
199    let mut path: Vec<&'a str> = vec![k];
200
201    loop {
202        if path.len() >= max_depth {
203            break;
204        }
205        let obj = match cur_v.get_type() {
206            JsonType::Object => cur_v.as_object().unwrap(),
207            _ => break,
208        };
209        if obj.len() != 1 {
210            break;
211        }
212        let (nk, nv) = obj.iter().next().unwrap();
213        if key_needs_quoting(nk) {
214            break;
215        }
216        path.push(nk);
217        cur_v = nv;
218    }
219
220    if path.len() < 2 {
221        return None;
222    }
223
224    let joined: String = path.join(".");
225
226    if m.get(&joined).is_some() {
227        return None;
228    }
229
230    Some((joined, cur_v))
231}
232
233fn write_value_after_key<const DELIM: u8>(
234    v: &Value,
235    key_indent: usize,
236    cfg: &Config,
237    out: &mut String,
238) {
239    match v.get_type() {
240        JsonType::Object => {
241            let child = v.as_object().unwrap();
242            if child.is_empty() {
243                out.push(':');
244            } else if let Some(fields) = keyed_fields(child) {
245                // Keyed tabular form replaces the nested object body; the
246                // header attaches directly to the key just written (§9.5).
247                write_keyed_table::<DELIM>(child, &fields, key_indent, cfg, out);
248            } else {
249                out.push_str(":\n");
250                // Folding restarts only at a branch point (multi-key object).
251                // A single-key body is part of a chain whose fold decision was
252                // already made by the parent's try_fold — re-folding it would
253                // wrongly bypass collision/flattenDepth stops (spec §13.4).
254                let allow = cfg.key_folding && child.len() > 1;
255                write_object_body::<DELIM>(child, key_indent + 1, cfg, allow, out);
256            }
257        }
258        JsonType::Array => {
259            let arr = v.as_array().unwrap();
260            // Object value: canonical `key: []`; legacy `key[0]:` otherwise.
261            if arr.is_empty() && cfg.empty_array_bare {
262                out.push_str(": []");
263            } else {
264                write_array_suffix::<DELIM>(arr, key_indent, cfg, true, out);
265            }
266        }
267        _ => {
268            out.push_str(": ");
269            write_scalar::<DELIM>(v, cfg, out);
270        }
271    }
272}
273
274/// Emit the legacy empty-array header `[0<delim?>]:` at the current position.
275/// Used in list-item context, where v3.1 keeps this form (SPEC §9.2).
276fn write_empty_array_legacy<const DELIM: u8>(out: &mut String) {
277    out.push_str("[0");
278    if DELIM != b',' {
279        out.push(DELIM as char);
280    }
281    out.push_str("]:");
282}
283
284/// Emit a field list `{f1<delim>f2{sub}<delim>…}` for a tabular or keyed header,
285/// recursing into nested field groups (§9.3).
286fn write_field_list<const DELIM: u8>(fields: &[Field], cfg: &Config, out: &mut String) {
287    out.push('{');
288    for (i, f) in fields.iter().enumerate() {
289        if i > 0 {
290            out.push(DELIM as char);
291        }
292        match f {
293            Field::Leaf(k) => write_key(k, cfg, out),
294            Field::Group(k, sub) => {
295                write_key(k, cfg, out);
296                write_field_list::<DELIM>(sub, cfg, out);
297            }
298        }
299    }
300    out.push('}');
301}
302
303/// Emit one row's cells in depth-first pre-order of the field list, so the cell
304/// count equals the header's leaf-field count (§9.3).
305fn write_row_cells<const DELIM: u8>(
306    m: &Object,
307    fields: &[Field],
308    cfg: &Config,
309    first: &mut bool,
310    out: &mut String,
311) {
312    for (idx, f) in fields.iter().enumerate() {
313        match f {
314            Field::Leaf(k) => {
315                if !*first {
316                    out.push(DELIM as char);
317                }
318                *first = false;
319                write_scalar::<DELIM>(column_value(m, idx, k).unwrap(), cfg, out);
320            }
321            Field::Group(k, sub) => {
322                let child = column_value(m, idx, k).unwrap().as_object().unwrap();
323                write_row_cells::<DELIM>(child, sub, cfg, first, out);
324            }
325        }
326    }
327}
328
329/// Emit the keyed tabular body `[N:<delim?>]{fields}:` plus one entry row per
330/// entry (§9.5). The caller has already written the key, if any — at the root
331/// the header is keyless.
332fn write_keyed_table<const DELIM: u8>(
333    m: &Object,
334    fields: &[Field],
335    indent: usize,
336    cfg: &Config,
337    out: &mut String,
338) {
339    out.push('[');
340    let mut len_buf = itoa::Buffer::new();
341    out.push_str(len_buf.format(m.len()));
342    out.push(':');
343    if DELIM != b',' {
344        out.push(DELIM as char);
345    }
346    out.push(']');
347    write_field_list::<DELIM>(fields, cfg, out);
348    out.push(':');
349
350    for (k, v) in m.iter() {
351        out.push('\n');
352        write_indent(indent + 1, out);
353        write_key(k, cfg, out);
354        out.push_str(": ");
355        let mut first = true;
356        write_row_cells::<DELIM>(v.as_object().unwrap(), fields, cfg, &mut first, out);
357    }
358}
359
360fn write_array_suffix<const DELIM: u8>(
361    arr: &Array,
362    indent: usize,
363    cfg: &Config,
364    allow_tabular: bool,
365    out: &mut String,
366) {
367    if arr.is_empty() {
368        write_empty_array_legacy::<DELIM>(out);
369        return;
370    }
371
372    out.push('[');
373    let mut len_buf = itoa::Buffer::new();
374    out.push_str(len_buf.format(arr.len()));
375    if DELIM != b',' {
376        out.push(DELIM as char);
377    }
378    out.push(']');
379
380    if arr.iter().all(is_scalar) {
381        out.push_str(": ");
382        let mut first = true;
383        for v in arr.iter() {
384            if !first {
385                out.push(DELIM as char);
386            }
387            first = false;
388            write_scalar::<DELIM>(v, cfg, out);
389        }
390        return;
391    }
392
393    // A keyless fields-bearing header is valid only at the document root (§6),
394    // so an array sitting in list-item position takes list form even when its
395    // elements would otherwise be tabular-eligible (§9.4).
396    let shape = if allow_tabular {
397        table_shape(arr)
398    } else {
399        None
400    };
401
402    if let Some(Table::Nested(fields)) = &shape {
403        write_field_list::<DELIM>(fields, cfg, out);
404        out.push(':');
405        for item in arr.iter() {
406            out.push('\n');
407            write_indent(indent + 1, out);
408            let mut first = true;
409            write_row_cells::<DELIM>(item.as_object().unwrap(), fields, cfg, &mut first, out);
410        }
411        return;
412    }
413
414    if let Some(Table::Flat(keys, uniform_order)) = shape {
415        // Writes the field list inline rather than through write_field_list:
416        // flat tables are the hot path, and routing them through `Field` would
417        // allocate a tree for a list of names that are all leaves.
418        out.push('{');
419        for (i, k) in keys.iter().enumerate() {
420            if i > 0 {
421                out.push(DELIM as char);
422            }
423            write_key(k, cfg, out);
424        }
425        out.push_str("}:");
426        if uniform_order {
427            for item in arr.iter() {
428                let m = item.as_object().unwrap();
429                out.push('\n');
430                write_indent(indent + 1, out);
431                let mut first = true;
432                for (_, v) in m.iter() {
433                    if !first {
434                        out.push(DELIM as char);
435                    }
436                    first = false;
437                    write_scalar::<DELIM>(v, cfg, out);
438                }
439            }
440        } else {
441            for item in arr.iter() {
442                let m = item.as_object().unwrap();
443                out.push('\n');
444                write_indent(indent + 1, out);
445                let mut first = true;
446                for k in &keys {
447                    if !first {
448                        out.push(DELIM as char);
449                    }
450                    first = false;
451                    write_scalar::<DELIM>(m.get(k).unwrap(), cfg, out);
452                }
453            }
454        }
455        return;
456    }
457
458    out.push(':');
459    for item in arr.iter() {
460        out.push('\n');
461        write_indent(indent + 1, out);
462        out.push('-');
463        write_list_item::<DELIM>(item, indent + 1, cfg, out);
464    }
465}
466
467fn write_list_item<const DELIM: u8>(v: &Value, l: usize, cfg: &Config, out: &mut String) {
468    match v.get_type() {
469        JsonType::Object => {
470            let m = v.as_object().unwrap();
471            if !m.is_empty() {
472                out.push(' ');
473                write_list_item_object::<DELIM>(m, l, cfg, out);
474            }
475        }
476        JsonType::Array => {
477            out.push(' ');
478            // List-item position: no keyless tabular header here (§9.4).
479            write_array_suffix::<DELIM>(v.as_array().unwrap(), l, cfg, false, out);
480        }
481        _ => {
482            out.push(' ');
483            write_scalar::<DELIM>(v, cfg, out);
484        }
485    }
486}
487
488fn write_list_item_object<const DELIM: u8>(m: &Object, l: usize, cfg: &Config, out: &mut String) {
489    let mut first = true;
490    for (k, v) in m.iter() {
491        if !first {
492            out.push('\n');
493            write_indent(l + 1, out);
494        }
495        first = false;
496        write_key(k, cfg, out);
497        write_value_after_key::<DELIM>(v, l + 1, cfg, out);
498    }
499}
500
501// ==================== Depth guard ====================
502
503/// Per-byte structural class for the depth scanner. Most bytes are `Other`
504/// (digits, whitespace, separators, string content) and cost a single table
505/// lookup + skip, so the scan stays close to memory bandwidth.
506const OPEN: u8 = 1;
507const CLOSE: u8 = 2;
508const QUOTE: u8 = 3;
509
510const CLASS: [u8; 256] = {
511    let mut t = [0u8; 256];
512    t[b'{' as usize] = OPEN;
513    t[b'[' as usize] = OPEN;
514    t[b'}' as usize] = CLOSE;
515    t[b']' as usize] = CLOSE;
516    t[b'"' as usize] = QUOTE;
517    t
518};
519
520/// Single linear pass over the raw JSON bytes tracking `{`/`[` nesting depth,
521/// skipping brackets inside string literals. Returns `Some(depth)` with the
522/// first depth that exceeds `max_depth`, or `None` if the input stays within
523/// bounds. No allocation; bails out as soon as the limit is crossed.
524///
525/// String interiors are skipped with `memchr` (SIMD), so quoted content — the
526/// bulk of typical payloads — costs near-zero, and the scalar loop only sees
527/// structural bytes.
528fn scan_exceeds_depth(bytes: &[u8], max_depth: usize) -> Option<usize> {
529    let mut depth: usize = 0;
530    let mut i = 0;
531    let n = bytes.len();
532    while i < n {
533        match CLASS[bytes[i] as usize] {
534            OPEN => {
535                depth += 1;
536                if depth > max_depth {
537                    return Some(depth);
538                }
539                i += 1;
540            }
541            CLOSE => {
542                depth = depth.saturating_sub(1);
543                i += 1;
544            }
545            QUOTE => {
546                // Skip to the closing quote, honoring backslash escapes. Each
547                // memchr2 jumps straight to the next `"` or `\`.
548                i += 1;
549                loop {
550                    // No `"` or `\` left: the string is unterminated, so there
551                    // is no further nesting to find.
552                    let p = memchr::memchr2(b'"', b'\\', &bytes[i..])?;
553                    if bytes[i + p] == b'"' {
554                        i += p + 1;
555                        break;
556                    }
557                    // backslash: skip the escaped byte
558                    i += p + 2;
559                    if i >= n {
560                        return None;
561                    }
562                }
563            }
564            _ => i += 1,
565        }
566    }
567    None
568}
569
570// ==================== Helpers ====================
571
572const INDENTS: [&str; 9] = [
573    "",
574    "  ",
575    "    ",
576    "      ",
577    "        ",
578    "          ",
579    "            ",
580    "              ",
581    "                ",
582];
583
584#[inline]
585fn write_indent(level: usize, out: &mut String) {
586    if level < INDENTS.len() {
587        out.push_str(INDENTS[level]);
588    } else {
589        for _ in 0..(level * 2) {
590            out.push(' ');
591        }
592    }
593}
594
595fn is_scalar(v: &Value) -> bool {
596    !matches!(v.get_type(), JsonType::Object | JsonType::Array)
597}
598
599/// One column of a tabular header (spec §9.3): a bare leaf field, or a nested
600/// field group whose sub-columns are themselves leaves or groups. Nesting depth
601/// is unbounded.
602enum Field<'a> {
603    Leaf(&'a str),
604    Group(&'a str, Vec<Field<'a>>),
605}
606
607/// Tabular shape of an array of objects (§9.3).
608enum Table<'a> {
609    /// Every column is uniform-primitive. The flag records whether all rows
610    /// share the first row's key order, letting cells be emitted by iterating
611    /// values in place instead of looking each key up.
612    Flat(Vec<&'a str>, bool),
613    /// At least one nested-uniform column, emitted as a nested field group.
614    Nested(Vec<Field<'a>>),
615}
616
617/// Value of column `k` in `m`. Rows normally share the header's key order, so
618/// try position `idx` first and fall back to a lookup only when it differs.
619#[inline]
620fn column_value<'a>(m: &'a Object, idx: usize, k: &str) -> Option<&'a Value> {
621    match m.iter().nth(idx) {
622        Some((ik, iv)) if ik == k => Some(iv),
623        _ => m.get(&k),
624    }
625}
626
627/// First-row probe for the §9.3 column rules: an array value or an empty object
628/// disqualifies its column outright, so a mismatch is visible from one object
629/// alone. Callers use it to bail in O(columns) before collecting every row;
630/// `build_fields` re-checks each column itself.
631#[inline]
632fn columns_could_be_uniform(first: &Object) -> bool {
633    !first.is_empty()
634        && first.iter().all(|(_, v)| match v.get_type() {
635            JsonType::Array => false,
636            JsonType::Object => !v.as_object().unwrap().is_empty(),
637            _ => true,
638        })
639}
640
641/// Field tree shared by `objs` (§9.3 column classification), or None when any
642/// column is neither uniform-primitive nor nested-uniform. Also used for the
643/// entry values of a keyed tabular object (§9.5).
644fn build_fields<'a>(objs: &[&'a Object]) -> Option<Vec<Field<'a>>> {
645    let first = *objs.first()?;
646    if first.is_empty() {
647        return None;
648    }
649    for m in &objs[1..] {
650        if m.len() != first.len() {
651            return None;
652        }
653    }
654
655    let mut fields = Vec::with_capacity(first.len());
656    for (idx, (k, v0)) in first.iter().enumerate() {
657        match v0.get_type() {
658            JsonType::Object => {
659                let sub0 = v0.as_object().unwrap();
660                if sub0.is_empty() {
661                    return None;
662                }
663                let mut subs = Vec::with_capacity(objs.len());
664                subs.push(sub0);
665                for m in &objs[1..] {
666                    let sub = column_value(m, idx, k)?.as_object()?;
667                    if sub.is_empty() {
668                        return None;
669                    }
670                    subs.push(sub);
671                }
672                fields.push(Field::Group(k, build_fields(&subs)?));
673            }
674            // Arrays disqualify the column outright; so does any row whose
675            // value at this key is not a primitive.
676            JsonType::Array => return None,
677            _ => {
678                for m in &objs[1..] {
679                    if !is_scalar(column_value(m, idx, k)?) {
680                        return None;
681                    }
682                }
683                fields.push(Field::Leaf(k));
684            }
685        }
686    }
687    Some(fields)
688}
689
690fn table_shape<'a>(arr: &'a Array) -> Option<Table<'a>> {
691    if let Some((keys, uniform_order)) = table_keys(arr) {
692        return Some(Table::Flat(keys, uniform_order));
693    }
694    // Flat detection bails at the first non-primitive value, but a column of
695    // uniform objects still qualifies as a nested field group (§9.3), so retry
696    // with the recursive walk. Probe the first element before walking all of
697    // them: with no object column there is nothing the flat pass missed, and a
698    // disqualifying value is usually already visible here — that keeps the
699    // common mixed-array case (a tabular-looking array with one list column)
700    // from paying for a full scan on its way to list form.
701    let probe = arr.iter().next()?.as_object()?;
702    if !columns_could_be_uniform(probe)
703        || !probe
704            .iter()
705            .any(|(_, v)| matches!(v.get_type(), JsonType::Object))
706    {
707        return None;
708    }
709
710    let mut objs = Vec::with_capacity(arr.len());
711    for v in arr.iter() {
712        objs.push(v.as_object()?);
713    }
714    Some(Table::Nested(build_fields(&objs)?))
715}
716
717/// Field tree when `m` qualifies for keyed tabular form (§9.5): at least two
718/// entries, every entry value a non-empty object, one shared key set, and every
719/// column uniform-primitive or nested-uniform.
720fn keyed_fields<'a>(m: &'a Object) -> Option<Vec<Field<'a>>> {
721    if m.len() < 2 {
722        return None;
723    }
724    // Cheap reject before allocating: most objects fail on their first entry.
725    let probe = m.iter().next()?.1.as_object()?;
726    if !columns_could_be_uniform(probe) {
727        return None;
728    }
729    let mut objs = Vec::with_capacity(m.len());
730    for (_, v) in m.iter() {
731        objs.push(v.as_object()?);
732    }
733    build_fields(&objs)
734}
735
736fn table_keys<'a>(arr: &'a Array) -> Option<(Vec<&'a str>, bool)> {
737    let first_v = arr.iter().next()?;
738    let first = first_v.as_object()?;
739    if first.is_empty() {
740        return None;
741    }
742    if !first.iter().all(|(_, v)| is_scalar(v)) {
743        return None;
744    }
745    let keys: Vec<&'a str> = first.iter().map(|(k, _)| k).collect();
746    let mut uniform_order = true;
747
748    for item in arr.iter().skip(1) {
749        let m = item.as_object()?;
750        if m.len() != keys.len() {
751            return None;
752        }
753        let mut row_iter = m.iter();
754        for k in &keys {
755            let (ik, iv) = row_iter.next()?;
756            if !is_scalar(iv) {
757                return None;
758            }
759            if ik != *k {
760                uniform_order = false;
761            }
762        }
763        if !uniform_order {
764            for k in &keys {
765                match m.get(k) {
766                    Some(v) if is_scalar(v) => {}
767                    _ => return None,
768                }
769            }
770        }
771    }
772    Some((keys, uniform_order))
773}
774
775// ==================== Scalar ====================
776
777#[inline]
778fn write_scalar<const DELIM: u8>(v: &Value, cfg: &Config, out: &mut String) {
779    match v.get_type() {
780        JsonType::Null => out.push_str("null"),
781        JsonType::Boolean => out.push_str(if v.as_bool().unwrap() {
782            "true"
783        } else {
784            "false"
785        }),
786        JsonType::Number => write_number(v, out),
787        JsonType::String => write_string_value::<DELIM>(v.as_str().unwrap(), cfg, out),
788        _ => unreachable!("write_scalar on non-scalar"),
789    }
790}
791
792fn write_number(v: &Value, out: &mut String) {
793    if let Some(i) = v.as_i64() {
794        let mut buf = itoa::Buffer::new();
795        out.push_str(buf.format(i));
796        return;
797    }
798    if let Some(u) = v.as_u64() {
799        let mut buf = itoa::Buffer::new();
800        out.push_str(buf.format(u));
801        return;
802    }
803    // Non-integer or beyond u64: format once via write_float. (The old code
804    // also called v.to_string() first just to probe for a decimal point,
805    // formatting floats twice — dropping that probe is ~3x faster here.)
806    if let Some(f) = v.as_f64() {
807        write_float(f, out);
808    } else {
809        out.push_str("null");
810    }
811}
812
813fn write_float(f: f64, out: &mut String) {
814    if !f.is_finite() {
815        out.push_str("null");
816        return;
817    }
818    if f == 0.0 {
819        out.push('0');
820        return;
821    }
822    // Integer-valued float in i64 range: itoa is faster than float formatting.
823    if f.fract() == 0.0 && f.abs() < 1e16 {
824        let mut buf = itoa::Buffer::new();
825        out.push_str(buf.format(f as i64));
826        return;
827    }
828    // ryu is ~2.4x faster than std Display for non-integer floats, but emits
829    // scientific notation for very small/large magnitudes (1e-6, 1e21) which
830    // violates TOON's expanded-decimal form. Use ryu when its output has no
831    // exponent (the common LLM-payload case); otherwise fall back to std
832    // Display, which always expands.
833    let mut buf = ryu::Buffer::new();
834    let s = buf.format_finite(f);
835    if s.as_bytes().contains(&b'e') {
836        // std Display gives spec-canonical decimals (expanded, no trailing zeros).
837        write!(out, "{}", f).unwrap();
838    } else {
839        out.push_str(s);
840    }
841}
842
843// ==================== String ====================
844
845#[inline]
846fn write_string_value<const DELIM: u8>(s: &str, cfg: &Config, out: &mut String) {
847    if value_needs_quoting::<DELIM>(s, cfg.escape_controls) {
848        write_quoted(s, cfg.escape_controls, out);
849    } else {
850        out.push_str(s);
851    }
852}
853
854fn write_key(k: &str, cfg: &Config, out: &mut String) {
855    if key_needs_quoting(k) {
856        write_quoted(k, cfg.escape_controls, out);
857    } else {
858        out.push_str(k);
859    }
860}
861
862/// Keys must match TOON identifier pattern: `[@$#a-zA-Z_][a-zA-Z0-9_.]*`.
863/// Sigil prefixes `@`, `$`, `#` are allowed for ecosystem compatibility:
864/// - `@` : AWS CloudWatch, Elasticsearch, Serilog, XML→JSON
865/// - `$` : MongoDB, JSON Schema, AWS CloudFormation
866/// - `#` : JSON-LD, Azure Resource Manager
867#[inline]
868fn key_needs_quoting(s: &str) -> bool {
869    if s.is_empty() {
870        return true;
871    }
872    let bytes = s.as_bytes();
873    let start = match bytes[0] {
874        b'@' | b'$' | b'#' => {
875            if bytes.len() < 2 {
876                return true; // bare sigil needs quoting
877            }
878            1
879        }
880        _ => 0,
881    };
882    let first = bytes[start];
883    if !(first.is_ascii_alphabetic() || first == b'_') {
884        return true;
885    }
886    for &b in &bytes[start + 1..] {
887        if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.') {
888            return true;
889        }
890    }
891    false
892}
893
894#[inline]
895fn value_needs_quoting<const DELIM: u8>(s: &str, escape_controls: bool) -> bool {
896    if s.is_empty() {
897        return true;
898    }
899    let bytes = s.as_bytes();
900    match bytes[0] {
901        b'-' | b'#' | b' ' | b'\t' => return true,
902        _ => {}
903    }
904    match bytes[bytes.len() - 1] {
905        b' ' | b'\t' => return true,
906        _ => {}
907    }
908    // DELIM is a compile-time constant, so this match collapses into the
909    // single match arm below when DELIM is in {',', '\t'} (already included),
910    // and stays as a separate branch only for DELIM = '|'.
911    for &b in bytes {
912        match b {
913            // Brackets and braces anywhere in the value, not just at position 0
914            // (spec §7.2) — an unquoted `]` would otherwise close a header the
915            // decoder is scanning.
916            b':' | b'\n' | b'\r' | b'\t' | b'"' | b'\\' | b'[' | b']' | b'{' | b'}' => return true,
917            // Other U+0000–U+001F controls force quoting so write_quoted can
918            // emit `\u00XX` (TOON spec v3.1); only when the option is on.
919            _ if escape_controls && b < 0x20 => return true,
920            _ if b == DELIM => return true,
921            _ => {}
922        }
923    }
924    if matches!(s, "true" | "false" | "null") {
925        return true;
926    }
927    looks_like_number(bytes)
928}
929
930/// Numeric-like per spec §7.2: `^[+-]?[0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?$`.
931/// The leading sign includes `+`, so `"+1"` is quoted and survives round-trip.
932fn looks_like_number(bytes: &[u8]) -> bool {
933    let mut i = 0;
934    if matches!(bytes[0], b'-' | b'+') {
935        i = 1;
936        if i == bytes.len() {
937            return false;
938        }
939    }
940    let mut has_digit = false;
941    while i < bytes.len() && bytes[i].is_ascii_digit() {
942        has_digit = true;
943        i += 1;
944    }
945    if !has_digit {
946        return false;
947    }
948    if i < bytes.len() && bytes[i] == b'.' {
949        i += 1;
950        let mut has_frac = false;
951        while i < bytes.len() && bytes[i].is_ascii_digit() {
952            has_frac = true;
953            i += 1;
954        }
955        if !has_frac {
956            return false;
957        }
958    }
959    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
960        i += 1;
961        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
962            i += 1;
963        }
964        let mut has_exp_digit = false;
965        while i < bytes.len() && bytes[i].is_ascii_digit() {
966            has_exp_digit = true;
967            i += 1;
968        }
969        if !has_exp_digit {
970            return false;
971        }
972    }
973    i == bytes.len()
974}
975
976/// Lowercase hex digit for nibble `n` (0–15).
977#[inline]
978fn hex_lower(n: u8) -> u8 {
979    match n {
980        0..=9 => b'0' + n,
981        _ => b'a' + (n - 10),
982    }
983}
984
985fn write_quoted(s: &str, escape_controls: bool, out: &mut String) {
986    out.push('"');
987    let bytes = s.as_bytes();
988    let mut start = 0;
989    for (i, &b) in bytes.iter().enumerate() {
990        // Named escapes always apply; other U+0000–U+001F controls become
991        // `\u00XX` only when escape_controls is on (TOON spec v3.1).
992        let named = matches!(b, b'\\' | b'"' | b'\n' | b'\r' | b'\t');
993        let other_control = escape_controls && b < 0x20;
994        if named || other_control {
995            if start < i {
996                out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) });
997            }
998            match b {
999                b'\\' => out.push_str("\\\\"),
1000                b'"' => out.push_str("\\\""),
1001                b'\n' => out.push_str("\\n"),
1002                b'\r' => out.push_str("\\r"),
1003                b'\t' => out.push_str("\\t"),
1004                _ => {
1005                    // \u00XX, lowercase hex (b < 0x20 so high nibble is 0 or 1)
1006                    out.push_str("\\u00");
1007                    out.push(hex_lower(b >> 4) as char);
1008                    out.push(hex_lower(b & 0x0f) as char);
1009                }
1010            }
1011            start = i + 1;
1012        }
1013    }
1014    if start < bytes.len() {
1015        out.push_str(unsafe { std::str::from_utf8_unchecked(&bytes[start..]) });
1016    }
1017    out.push('"');
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::{encode, encode_with, Config, EncodeError};
1023
1024    fn enc(json: &str) -> String {
1025        encode(json.as_bytes()).unwrap()
1026    }
1027
1028    fn enc_with(json: &str, cfg: &Config) -> String {
1029        encode_with(json.as_bytes(), cfg).unwrap()
1030    }
1031
1032    // ── Error surface ──
1033    // The CLI prints Display output verbatim and the PyO3 binding forwards it;
1034    // a wording change here is an API change.
1035
1036    #[test]
1037    fn test_encode_error_max_depth_carries_fields_and_exact_message() {
1038        let deep = format!("{}1{}", "{\"a\":".repeat(11), "}".repeat(11));
1039        let cfg = Config {
1040            max_depth: 10,
1041            ..Config::default()
1042        };
1043        let err = encode_with(deep.as_bytes(), &cfg).unwrap_err();
1044        assert_eq!(
1045            err,
1046            EncodeError::MaxDepth {
1047                depth: 11,
1048                limit: 10
1049            }
1050        );
1051        assert_eq!(err.to_string(), "input exceeds max_depth (11 > 10)");
1052    }
1053
1054    #[test]
1055    fn test_encode_error_max_input_bytes_carries_fields_and_exact_message() {
1056        let cfg = Config {
1057            max_input_bytes: 1,
1058            ..Config::default()
1059        };
1060        let err = encode_with(b"{}", &cfg).unwrap_err();
1061        assert_eq!(err, EncodeError::MaxInputBytes { size: 2, limit: 1 });
1062        assert_eq!(err.to_string(), "input exceeds max_input_bytes (2 > 1)");
1063    }
1064
1065    #[test]
1066    fn test_encode_error_json_parse_wraps_parser_message() {
1067        let err = encode(b"{oops").unwrap_err();
1068        assert!(matches!(err, EncodeError::JsonParse(_)));
1069        assert!(
1070            err.to_string().starts_with("JSON parse error: "),
1071            "unexpected: {err}"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_encode_error_delimiter_display_is_stable() {
1077        assert_eq!(
1078            EncodeError::Delimiter.to_string(),
1079            "delimiter must be ',', '\\t', or '|'"
1080        );
1081    }
1082
1083    // ── Number formatting (JSON → Rust path; no Python repr to lean on) ──
1084    // Pin the decimal canonicalization the to_string-probe removal relies on:
1085    // std Display must expand small exponents and drop integer-valued `.0`.
1086
1087    #[test]
1088    fn test_write_number_small_exponent_expands_to_decimal() {
1089        assert_eq!(enc(r#"{"n":1e-6}"#), "n: 0.000001");
1090        assert_eq!(enc(r#"{"n":1e-7}"#), "n: 0.0000001");
1091    }
1092
1093    #[test]
1094    fn test_write_number_integer_valued_float_drops_fraction() {
1095        assert_eq!(enc(r#"{"n":100.0}"#), "n: 100");
1096        assert_eq!(enc(r#"{"n":-2.0}"#), "n: -2");
1097    }
1098
1099    #[test]
1100    fn test_write_number_decimal_full_precision_preserved() {
1101        assert_eq!(enc(r#"{"n":3.14}"#), "n: 3.14");
1102        assert_eq!(enc(r#"{"n":0.3333333333333333}"#), "n: 0.3333333333333333");
1103        assert_eq!(enc(r#"{"n":1234567.89}"#), "n: 1234567.89");
1104    }
1105
1106    #[test]
1107    fn test_write_number_large_magnitude_float_expands_no_exponent() {
1108        assert_eq!(enc(r#"{"n":1e21}"#), "n: 1000000000000000000000");
1109    }
1110
1111    #[test]
1112    fn test_write_number_i64_and_u64_fast_paths() {
1113        assert_eq!(enc(r#"{"n":42}"#), "n: 42");
1114        assert_eq!(
1115            enc(r#"{"n":-9223372036854775808}"#),
1116            "n: -9223372036854775808"
1117        );
1118        assert_eq!(
1119            enc(r#"{"n":18446744073709551615}"#),
1120            "n: 18446744073709551615"
1121        );
1122    }
1123
1124    #[test]
1125    fn test_write_number_beyond_u64_keeps_expanded_form() {
1126        // sonic-rs parses this through f64 (precision lost at parse time), but
1127        // the output must stay an expanded integer string, not an exponent.
1128        assert_eq!(enc(r#"{"n":1e30}"#), "n: 1000000000000000000000000000000");
1129    }
1130
1131    // ── Empty arrays (spec v3.1 canonical, default on) ──
1132
1133    #[test]
1134    fn test_empty_array_root_is_bare_brackets() {
1135        assert_eq!(enc("[]"), "[]");
1136    }
1137
1138    #[test]
1139    fn test_empty_array_object_field_is_key_bracket() {
1140        assert_eq!(enc(r#"{"a":[]}"#), "a: []");
1141        assert_eq!(enc(r#"{"x":{"a":[]}}"#), "x:\n  a: []");
1142    }
1143
1144    #[test]
1145    fn test_empty_array_as_array_element_keeps_legacy_header() {
1146        // SPEC §9.2: a bare array element that is itself empty stays `- [0]:`.
1147        assert_eq!(enc(r#"{"pairs":[[],[]]}"#), "pairs[2]:\n  - [0]:\n  - [0]:");
1148    }
1149
1150    #[test]
1151    fn test_empty_array_legacy_form_when_option_off() {
1152        let cfg = Config {
1153            empty_array_bare: false,
1154            ..Config::default()
1155        };
1156        assert_eq!(enc_with("[]", &cfg), "[0]:");
1157        assert_eq!(enc_with(r#"{"a":[]}"#, &cfg), "a[0]:");
1158    }
1159
1160    // ── Control-character escaping (spec v3.1, default on) ──
1161
1162    // Control chars can only enter via JSON `\uXXXX` escapes — strict JSON
1163    // (sonic-rs) rejects raw control bytes in string literals. This mirrors the
1164    // Python path: orjson escapes them before they reach the Rust encoder.
1165    #[test]
1166    fn test_escape_controls_emits_lowercase_u_escape() {
1167        // Control chars enter only via JSON \uXXXX escapes; strict JSON
1168        // (sonic-rs) rejects raw control bytes. Mirrors the Python path where
1169        // orjson escapes them before they reach the Rust encoder.
1170        assert_eq!(enc("{\"s\":\"a\\u001fb\"}"), "s: \"a\\u001fb\"");
1171        assert_eq!(enc("{\"s\":\"a\\u0000b\"}"), "s: \"a\\u0000b\"");
1172        assert_eq!(enc("{\"s\":\"\\u0004\"}"), "s: \"\\u0004\"");
1173    }
1174
1175    #[test]
1176    fn test_escape_controls_keeps_named_escapes() {
1177        assert_eq!(enc(r#"{"s":"a\nb"}"#), "s: \"a\\nb\"");
1178        assert_eq!(enc(r#"{"s":"a\tb"}"#), "s: \"a\\tb\"");
1179        assert_eq!(enc(r#"{"s":"a\rb"}"#), "s: \"a\\rb\"");
1180    }
1181
1182    #[test]
1183    fn test_escape_controls_off_passes_raw_byte() {
1184        let cfg = Config {
1185            escape_controls: false,
1186            ..Config::default()
1187        };
1188        assert_eq!(enc_with("{\"s\":\"a\\u001fb\"}", &cfg), "s: a\u{1f}b");
1189    }
1190
1191    // ── Key folding at depth (spec §13.4) ──
1192
1193    #[test]
1194    fn test_fold_keys_root_chain() {
1195        let cfg = Config {
1196            key_folding: true,
1197            ..Config::default()
1198        };
1199        assert_eq!(enc_with(r#"{"a":{"b":{"c":1}}}"#, &cfg), "a.b.c: 1");
1200    }
1201
1202    #[test]
1203    fn test_fold_keys_restarts_in_multikey_object_body() {
1204        // The single-key chain nested→b→c sits inside multi-key object `a`, so
1205        // folding restarts there and produces `nested.b.c`.
1206        let cfg = Config {
1207            key_folding: true,
1208            ..Config::default()
1209        };
1210        assert_eq!(
1211            enc_with(r#"{"a":{"x":1,"nested":{"b":{"c":2}}}}"#, &cfg),
1212            "a:\n  x: 1\n  nested.b.c: 2"
1213        );
1214    }
1215
1216    #[test]
1217    fn test_fold_keys_does_not_refold_past_flatten_depth() {
1218        let cfg = Config {
1219            key_folding: true,
1220            flatten_depth: Some(2),
1221            ..Config::default()
1222        };
1223        assert_eq!(
1224            enc_with(r#"{"a":{"b":{"c":{"d":1}}}}"#, &cfg),
1225            "a.b:\n  c:\n    d: 1"
1226        );
1227    }
1228
1229    #[test]
1230    fn test_fold_keys_skips_sibling_collision_at_any_depth() {
1231        // A top-level literal `data.meta.items` blocks folding the whole chain.
1232        let cfg = Config {
1233            key_folding: true,
1234            ..Config::default()
1235        };
1236        assert_eq!(
1237            enc_with(
1238                r#"{"data":{"meta":{"items":[1,2]}},"data.meta.items":"literal"}"#,
1239                &cfg
1240            ),
1241            "data:\n  meta:\n    items[2]: 1,2\ndata.meta.items: literal"
1242        );
1243    }
1244
1245    // ── Keyed tabular form (spec §9.5) ──
1246    // The happy paths live in tests/fixtures/encode/objects-keyed.json; these
1247    // pin the detection boundaries, where the object must stay nested.
1248
1249    #[test]
1250    fn test_keyed_table_needs_two_entries() {
1251        // A single entry stays nested — the header would cost more than it saves.
1252        assert_eq!(enc(r#"{"m":{"a":{"x":1}}}"#), "m:\n  a:\n    x: 1");
1253        assert_eq!(
1254            enc(r#"{"m":{"a":{"x":1},"b":{"x":2}}}"#),
1255            "m[2:]{x}:\n  a: 1\n  b: 2"
1256        );
1257    }
1258
1259    #[test]
1260    fn test_keyed_table_rejects_non_uniform_columns() {
1261        // Mismatched key sets, a non-object entry, and an array column each
1262        // disqualify the whole object (§9.5 detection).
1263        assert_eq!(
1264            enc(r#"{"m":{"a":{"x":1},"b":{"y":2}}}"#),
1265            "m:\n  a:\n    x: 1\n  b:\n    y: 2"
1266        );
1267        assert_eq!(
1268            enc(r#"{"m":{"a":{"x":1},"b":7}}"#),
1269            "m:\n  a:\n    x: 1\n  b: 7"
1270        );
1271        assert_eq!(
1272            enc(r#"{"m":{"a":{"x":[1]},"b":{"x":[2]}}}"#),
1273            "m:\n  a:\n    x[1]: 1\n  b:\n    x[1]: 2"
1274        );
1275    }
1276
1277    #[test]
1278    fn test_keyed_table_not_used_for_array_elements() {
1279        // The `q` column mixes an object with an array, so the array takes list
1280        // form. Its first element is keyed-eligible on its own (two entries,
1281        // one shared key set) but stays nested: array elements are anonymous
1282        // and there is no `- [N:]{…}:` list item (§9.5, §10).
1283        assert_eq!(
1284            enc(r#"{"a":[{"p":{"x":1},"q":{"x":2}},{"p":{"x":3},"q":[9]}]}"#),
1285            "a[2]:\n  - p:\n      x: 1\n    q:\n      x: 2\n  - p:\n      x: 3\n    q[1]: 9"
1286        );
1287    }
1288
1289    #[test]
1290    fn test_keyed_eligible_column_becomes_nested_field_group() {
1291        // In a tabular column, a keyed-eligible object encodes as a nested
1292        // field group rather than a keyed table (§9.5).
1293        assert_eq!(
1294            enc(r#"{"a":[{"p":{"x":1},"q":{"x":2}}]}"#),
1295            "a[1]{p{x},q{x}}:\n  1,2"
1296        );
1297    }
1298
1299    // ── Nested field groups (spec §9.3) ──
1300
1301    #[test]
1302    fn test_nested_field_group_rejects_empty_object_column() {
1303        // A column of empty objects has no subfields to declare, so the array
1304        // falls back to list form.
1305        assert_eq!(enc(r#"{"a":[{"n":{}},{"n":{}}]}"#), "a[2]:\n  - n:\n  - n:");
1306    }
1307
1308    #[test]
1309    fn test_nested_field_group_rejects_mixed_null_and_object_column() {
1310        // `null` is a primitive, so the column is neither uniform-primitive nor
1311        // nested-uniform (§9.3) and the array takes list form.
1312        assert_eq!(
1313            enc(r#"{"a":[{"n":{"x":1}},{"n":null}]}"#),
1314            "a[2]:\n  - n:\n      x: 1\n  - n: null"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_nested_field_group_tolerates_row_key_reordering() {
1320        // Key order may vary per element; cells still follow the header order.
1321        assert_eq!(
1322            enc(r#"{"a":[{"id":1,"g":{"x":1,"y":2}},{"g":{"y":4,"x":3},"id":2}]}"#),
1323            "a[2]{id,g{x,y}}:\n  1,1,2\n  2,3,4"
1324        );
1325    }
1326
1327    // ── String quoting (spec §7.2) ──
1328
1329    #[test]
1330    fn test_quotes_leading_plus_numeric_like_string() {
1331        assert_eq!(enc(r#"{"a":"+1"}"#), r#"a: "+1""#);
1332        assert_eq!(enc(r#"{"a":"+1.5e-3"}"#), r#"a: "+1.5e-3""#);
1333        // A plus that does not form a number stays unquoted.
1334        assert_eq!(enc(r#"{"a":"+x"}"#), "a: +x");
1335    }
1336
1337    #[test]
1338    fn test_quotes_brackets_and_braces_anywhere_in_value() {
1339        assert_eq!(enc(r#"{"a":"x[1]"}"#), r#"a: "x[1]""#);
1340        assert_eq!(enc(r#"{"a":"a}b"}"#), r#"a: "a}b""#);
1341    }
1342
1343    // ── Depth guard (P0: prevents sonic-rs/emitter stack overflow) ──
1344
1345    #[test]
1346    fn test_max_depth_rejects_overdeep_input_before_parse() {
1347        // Depth far below the ~50k crash threshold, but past a small limit:
1348        // must return Err, never overflow the stack.
1349        let deep: Vec<u8> = b"["
1350            .iter()
1351            .cycle()
1352            .take(100)
1353            .chain(b"1".iter())
1354            .chain(b"]".iter().cycle().take(100))
1355            .copied()
1356            .collect();
1357        let cfg = Config {
1358            max_depth: 10,
1359            ..Config::default()
1360        };
1361        let err = encode_with(&deep, &cfg).unwrap_err();
1362        assert!(err.to_string().contains("max_depth"), "got: {err}");
1363    }
1364
1365    #[test]
1366    fn test_max_depth_default_allows_normal_nesting() {
1367        // Ordinary nesting (well under default 1000) encodes fine.
1368        assert_eq!(enc(r#"{"a":{"b":{"c":1}}}"#), "a:\n  b:\n    c: 1");
1369    }
1370
1371    #[test]
1372    fn test_max_depth_ignores_brackets_inside_strings() {
1373        // Brackets in string literals must not count toward depth.
1374        let cfg = Config {
1375            max_depth: 2,
1376            ..Config::default()
1377        };
1378        assert_eq!(
1379            enc_with(r#"{"s":"[[[[[deep]]]]]"}"#, &cfg),
1380            r#"s: "[[[[[deep]]]]]""#
1381        );
1382    }
1383
1384    // ── Input-size guard (P1: OOM protection, off by default) ──
1385
1386    #[test]
1387    fn test_max_input_bytes_rejects_oversize_input() {
1388        let cfg = Config {
1389            max_input_bytes: 4,
1390            ..Config::default()
1391        };
1392        let err = encode_with(br#"{"a":1}"#, &cfg).unwrap_err();
1393        assert!(err.to_string().contains("max_input_bytes"), "got: {err}");
1394    }
1395
1396    #[test]
1397    fn test_max_input_bytes_zero_disables_check() {
1398        // Default (0) imposes no limit.
1399        assert_eq!(enc(r#"{"a":1}"#), "a: 1");
1400    }
1401
1402    // ── ryu float fast path keeps spec-canonical output ──
1403
1404    #[test]
1405    fn test_ryu_regular_floats_match_spec_form() {
1406        // Common-range floats go through ryu (no exponent) and stay expanded.
1407        assert_eq!(enc(r#"{"n":2.5}"#), "n: 2.5");
1408        assert_eq!(enc(r#"{"n":99.99}"#), "n: 99.99");
1409        assert_eq!(enc(r#"{"n":0.1}"#), "n: 0.1");
1410        assert_eq!(enc(r#"{"n":-0.0625}"#), "n: -0.0625");
1411    }
1412}