Skip to main content

omnist/
document.rs

1//! The Document model — a canonical tree of ordered, labeled edges.
2//!
3//! Ported from `~/dev/omnist/omnist/document.py` (see issue #4). A Document
4//! **node** is either a **leaf** holding a [`Scalar`], or an **internal
5//! node** holding an *ordered list of edges*, each a `(label, child)` pair.
6//! **Labels may repeat** — "many members" is the label `member` appearing
7//! several times, not a field pointing to an array.
8//!
9//! ## Architecture (per issue #1, "architecture freedom")
10//!
11//! This port uses an arena: nodes live in a `Vec<Entry>` inside [`Doc`],
12//! referenced by [`NodeId`] (an index newtype), not `Rc<RefCell<_>>`. Two
13//! consequences that don't mirror Python/TypeScript 1:1:
14//!
15//! - **No cycle detection.** Python/TS guard against cycles because a plain
16//!   `dict`/object can be made self-referential through shared mutable
17//!   references. [`Value`] (this port's "plain value" input type, analogous
18//!   to a parsed JSON value) is a plain owned tree — building a
19//!   self-referential `Value` without `unsafe` or `Rc<RefCell<_>>` isn't
20//!   possible, so the whole bug class is closed by the type system rather
21//!   than checked at runtime (see the workflow playbook's "what NOT to
22//!   carry over unexamined").
23//! - **Integer-digit security cap.** Python's `_check_int_digits` defends
24//!   against `str()`-converting an arbitrary-precision `int` with
25//!   thousands of digits (a superlinear operation). Since issue #104,
26//!   `Scalar::Int` is backed by arbitrary-precision `num_bigint::BigInt`,
27//!   and format decoders (JSON, YAML, TOML) enforce the security cap
28//!   (`crate::limits::MAX_INT_DIGITS`, 4300 digits) during lexing/parsing
29//!   before BigInt construction.
30//!
31//! Observable behavior for everything else (construction, depth guard,
32//! edge ordering, mutation semantics) matches the Python spec.
33
34use indexmap::IndexMap;
35use std::fmt;
36
37use crate::error::DocumentError;
38
39/// Maximum nesting depth for a Document node (matches Python's `_MAX_DEPTH`).
40pub const MAX_DEPTH: usize = 200;
41
42/// Maximum total node count for a single Document (matches the reference
43/// default in omnist-spec docs/02-document-model.md Sec2.4: a depth limit
44/// alone doesn't bound a shallow-but-enormous document, e.g. a million
45/// sibling edges at depth 1). Enforced once, in `push`, the single arena
46/// choke point every construction path (`build_node`, `push_raw`) funnels
47/// through -- see omnist-rs#78: previously the only node-count guard in
48/// this crate was scoped narrowly to `formats::yaml`'s anchor/alias
49/// amplification defense, leaving every other construction path unbounded.
50pub const MAX_NODES: usize = 1_000_000;
51
52/// An index into a [`Doc`]'s arena. Opaque outside this module's crate.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub struct NodeId(usize);
55
56/// A leaf value.
57///
58/// `Int` is arbitrary-precision (`BigInt`), not a fixed-width integer --
59/// omnist-spec §2.2 defines `integer` as arbitrary-precision, bounded only
60/// by the shared digit-count cap (`MAX_INT_DIGITS`), the same way Python's
61/// native `int` and Go's `*big.Int` already are (issue #104; previously
62/// `i64`, which silently rejected any literal past ~19 digits -- a
63/// spec-conformance bug, not a permitted narrower-limit variation, since no
64/// digit-count override was in play).
65///
66/// `Date`/`Time`/`Datetime` (issue #105) each hold their already
67/// shape-validated, canonical ISO spelling -- no `chrono`/`time` crate
68/// dependency, since the algebra never does temporal arithmetic, only
69/// equality and canonical rendering (the same reasoning issue #104 applied
70/// to `Int`). Constructing one always goes through
71/// `crate::schema::is_iso_date`/`is_iso_time`/`is_iso_datetime` (shape
72/// validation) and, for `Time`/`Datetime`,
73/// `crate::schema::canonicalize_iso_time`/`canonicalize_iso_datetime`
74/// (canonicalization) -- there is no code path that constructs one of
75/// these holding un-validated or non-canonical text. This closed the
76/// architecture gap issue #16 originally left open (previously cited as a
77/// reason `Scalar` had no temporal variant at all); see issue #99's
78/// `RawNode::TemporalLeaf`, now removed, for the write-hint mechanism this
79/// replaces.
80#[derive(Debug, Clone, PartialEq)]
81pub enum Scalar {
82    /// Null scalar value (spec §2.2).
83    Null,
84    /// Boolean scalar value (spec §2.2).
85    Bool(bool),
86    /// Arbitrary-precision integer scalar value (spec §2.2).
87    Int(num_bigint::BigInt),
88    /// IEEE 754 floating point scalar value (spec §2.2).
89    Float(f64),
90    /// UTF-8 string scalar value (spec §2.2).
91    Str(String),
92    /// ISO 8601 calendar date (`YYYY-MM-DD`, spec §2.2).
93    Date(String),
94    /// ISO 8601 clock time (`hh:mm:ss`, spec §2.2).
95    Time(String),
96    /// ISO 8601 date-time (`YYYY-MM-DDThh:mm:ss`, spec §2.2).
97    Datetime(String),
98}
99
100impl fmt::Display for Scalar {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Scalar::Null => write!(f, "null"),
104            Scalar::Bool(b) => write!(f, "{b}"),
105            Scalar::Int(i) => write!(f, "{i}"),
106            Scalar::Float(x) => write!(f, "{x}"),
107            Scalar::Str(s) => write!(f, "{s:?}"),
108            Scalar::Date(s) | Scalar::Time(s) | Scalar::Datetime(s) => write!(f, "{s}"),
109        }
110    }
111}
112
113/// A plain input value (analogous to a parsed JSON/YAML/TOML value), the
114/// thing [`Doc::of`]/[`Doc::add`]/[`Doc::set`] turn into canonical nodes.
115///
116/// `Object` uses [`IndexMap`] (per issue #1 §2) so key order — which the
117/// Document model treats as data, not incidental — survives construction.
118#[derive(Debug, Clone, PartialEq)]
119pub enum Value {
120    /// Null value.
121    Null,
122    /// Boolean value.
123    Bool(bool),
124    /// Arbitrary-precision integer value.
125    Int(num_bigint::BigInt),
126    /// Floating point value.
127    Float(f64),
128    /// String value.
129    Str(String),
130    /// See [`Scalar::Date`]/[`Scalar::Time`]/[`Scalar::Datetime`] (issue
131    /// #105) -- always already shape-validated and canonical, same
132    /// invariant.
133    Date(String),
134    /// ISO time value, matching [`Scalar::Time`].
135    Time(String),
136    /// ISO datetime value, matching [`Scalar::Datetime`].
137    Datetime(String),
138    /// Heterogeneous array value.
139    Array(Vec<Value>),
140    /// Key-value object mapping.
141    Object(IndexMap<String, Value>),
142}
143
144impl Value {
145    /// Borrow the underlying map if this is an `Object`, else `None`.
146    /// Used by tests to inspect `to_grouped()`'s output without a
147    /// match arm that (for any one call site) is only ever exercised on
148    /// one side -- see the test suite for both an `Object` and a
149    /// non-`Object` call site.
150    #[cfg(test)]
151    fn as_object(&self) -> Option<&IndexMap<String, Value>> {
152        match self {
153            Value::Object(m) => Some(m),
154            _ => None,
155        }
156    }
157}
158
159impl From<Scalar> for Value {
160    fn from(s: Scalar) -> Self {
161        match s {
162            Scalar::Null => Value::Null,
163            Scalar::Bool(b) => Value::Bool(b),
164            Scalar::Int(i) => Value::Int(i),
165            Scalar::Float(x) => Value::Float(x),
166            Scalar::Str(s) => Value::Str(s),
167            Scalar::Date(s) => Value::Date(s),
168            Scalar::Time(s) => Value::Time(s),
169            Scalar::Datetime(s) => Value::Datetime(s),
170        }
171    }
172}
173
174#[derive(Debug, Clone)]
175enum NodeData {
176    Leaf(Scalar),
177    Internal(Vec<(String, NodeId)>),
178}
179
180#[derive(Debug, Clone)]
181struct Entry {
182    data: NodeData,
183    /// This node's own depth relative to the document root (root = 0).
184    /// Recorded at construction time so a later mutation rooted at this
185    /// node (add/set) can seed the depth guard from *here*, not from 0 --
186    /// this is the exact bug class from omnist-ts#37 (a depth check reset
187    /// to 0 on every mutation instead of accounting for how deep the
188    /// mutation's attachment point already is) and omnist-ts#70 (a second
189    /// writer that had the same reset bug because the guard lived in one
190    /// place, not one shared helper called from every entry point).
191    depth: usize,
192}
193
194/// The shared depth guard. Every construction/mutation path that can grow
195/// the tree calls this before creating a node -- see the module-level test
196/// `every_tree_mutating_entry_point_enforces_the_depth_guard` for the audit
197/// that walks every public entry point and confirms each one does.
198pub(crate) fn check_write_depth(depth: usize, path: &str) -> Result<(), DocumentError> {
199    if depth > MAX_DEPTH {
200        return Err(DocumentError::new(
201            path,
202            format!("nesting exceeds the maximum depth ({MAX_DEPTH})"),
203        ));
204    }
205    Ok(())
206}
207
208fn join(path: &str, key: &str) -> String {
209    let is_identifier = !key.is_empty()
210        && key
211            .chars()
212            .next()
213            .is_some_and(|c| c.is_alphabetic() || c == '_')
214        && key.chars().all(|c| c.is_alphanumeric() || c == '_');
215    if is_identifier {
216        format!("{path}.{key}")
217    } else {
218        format!("{path}[\"{key}\"]")
219    }
220}
221
222/// One (path, depth, value) triple to be turned into a child node -- mirrors
223/// Python's `_children` generator, including its extra depth level for
224/// items of an array value (an array value sits one level deeper than a
225/// plain scalar/object value under the same key).
226struct ChildSpec<'a> {
227    path: String,
228    depth: usize,
229    value: &'a Value,
230}
231
232fn child_specs<'a>(
233    v: &'a Value,
234    path: &str,
235    depth: usize,
236) -> Result<Vec<ChildSpec<'a>>, DocumentError> {
237    match v {
238        Value::Array(items) => {
239            let mut out = Vec::with_capacity(items.len());
240            for (i, item) in items.iter().enumerate() {
241                let ip = format!("{path}[{i}]");
242                if matches!(item, Value::Array(_)) {
243                    return Err(DocumentError::new(
244                        ip,
245                        "an array of arrays has no labeled-edge form",
246                    ));
247                }
248                out.push(ChildSpec {
249                    path: ip,
250                    depth: depth + 1,
251                    value: item,
252                });
253            }
254            Ok(out)
255        }
256        other => Ok(vec![ChildSpec {
257            path: path.to_string(),
258            depth,
259            value: other,
260        }]),
261    }
262}
263
264/// Turn a plain [`Value`] into a canonical node inside `arena`, returning
265/// its [`NodeId`]. Mirrors Python's `build_node`.
266fn build_node(
267    arena: &mut Vec<Entry>,
268    value: &Value,
269    path: &str,
270    depth: usize,
271) -> Result<NodeId, DocumentError> {
272    check_write_depth(depth, path)?;
273    match value {
274        Value::Object(map) => {
275            let mut edges = Vec::new();
276            for (k, v) in map {
277                let kp = join(path, k);
278                for spec in child_specs(v, &kp, depth + 1)? {
279                    let cid = build_node(arena, spec.value, &spec.path, spec.depth)?;
280                    edges.push((k.clone(), cid));
281                }
282            }
283            push(arena, NodeData::Internal(edges), depth, path)
284        }
285        Value::Array(_) => Err(DocumentError::new(
286            path,
287            "a bare array has no labeled-edge form (arrays appear only as a repeated field)",
288        )),
289        // Scalars are matched directly as top-level arms of this match
290        // (rather than through a `scalar => { match scalar { ... } }`
291        // catch-all) so every arm is a real, exhaustive possibility --
292        // no `Value::Array(_) | Value::Object(_) => unreachable!()` arm
293        // to justify, since those two variants are already handled above.
294        Value::Null => push(arena, NodeData::Leaf(Scalar::Null), depth, path),
295        Value::Bool(b) => push(arena, NodeData::Leaf(Scalar::Bool(*b)), depth, path),
296        Value::Int(i) => push(arena, NodeData::Leaf(Scalar::Int(i.clone())), depth, path),
297        Value::Float(x) => push(arena, NodeData::Leaf(Scalar::Float(*x)), depth, path),
298        Value::Date(s) => push(arena, NodeData::Leaf(Scalar::Date(s.clone())), depth, path),
299        Value::Time(s) => push(arena, NodeData::Leaf(Scalar::Time(s.clone())), depth, path),
300        Value::Datetime(s) => push(
301            arena,
302            NodeData::Leaf(Scalar::Datetime(s.clone())),
303            depth,
304            path,
305        ),
306        Value::Str(s) => push(arena, NodeData::Leaf(Scalar::Str(s.clone())), depth, path),
307    }
308}
309
310/// The shared node-count guard, checked once here since every construction
311/// path (`build_node`, `push_raw`) funnels through this single function.
312fn push(
313    arena: &mut Vec<Entry>,
314    data: NodeData,
315    depth: usize,
316    path: &str,
317) -> Result<NodeId, DocumentError> {
318    if arena.len() >= MAX_NODES {
319        return Err(DocumentError::new(
320            path,
321            format!("document exceeds the maximum node count ({MAX_NODES})"),
322        ));
323    }
324    let id = NodeId(arena.len());
325    arena.push(Entry { data, depth });
326    Ok(id)
327}
328
329/// A guarded handle on a Document tree: an arena of nodes plus the root.
330#[derive(Debug, Clone)]
331pub struct Doc {
332    arena: Vec<Entry>,
333    root: NodeId,
334}
335
336impl Doc {
337    /// Build a `Doc` from a plain [`Value`].
338    pub fn of(value: &Value) -> Result<Doc, DocumentError> {
339        let mut arena = Vec::new();
340        let root = build_node(&mut arena, value, "$", 0)?;
341        Ok(Doc { arena, root })
342    }
343
344    /// A cursor to the document root, at path `"$"`.
345    pub fn root(&self) -> Cursor<'_> {
346        Cursor {
347            doc: self,
348            id: self.root,
349            path: "$".to_string(),
350        }
351    }
352
353    fn entry(&self, id: NodeId) -> &Entry {
354        &self.arena[id.0]
355    }
356
357    /// Append an edge `(label, value)` under the node at `at`/`path`. A
358    /// repeated label is how an array grows. Returns the new edge's
359    /// `NodeId`.
360    pub fn add(
361        &mut self,
362        at: NodeId,
363        path: &str,
364        label: &str,
365        value: &Value,
366    ) -> Result<NodeId, DocumentError> {
367        self.require_internal(at, path, "add")?;
368        let attach_depth = self.entry(at).depth;
369        let child_path = join(path, label);
370        let cid = build_node(&mut self.arena, value, &child_path, attach_depth + 1)?;
371        let edges = self.internal_edges_mut(at, path, "add")?;
372        edges.push((label.to_string(), cid));
373        Ok(cid)
374    }
375
376    /// Replace all edges under `label` with a single new edge, positioned
377    /// at the first old occurrence (`set` = `remove` + `add`).
378    pub fn set(
379        &mut self,
380        at: NodeId,
381        path: &str,
382        label: &str,
383        value: &Value,
384    ) -> Result<NodeId, DocumentError> {
385        self.require_internal(at, path, "set")?;
386        let attach_depth = self.entry(at).depth;
387        let child_path = join(path, label);
388        let cid = build_node(&mut self.arena, value, &child_path, attach_depth + 1)?;
389        let edges = self.internal_edges_mut(at, path, "set")?;
390        let mut first: Option<usize> = None;
391        let mut kept: Vec<(String, NodeId)> = Vec::with_capacity(edges.len());
392        for (lbl, child) in edges.drain(..) {
393            if lbl == label {
394                if first.is_none() {
395                    first = Some(kept.len());
396                    kept.push((label.to_string(), cid));
397                }
398                // later duplicates are dropped
399            } else {
400                kept.push((lbl, child));
401            }
402        }
403        if first.is_none() {
404            kept.push((label.to_string(), cid));
405        }
406        *edges = kept;
407        Ok(cid)
408    }
409
410    /// Remove every edge under `label`.
411    pub fn remove(&mut self, at: NodeId, path: &str, label: &str) -> Result<(), DocumentError> {
412        self.require_internal(at, path, "remove")?;
413        let edges = self.internal_edges_mut(at, path, "remove")?;
414        edges.retain(|(lbl, _)| lbl != label);
415        Ok(())
416    }
417
418    fn require_internal(&self, id: NodeId, path: &str, op: &str) -> Result<(), DocumentError> {
419        match self.entry(id).data {
420            NodeData::Internal(_) => Ok(()),
421            NodeData::Leaf(_) => Err(DocumentError::new(path, format!("cannot {op} on a leaf"))),
422        }
423    }
424
425    /// The mutable edge list at `at`, or an error if it's a leaf.
426    ///
427    /// `add`/`set`/`remove` all call `require_internal` first (preserving
428    /// Python's check-leaf-before-build-value error ordering), so in
429    /// practice this function's `Leaf` arm never fires through the public
430    /// API -- it exists so the actual mutation site is a real two-armed
431    /// match (both arms reachable and independently tested, see
432    /// `internal_edges_mut_rejects_a_leaf_directly` below) instead of an
433    /// `unreachable!()`/if-let-without-else that would leave a
434    /// structurally-dead branch for `cargo llvm-cov` to flag.
435    fn internal_edges_mut(
436        &mut self,
437        at: NodeId,
438        path: &str,
439        op: &str,
440    ) -> Result<&mut Vec<(String, NodeId)>, DocumentError> {
441        match &mut self.arena[at.0].data {
442            NodeData::Internal(edges) => Ok(edges),
443            NodeData::Leaf(_) => Err(DocumentError::new(path, format!("cannot {op} on a leaf"))),
444        }
445    }
446
447    /// A JSON-shaped projection of the whole document: same-label edges
448    /// grouped into an array; a label seen once stays a single value.
449    pub fn to_grouped(&self) -> Value {
450        self.grouped_at(self.root)
451    }
452
453    /// `true` iff [`Doc::to_grouped`]'s grouping (same-label edges
454    /// collapsed into an array, in first-seen order) changes the relative
455    /// order of edges anywhere in the document -- i.e. some node has two
456    /// edges under the same label with a *different* label's edge between
457    /// them (`[(m,A),(x,X),(m,B)]`), as opposed to a label merely
458    /// repeating contiguously (`[(m,A),(m,B),(x,X)]`), which grouping
459    /// reorders nothing for. Used by the JSON-family writers
460    /// (`json.rs`/`yaml.rs`/`toml.rs`, all built on `to_grouped`) to emit
461    /// `format.interleaving-lost` (spec Sec8.3.8, D-3) at the whole-document
462    /// path `$`, since the loss isn't localized to one label's edges.
463    pub(crate) fn has_interleaving_loss(&self) -> bool {
464        self.node_has_interleaving_loss(self.root)
465    }
466
467    fn node_has_interleaving_loss(&self, id: NodeId) -> bool {
468        match &self.entry(id).data {
469            NodeData::Leaf(_) => false,
470            NodeData::Internal(edges) => {
471                let mut closed: std::collections::HashSet<&str> = std::collections::HashSet::new();
472                let mut prev_label: Option<&str> = None;
473                for (label, _) in edges {
474                    if let Some(p) = prev_label
475                        && p != label.as_str()
476                    {
477                        closed.insert(p);
478                    }
479                    if closed.contains(label.as_str()) {
480                        return true;
481                    }
482                    prev_label = Some(label.as_str());
483                }
484                edges
485                    .iter()
486                    .any(|(_, child)| self.node_has_interleaving_loss(*child))
487            }
488        }
489    }
490
491    // Note: unlike `build_node`/`data_at`, this has no depth parameter --
492    // it walks an already-validated tree (every node was depth-checked at
493    // construction time), so there's nothing left to guard here.
494    fn grouped_at(&self, id: NodeId) -> Value {
495        match &self.entry(id).data {
496            NodeData::Leaf(s) => Value::from(s.clone()),
497            NodeData::Internal(edges) => {
498                let mut counts: IndexMap<&str, usize> = IndexMap::new();
499                for (label, _) in edges {
500                    *counts.entry(label.as_str()).or_insert(0) += 1;
501                }
502                let mut out: IndexMap<String, Value> = IndexMap::new();
503                for (label, child) in edges {
504                    let g = self.grouped_at(*child);
505                    if counts[label.as_str()] > 1 {
506                        match out.get_mut(label.as_str()) {
507                            Some(Value::Array(arr)) => arr.push(g),
508                            _ => {
509                                out.insert(label.clone(), Value::Array(vec![g]));
510                            }
511                        }
512                    } else {
513                        out.insert(label.clone(), g);
514                    }
515                }
516                Value::Object(out)
517            }
518        }
519    }
520
521    /// A lossless copy of the whole document back into [`Value`] form.
522    pub fn to_data(&self) -> Value {
523        self.data_at(self.root)
524    }
525
526    fn data_at(&self, id: NodeId) -> Value {
527        match &self.entry(id).data {
528            NodeData::Leaf(s) => Value::from(s.clone()),
529            NodeData::Internal(edges) => {
530                let mut map = IndexMap::new();
531                // Structural copy only -- this intentionally does NOT
532                // re-group repeated labels (see to_grouped for that); a
533                // repeated label here just overwrites in the IndexMap,
534                // which is why to_data is for equality/structural
535                // comparison, not for JSON-shaped export of repeated
536                // fields. Kept simple: only used by `eq` in this issue.
537                for (label, child) in edges {
538                    map.insert(label.clone(), self.data_at(*child));
539                }
540                Value::Object(map)
541            }
542        }
543    }
544
545    /// Structural equality between two documents (same shape, same edge
546    /// order, same labels, same leaf values).
547    pub fn eq_doc(&self, other: &Doc) -> bool {
548        self.node_eq(self.root, other, other.root)
549    }
550
551    fn node_eq(&self, a: NodeId, other: &Doc, b: NodeId) -> bool {
552        match (&self.entry(a).data, &other.entry(b).data) {
553            (NodeData::Leaf(x), NodeData::Leaf(y)) => x == y,
554            (NodeData::Internal(xs), NodeData::Internal(ys)) => {
555                xs.len() == ys.len()
556                    && xs
557                        .iter()
558                        .zip(ys.iter())
559                        .all(|((la, ca), (lb, cb))| la == lb && self.node_eq(*ca, other, *cb))
560            }
561            _ => false,
562        }
563    }
564}
565
566/// A read-only cursor into a [`Doc`]'s tree, tracking its own path.
567///
568/// Path is owned per-cursor (rather than borrowed) because it's built
569/// incrementally (`$.a.b[1]`) as cursors descend; it's only needed for
570/// error messages and equality with Python's `Doc.path`, not perf-critical
571/// traversal.
572#[derive(Debug, Clone)]
573pub struct Cursor<'a> {
574    doc: &'a Doc,
575    id: NodeId,
576    /// The path of this cursor within the document.
577    pub path: String,
578}
579
580impl<'a> Cursor<'a> {
581    /// The internal [`NodeId`] of the referenced node.
582    pub fn id(&self) -> NodeId {
583        self.id
584    }
585
586    /// Returns `true` iff this node is a leaf holding a scalar.
587    pub fn is_leaf(&self) -> bool {
588        matches!(self.doc.entry(self.id).data, NodeData::Leaf(_))
589    }
590
591    /// Returns the scalar value if this is a leaf node, or `Err` if it is an internal edge node.
592    pub fn value(&self) -> Result<&'a Scalar, DocumentError> {
593        match &self.doc.entry(self.id).data {
594            NodeData::Leaf(s) => Ok(s),
595            NodeData::Internal(_) => Err(DocumentError::new(&self.path, "not a leaf; use edges()")),
596        }
597    }
598
599    /// Returns the outgoing labeled edges if this is an internal node, or `Err` if it is a leaf.
600    pub fn edges(&self) -> Result<Vec<(String, Cursor<'a>)>, DocumentError> {
601        match &self.doc.entry(self.id).data {
602            NodeData::Internal(edges) => {
603                let mut counts: IndexMap<&str, usize> = IndexMap::new();
604                let mut out = Vec::with_capacity(edges.len());
605                for (label, child) in edges {
606                    let i = *counts.entry(label.as_str()).or_insert(0);
607                    counts.insert(label.as_str(), i + 1);
608                    let cp = crate::report::child_path(&self.path, label, i);
609                    out.push((
610                        label.clone(),
611                        Cursor {
612                            doc: self.doc,
613                            id: *child,
614                            path: cp,
615                        },
616                    ));
617                }
618                Ok(out)
619            }
620            NodeData::Leaf(_) => Err(DocumentError::new(&self.path, "a leaf has no edges")),
621        }
622    }
623
624    /// Like [`Cursor::edges`], but doesn't build a path `String` for every
625    /// child up front (issue #44) -- returns each edge's label, its
626    /// same-label occurrence index, and its `NodeId`, leaving path
627    /// construction to the caller. Paired with [`Cursor::seek`], used by
628    /// `schema.rs`'s `conform_record` (the validate hot path), which reuses
629    /// one path buffer for the whole tree walk instead of allocating one
630    /// `String` per edge regardless of whether that edge ever needs one.
631    pub(crate) fn internal_edges(&self) -> Result<&'a [(String, NodeId)], DocumentError> {
632        match &self.doc.entry(self.id).data {
633            NodeData::Internal(edges) => Ok(edges),
634            NodeData::Leaf(_) => Err(DocumentError::new(&self.path, "a leaf has no edges")),
635        }
636    }
637
638    pub(crate) fn raw_edges(&self) -> Result<Vec<(&'a str, usize, NodeId)>, DocumentError> {
639        match &self.doc.entry(self.id).data {
640            NodeData::Internal(edges) => {
641                let mut counts: IndexMap<&str, usize> = IndexMap::new();
642                let mut out = Vec::with_capacity(edges.len());
643                for (label, child) in edges {
644                    let i = *counts.entry(label.as_str()).or_insert(0);
645                    counts.insert(label.as_str(), i + 1);
646                    out.push((label.as_str(), i, *child));
647                }
648                Ok(out)
649            }
650            NodeData::Leaf(_) => Err(DocumentError::new(&self.path, "a leaf has no edges")),
651        }
652    }
653
654    /// Build a cursor for `id` without a meaningful `path` -- only valid for
655    /// callers (like `schema.rs`'s buffer-threaded `conform`) that never
656    /// read the returned cursor's `path` field directly, tracking the real
657    /// path in their own reused buffer instead. Paired with [`Cursor::raw_edges`].
658    pub(crate) fn seek(&self, id: NodeId) -> Cursor<'a> {
659        Cursor {
660            doc: self.doc,
661            id,
662            path: String::new(),
663        }
664    }
665
666    /// Returns a deduplicated list of child edge labels.
667    pub fn labels(&self) -> Vec<String> {
668        let mut seen = std::collections::HashSet::new();
669        let mut out = Vec::new();
670        if let NodeData::Internal(edges) = &self.doc.entry(self.id).data {
671            for (label, _) in edges {
672                if seen.insert(label.clone()) {
673                    out.push(label.clone());
674                }
675            }
676        }
677        out
678    }
679
680    /// Returns all child cursors along edges with the given `label`.
681    pub fn get(&self, label: &str) -> Vec<Cursor<'a>> {
682        self.edges()
683            .into_iter()
684            .flatten()
685            .filter(|(lbl, _)| lbl == label)
686            .map(|(_, c)| c)
687            .collect()
688    }
689
690    /// Returns the single child cursor for `label`, or `Err` if there are 0 or >1 matching edges.
691    pub fn get_one(&self, label: &str) -> Result<Cursor<'a>, DocumentError> {
692        let mut cs = self.get(label);
693        if cs.len() != 1 {
694            return Err(DocumentError::new(
695                &self.path,
696                format!("expected exactly one {label:?}, found {}", cs.len()),
697            ));
698        }
699        Ok(cs.remove(0))
700    }
701
702    /// Returns the number of edges with the given `label`.
703    pub fn count(&self, label: &str) -> usize {
704        if let NodeData::Internal(edges) = &self.doc.entry(self.id).data {
705            edges.iter().filter(|(lbl, _)| lbl == label).count()
706        } else {
707            0
708        }
709    }
710
711    /// A cursor to the single child under `label`.
712    pub fn child(&self, label: &str) -> Result<Cursor<'a>, DocumentError> {
713        self.get_one(label)
714    }
715
716    /// A lossless [`RawNode`] copy of the subtree rooted at this cursor,
717    /// preserving edge order and interleaving exactly. Used by
718    /// `crate::materialize` to carry an untouched subtree forward (e.g. an
719    /// unexpected field it still has to emit for a caller inspecting the
720    /// materialized-but-erroring result) without a second, hand-rolled
721    /// tree-copy routine.
722    pub fn to_raw(&self) -> RawNode {
723        self.doc.raw_at(self.id)
724    }
725}
726
727/// The *raw* canonical Document node: either a leaf scalar, or an ordered
728/// list of `(label, child)` edges that may repeat **and interleave** a label
729/// arbitrarily (`[("b",1),("c",2),("b",3)]` is representable exactly).
730///
731/// This is distinct from [`Value`]: `Value::Object`'s `IndexMap` can't hold
732/// a repeated key, so its "repeated label" convention (a `Value::Array`
733/// under one key, per `child_specs`) only ever expands to a *contiguous*
734/// run of same-label edges. OML is the one format that must round-trip
735/// arbitrary interleaving losslessly (per its own docs: "no adjustment ever
736/// needed"), so its reader/writer (`crate::oml`) builds/walks a `Doc`
737/// through this type instead of going through `Value`.
738#[derive(Debug, Clone, PartialEq)]
739pub enum RawNode {
740    /// A leaf node holding a scalar value.
741    Leaf(Scalar),
742    /// An internal node holding labeled outgoing edges.
743    Edges(Vec<(String, RawNode)>),
744}
745
746impl Doc {
747    /// Build a `Doc` from a [`RawNode`], preserving edge order and
748    /// interleaving exactly. Depth-guarded via the same
749    /// `check_write_depth` every other construction path uses.
750    pub fn from_raw(root: RawNode) -> Result<Doc, DocumentError> {
751        let mut arena = Vec::new();
752        let root_id = push_raw(&mut arena, root, 0)?;
753        Ok(Doc {
754            arena,
755            root: root_id,
756        })
757    }
758
759    /// The inverse of [`Doc::from_raw`]: a lossless walk back into
760    /// [`RawNode`] form, preserving edge order and interleaving exactly.
761    pub fn to_raw(&self) -> RawNode {
762        self.raw_at(self.root)
763    }
764
765    fn raw_at(&self, id: NodeId) -> RawNode {
766        let entry = self.entry(id);
767        match &entry.data {
768            NodeData::Leaf(s) => RawNode::Leaf(s.clone()),
769            NodeData::Internal(edges) => RawNode::Edges(
770                edges
771                    .iter()
772                    .map(|(label, child)| (label.clone(), self.raw_at(*child)))
773                    .collect(),
774            ),
775        }
776    }
777}
778
779/// Name-keyed dispatch through [`crate::registry`] (issue #31), mirroring
780/// Python's `Doc.from_format`/`to_format`/`check_format` in
781/// `~/dev/omnist/omnist/document.py`. Kept as its own `impl Doc` block
782/// (rather than folded into the constructors/export blocks above) since it
783/// is the one place `Doc` depends on the registry module rather than a
784/// single format module directly.
785impl Doc {
786    /// Read `text` as the registered format `name` (an
787    /// [`crate::error::OmnistError::Format`] if `name` isn't registered).
788    /// Mirrors Python's `Doc.from_format(name, text)`.
789    pub fn from_format(name: &str, text: &str) -> Result<Doc, crate::error::OmnistError> {
790        let fmt = crate::registry::get_format(name)?;
791        (fmt.read)(text)
792    }
793
794    /// Write `self` as the registered format `name`. Mirrors Python's
795    /// `Doc.to_format(name)`.
796    pub fn to_format(&self, name: &str) -> Result<String, crate::error::OmnistError> {
797        let fmt = crate::registry::get_format(name)?;
798        (fmt.write)(self)
799    }
800
801    /// Simulate writing `self` as the registered format `name`, without
802    /// producing output. Mirrors Python's `Doc.check_format(name)`: an
803    /// [`crate::error::OmnistError::Document`] if `name`'s registered
804    /// [`crate::registry::Format`] has no `check` callable (a plugin
805    /// registered via [`crate::registry::Format::new`] alone) -- not a
806    /// panic.
807    pub fn check_format(
808        &self,
809        name: &str,
810    ) -> Result<crate::report::WriteReport, crate::error::OmnistError> {
811        let fmt = crate::registry::get_format(name)?;
812        match &fmt.check {
813            Some(check) => Ok(check(self)),
814            None => Err(DocumentError::new(
815                "$",
816                format!("format {name:?} has no check() -- cannot simulate a write"),
817            )
818            .into()),
819        }
820    }
821}
822
823fn push_raw(arena: &mut Vec<Entry>, node: RawNode, depth: usize) -> Result<NodeId, DocumentError> {
824    // Path information isn't meaningful during a from-source OML parse (no
825    // dotted-key path exists yet), so a fixed placeholder is used here --
826    // matching the depth guard's own error message, which never mentions
827    // path for depth violations anyway (see `check_write_depth`).
828    check_write_depth(depth, "$")?;
829    match node {
830        RawNode::Leaf(s) => push(arena, NodeData::Leaf(s), depth, "$"),
831        RawNode::Edges(edges) => {
832            let mut out = Vec::with_capacity(edges.len());
833            for (label, child) in edges {
834                let cid = push_raw(arena, child, depth + 1)?;
835                out.push((label, cid));
836            }
837            push(arena, NodeData::Internal(out), depth, "$")
838        }
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    fn obj(pairs: &[(&str, Value)]) -> Value {
847        let mut m = IndexMap::new();
848        for (k, v) in pairs {
849            m.insert((*k).to_string(), v.clone());
850        }
851        Value::Object(m)
852    }
853
854    /// `levels` nested objects (each `{"a": ...}`) wrapping a leaf. Per
855    /// `build_node`'s depth arithmetic, the leaf ends up at depth `levels`
856    /// (each object level adds exactly 1 -- no array involved).
857    fn nest(levels: usize) -> Value {
858        let mut v = Value::Int((0).into());
859        for _ in 0..levels {
860            v = obj(&[("a", v)]);
861        }
862        v
863    }
864
865    // -- basic construction ---------------------------------------------
866
867    #[test]
868    fn constructs_a_scalar_leaf() {
869        let doc = Doc::of(&Value::Int((42).into())).unwrap();
870        let root = doc.root();
871        assert!(root.is_leaf());
872        assert_eq!(root.value().unwrap(), &Scalar::Int((42).into()));
873    }
874
875    #[test]
876    fn constructs_an_object_as_ordered_edges() {
877        let v = obj(&[("b", Value::Int((1).into())), ("a", Value::Int((2).into()))]);
878        let doc = Doc::of(&v).unwrap();
879        let root = doc.root();
880        assert!(!root.is_leaf());
881        let edges = root.edges().unwrap();
882        let labels: Vec<&str> = edges.iter().map(|(l, _)| l.as_str()).collect();
883        // Insertion order preserved, NOT sorted -- "b" before "a".
884        assert_eq!(labels, vec!["b", "a"]);
885    }
886
887    #[test]
888    fn a_list_value_expands_into_repeated_edges() {
889        let v = obj(&[(
890            "member",
891            Value::Array(vec![
892                Value::Int((1).into()),
893                Value::Int((2).into()),
894                Value::Int((3).into()),
895            ]),
896        )]);
897        let doc = Doc::of(&v).unwrap();
898        let root = doc.root();
899        assert_eq!(root.count("member"), 3);
900        let members = root.get("member");
901        let vals: Vec<&Scalar> = members.iter().map(|c| c.value().unwrap()).collect();
902        assert_eq!(
903            vals,
904            vec![
905                &Scalar::Int((1).into()),
906                &Scalar::Int((2).into()),
907                &Scalar::Int((3).into())
908            ]
909        );
910    }
911
912    #[test]
913    fn a_bare_top_level_array_is_rejected() {
914        let err = Doc::of(&Value::Array(vec![Value::Int((1).into())])).unwrap_err();
915        assert!(err.message.contains("bare array"));
916        assert_eq!(err.path, "$");
917    }
918
919    #[test]
920    fn an_array_of_arrays_is_rejected() {
921        let v = obj(&[(
922            "a",
923            Value::Array(vec![Value::Array(vec![Value::Int((1).into())])]),
924        )]);
925        let err = Doc::of(&v).unwrap_err();
926        assert!(err.message.contains("array of arrays"));
927        assert_eq!(err.path, "$.a[0]");
928    }
929
930    // -- depth guard: max-depth boundary ---------------------------------
931
932    #[test]
933    fn depth_guard_accepts_exactly_max_depth() {
934        // nest(MAX_DEPTH) puts the leaf at depth == MAX_DEPTH, which is
935        // the accept boundary (guard rejects only depth > MAX_DEPTH).
936        let v = nest(MAX_DEPTH);
937        assert!(Doc::of(&v).is_ok());
938    }
939
940    #[test]
941    fn depth_guard_rejects_one_past_max_depth() {
942        let v = nest(MAX_DEPTH + 1);
943        let err = Doc::of(&v).unwrap_err();
944        assert!(err.message.contains("maximum depth"));
945    }
946
947    // -- node-count guard: max-nodes boundary (omnist-rs#78) -------------
948    //
949    // A shallow document can still be enormous -- depth alone doesn't bound
950    // total memory, e.g. a single label repeated a million times is depth 1.
951    // `wide(n)` builds `{"a": [0, 0, ..., 0]}` with `n` array elements, for
952    // a total node count of `n + 1` (the root object, plus one leaf per
953    // array element -- the array itself desugars into repeated edges, not
954    // its own node).
955
956    fn wide(n: usize) -> Value {
957        obj(&[("a", Value::Array(vec![Value::Int((0).into()); n]))])
958    }
959
960    #[test]
961    fn node_guard_accepts_exactly_max_nodes() {
962        let v = wide(MAX_NODES - 1);
963        assert!(Doc::of(&v).is_ok());
964    }
965
966    #[test]
967    fn node_guard_rejects_one_past_max_nodes() {
968        let v = wide(MAX_NODES);
969        let err = Doc::of(&v).unwrap_err();
970        assert!(err.message.contains("maximum node count"));
971    }
972
973    #[test]
974    fn an_array_value_consumes_an_extra_depth_level() {
975        // A scalar directly under a key sits one level deeper than the
976        // object (depth 1). The same scalar wrapped in a one-item array
977        // under that key sits *two* levels deeper (depth 2) -- the array
978        // itself costs an extra level, mirroring Python's `_children`
979        // (see the module doc comment / build_node's `depth + 1` for the
980        // list branch on top of the caller's own `depth + 1`).
981        let direct = obj(&[("a", Value::Int((1).into()))]);
982        let via_array = obj(&[("a", Value::Array(vec![Value::Int((1).into())]))]);
983        let doc_direct = Doc::of(&direct).unwrap();
984        let doc_array = Doc::of(&via_array).unwrap();
985        let leaf_direct = doc_direct.root().child("a").unwrap();
986        let leaf_array = doc_array.root().child("a").unwrap();
987        assert_eq!(doc_direct.entry(leaf_direct.id()).depth, 1);
988        assert_eq!(doc_array.entry(leaf_array.id()).depth, 2);
989    }
990
991    // -- depth guard: every public tree-mutating entry point -------------
992
993    #[test]
994    fn every_tree_mutating_entry_point_enforces_the_depth_guard() {
995        // Doc::of
996        assert!(Doc::of(&nest(MAX_DEPTH + 1)).is_err());
997
998        // Doc::add, attaching at the root (depth 0): the pushed subtree's
999        // own internal depth is what must exceed MAX_DEPTH.
1000        let mut doc = Doc::of(&obj(&[("seed", Value::Int((0).into()))])).unwrap();
1001        let root_id = doc.root().id();
1002        let root_path = doc.root().path.clone();
1003        assert!(
1004            doc.add(root_id, &root_path, "b", &nest(MAX_DEPTH + 1))
1005                .is_err()
1006        );
1007
1008        // Doc::set: same guard, different mutating call site.
1009        let mut doc2 = Doc::of(&obj(&[("seed", Value::Int((0).into()))])).unwrap();
1010        let root_id2 = doc2.root().id();
1011        let root_path2 = doc2.root().path.clone();
1012        assert!(
1013            doc2.set(root_id2, &root_path2, "b", &nest(MAX_DEPTH + 1))
1014                .is_err()
1015        );
1016    }
1017
1018    // -- depth guard: hand-constructed deep subtree bypassing a
1019    //    construction-time-only check (omnist-ts#37 / omnist-ts#70) -------
1020
1021    #[test]
1022    fn add_at_a_deep_cursor_accounts_for_the_cursors_own_depth() {
1023        // Build a document 200 objects deep (leaf at depth 200), then walk
1024        // a cursor down 190 levels to an *internal* node sitting at depth
1025        // 190. If `add()` (re-)started the depth guard's counter at 0 for
1026        // the pushed subtree -- the exact omnist-ts#37 bug, where
1027        // `buildNode` was called with no depth argument on every mutation
1028        // -- a modest 15-level subtree would wrongly be accepted here
1029        // (15 < MAX_DEPTH). Threading the cursor's own depth through
1030        // means 190 + 1 (attach) + 15 = 206 > MAX_DEPTH is correctly
1031        // rejected instead.
1032        let mut doc = Doc::of(&nest(MAX_DEPTH)).unwrap();
1033        let mut cursor = doc.root();
1034        for _ in 0..190 {
1035            cursor = cursor.child("a").unwrap();
1036        }
1037        assert_eq!(doc.entry(cursor.id()).depth, 190);
1038        let id = cursor.id();
1039        let path = cursor.path.clone();
1040
1041        let too_deep = nest(15);
1042        assert!(doc.add(id, &path, "b", &too_deep).is_err());
1043
1044        // Positive control: a shallow enough subtree at the same depth
1045        // succeeds, proving the rejection above is about depth, not some
1046        // unrelated failure.
1047        let shallow = nest(5);
1048        assert!(doc.set(id, &path, "b", &shallow).is_ok());
1049    }
1050
1051    // -- IndexMap ordering preserved on iteration ------------------------
1052
1053    #[test]
1054    fn labels_and_get_preserve_first_seen_and_insertion_order() {
1055        // Repeated labels come from an `Array` value under one key (a dict
1056        // key can't repeat) -- "z" appears twice because its value is a
1057        // 2-item array, not because the object has two "z" entries.
1058        let v = obj(&[
1059            (
1060                "z",
1061                Value::Array(vec![Value::Int((1).into()), Value::Int((3).into())]),
1062            ),
1063            ("a", Value::Int((2).into())),
1064            ("m", Value::Int((4).into())),
1065        ]);
1066        let doc = Doc::of(&v).unwrap();
1067        let root = doc.root();
1068        assert_eq!(root.labels(), vec!["z", "a", "m"]);
1069        let z_vals: Vec<&Scalar> = root.get("z").iter().map(|c| c.value().unwrap()).collect();
1070        assert_eq!(
1071            z_vals,
1072            vec![&Scalar::Int((1).into()), &Scalar::Int((3).into())]
1073        );
1074    }
1075
1076    #[test]
1077    fn labels_and_count_on_a_leaf_are_empty() {
1078        let doc = Doc::of(&Value::Int((1).into())).unwrap();
1079        let root = doc.root();
1080        assert!(root.labels().is_empty());
1081        assert_eq!(root.count("anything"), 0);
1082    }
1083
1084    #[test]
1085    fn to_grouped_preserves_first_seen_key_order() {
1086        let v = obj(&[
1087            (
1088                "z",
1089                Value::Array(vec![Value::Int((1).into()), Value::Int((3).into())]),
1090            ),
1091            ("a", Value::Int((2).into())),
1092        ]);
1093        let doc = Doc::of(&v).unwrap();
1094        let grouped = doc.to_grouped();
1095        let expected = obj(&[
1096            (
1097                "z",
1098                Value::Array(vec![Value::Int((1).into()), Value::Int((3).into())]),
1099            ),
1100            ("a", Value::Int((2).into())),
1101        ]);
1102        assert_eq!(grouped, expected);
1103        // `assert_eq!` above is order-insensitive (`IndexMap`'s `PartialEq`
1104        // ignores insertion order), so separately confirm key order here.
1105        let keys: Vec<&str> = grouped
1106            .as_object()
1107            .unwrap()
1108            .keys()
1109            .map(|s| s.as_str())
1110            .collect();
1111        assert_eq!(keys, vec!["z", "a"]);
1112    }
1113
1114    #[test]
1115    fn value_as_object_is_none_for_a_non_object() {
1116        assert!(Value::Int((1).into()).as_object().is_none());
1117    }
1118
1119    // -- mutation semantics: add / set / remove / count / get -----------
1120
1121    #[test]
1122    fn add_appends_and_get_one_requires_exactly_one() {
1123        let mut doc = Doc::of(&obj(&[])).unwrap();
1124        let root_id = doc.root().id();
1125        let root_path = doc.root().path.clone();
1126        doc.add(root_id, &root_path, "x", &Value::Int((1).into()))
1127            .unwrap();
1128        doc.add(root_id, &root_path, "x", &Value::Int((2).into()))
1129            .unwrap();
1130        let root = doc.root();
1131        assert_eq!(root.count("x"), 2);
1132        assert!(root.get_one("x").is_err());
1133    }
1134
1135    #[test]
1136    fn set_replaces_all_occurrences_at_first_position() {
1137        let mut doc = Doc::of(&obj(&[
1138            ("x", Value::Int((1).into())),
1139            ("y", Value::Int((9).into())),
1140            ("x", Value::Int((2).into())),
1141        ]))
1142        .unwrap();
1143        let root_id = doc.root().id();
1144        let root_path = doc.root().path.clone();
1145        doc.set(root_id, &root_path, "x", &Value::Int((100).into()))
1146            .unwrap();
1147        let root = doc.root();
1148        let labels: Vec<String> = root.edges().unwrap().into_iter().map(|(l, _)| l).collect();
1149        assert_eq!(labels, vec!["x", "y"]);
1150        assert_eq!(
1151            root.get_one("x").unwrap().value().unwrap(),
1152            &Scalar::Int((100).into())
1153        );
1154    }
1155
1156    #[test]
1157    fn remove_drops_every_edge_with_that_label() {
1158        let mut doc = Doc::of(&obj(&[
1159            ("x", Value::Int((1).into())),
1160            ("x", Value::Int((2).into())),
1161        ]))
1162        .unwrap();
1163        let root_id = doc.root().id();
1164        let root_path = doc.root().path.clone();
1165        doc.remove(root_id, &root_path, "x").unwrap();
1166        assert_eq!(doc.root().count("x"), 0);
1167    }
1168
1169    #[test]
1170    fn internal_edges_mut_rejects_a_leaf_directly() {
1171        // White-box test of the private helper: `add`/`set`/`remove` all
1172        // gate through `require_internal` first, so this helper's `Leaf`
1173        // arm is unreachable via the public API -- call it directly to
1174        // prove the arm itself is correct (see its doc comment).
1175        let mut doc = Doc::of(&Value::Int((1).into())).unwrap();
1176        let root_id = doc.root().id();
1177        let err = doc.internal_edges_mut(root_id, "$", "poke").unwrap_err();
1178        assert_eq!(err.path, "$");
1179        assert!(err.message.contains("cannot poke on a leaf"));
1180    }
1181
1182    #[test]
1183    fn mutation_on_a_leaf_is_rejected() {
1184        let mut doc = Doc::of(&Value::Int((1).into())).unwrap();
1185        let root_id = doc.root().id();
1186        let root_path = doc.root().path.clone();
1187        assert!(
1188            doc.add(root_id, &root_path, "x", &Value::Int((1).into()))
1189                .is_err()
1190        );
1191        assert!(
1192            doc.set(root_id, &root_path, "x", &Value::Int((1).into()))
1193                .is_err()
1194        );
1195        assert!(doc.remove(root_id, &root_path, "x").is_err());
1196    }
1197
1198    #[test]
1199    fn value_on_an_internal_node_is_rejected() {
1200        let doc = Doc::of(&obj(&[("x", Value::Int((1).into()))])).unwrap();
1201        assert!(doc.root().value().is_err());
1202    }
1203
1204    #[test]
1205    fn edges_on_a_leaf_is_rejected() {
1206        let doc = Doc::of(&Value::Int((1).into())).unwrap();
1207        assert!(doc.root().edges().is_err());
1208    }
1209
1210    #[test]
1211    fn raw_edges_on_a_leaf_is_rejected() {
1212        // Mirrors `edges_on_a_leaf_is_rejected` for the lazy-path variant
1213        // `edges()` piggybacks its own path-numbering on -- see issue #44.
1214        let doc = Doc::of(&Value::Int((1).into())).unwrap();
1215        assert!(doc.root().raw_edges().is_err());
1216    }
1217
1218    // -- export / equality ------------------------------------------------
1219
1220    #[test]
1221    fn to_data_round_trips_structure() {
1222        let v = obj(&[
1223            ("a", Value::Int((1).into())),
1224            ("b", Value::Str("hi".to_string())),
1225        ]);
1226        let doc = Doc::of(&v).unwrap();
1227        assert_eq!(doc.to_data(), v);
1228    }
1229
1230    #[test]
1231    fn to_data_round_trips_every_scalar_variant() {
1232        // Covers every `Value`/`Scalar` leaf variant through both
1233        // `build_node` (construction) and `From<Scalar> for Value`
1234        // (export via to_data), not just Int/Str.
1235        let v = obj(&[
1236            ("n", Value::Null),
1237            ("b", Value::Bool(true)),
1238            ("i", Value::Int((7).into())),
1239            ("f", Value::Float(1.5)),
1240            ("s", Value::Str("hi".to_string())),
1241        ]);
1242        let doc = Doc::of(&v).unwrap();
1243        assert_eq!(doc.to_data(), v);
1244        assert_eq!(doc.to_grouped(), v);
1245    }
1246
1247    #[test]
1248    fn join_quotes_a_non_identifier_key() {
1249        // A key that isn't a valid identifier (starts with a digit) takes
1250        // the `path["key"]` form, not `path.key`.
1251        let v = obj(&[(
1252            "1bad",
1253            Value::Array(vec![Value::Array(vec![Value::Int((1).into())])]),
1254        )]);
1255        let err = Doc::of(&v).unwrap_err();
1256        assert_eq!(err.path, "$[\"1bad\"][0]");
1257    }
1258
1259    #[test]
1260    fn eq_doc_compares_structurally() {
1261        let a = Doc::of(&obj(&[("a", Value::Int((1).into()))])).unwrap();
1262        let b = Doc::of(&obj(&[("a", Value::Int((1).into()))])).unwrap();
1263        let c = Doc::of(&obj(&[("a", Value::Int((2).into()))])).unwrap();
1264        assert!(a.eq_doc(&b));
1265        assert!(!a.eq_doc(&c));
1266    }
1267
1268    #[test]
1269    fn eq_doc_is_false_when_shapes_differ() {
1270        // A leaf is never equal to an internal node, regardless of
1271        // content -- exercises `node_eq`'s (Leaf, Internal) mismatch arm.
1272        let leaf = Doc::of(&Value::Int((1).into())).unwrap();
1273        let internal = Doc::of(&obj(&[("a", Value::Int((1).into()))])).unwrap();
1274        assert!(!leaf.eq_doc(&internal));
1275        assert!(!internal.eq_doc(&leaf));
1276    }
1277
1278    #[test]
1279    fn scalar_display_covers_every_variant() {
1280        assert_eq!(Scalar::Null.to_string(), "null");
1281        assert_eq!(Scalar::Bool(true).to_string(), "true");
1282        assert_eq!(Scalar::Int((1).into()).to_string(), "1");
1283        assert_eq!(Scalar::Float(1.5).to_string(), "1.5");
1284        assert_eq!(Scalar::Str("x".to_string()).to_string(), "\"x\"");
1285    }
1286
1287    // -- RawNode / from_raw / to_raw (interleaved-edge round trip) ---------
1288
1289    #[test]
1290    fn from_raw_to_raw_round_trips_interleaved_repeated_labels() {
1291        // ("b",1),("c",2),("b",3): interleaved, not a contiguous run --
1292        // exactly the shape `Value`/`IndexMap` cannot represent, which is
1293        // why the OML codec goes through RawNode instead.
1294        let raw = RawNode::Edges(vec![
1295            ("b".to_string(), RawNode::Leaf(Scalar::Int((1).into()))),
1296            ("c".to_string(), RawNode::Leaf(Scalar::Int((2).into()))),
1297            ("b".to_string(), RawNode::Leaf(Scalar::Int((3).into()))),
1298        ]);
1299        let doc = Doc::from_raw(raw.clone()).unwrap();
1300        assert_eq!(doc.to_raw(), raw);
1301        let labels: Vec<String> = doc
1302            .root()
1303            .edges()
1304            .unwrap()
1305            .into_iter()
1306            .map(|(l, _)| l)
1307            .collect();
1308        assert_eq!(labels, vec!["b", "c", "b"]);
1309    }
1310
1311    #[test]
1312    fn from_raw_leaf_round_trips() {
1313        let raw = RawNode::Leaf(Scalar::Str("hi".to_string()));
1314        let doc = Doc::from_raw(raw.clone()).unwrap();
1315        assert!(doc.root().is_leaf());
1316        assert_eq!(doc.to_raw(), raw);
1317    }
1318
1319    #[test]
1320    fn from_raw_enforces_the_depth_guard() {
1321        fn nest_raw(levels: usize) -> RawNode {
1322            let mut n = RawNode::Leaf(Scalar::Int((0).into()));
1323            for _ in 0..levels {
1324                n = RawNode::Edges(vec![("a".to_string(), n)]);
1325            }
1326            n
1327        }
1328        assert!(Doc::from_raw(nest_raw(MAX_DEPTH)).is_ok());
1329        let err = Doc::from_raw(nest_raw(MAX_DEPTH + 1)).unwrap_err();
1330        assert!(err.message.contains("maximum depth"));
1331    }
1332}