Skip to main content

omnist/formats/
toml.rs

1//! TOML codec. Ported from `~/dev/omnist/omnist/formats.py`'s
2//! `read_toml`/`write_toml`/`check_toml`.
3//!
4//! ## Crate choice
5//!
6//! Like `yaml.rs` (issue #18), this module delegates raw tokenization to a
7//! well-tested crate -- [`toml_edit`] -- rather than hand-rolling TOML's
8//! grammar (tables, dotted keys, inline tables, array-of-tables headers,
9//! four temporal literal forms, three integer radixes). `toml_edit` is used
10//! **read-side only**: [`read_toml`] walks its parsed `DocumentMut` into
11//! this crate's canonical [`crate::document::Value`]. The **writer is
12//! hand-written** (mirroring `json.rs`/`yaml.rs`'s writers), producing TOML
13//! text directly from a [`crate::document::Value`] rather than round-
14//! tripping back through `toml_edit`'s own formatter -- see "Writer output
15//! shape" below for why.
16//!
17//! Everything omnist-specific stays hand-written Rust, not delegated to the
18//! crate:
19//!
20//! * **Null-adjustment** (`strip_nulls`) -- TOML has no `null` at all;
21//!   dropping a null-valued field/array-item and recording it via
22//!   [`crate::report::WriteReport`] is this module's own logic (see
23//!   "No null" below).
24//! * **Temporal canonicalization** (`format_datetime`) -- turning
25//!   `toml_edit`'s parsed `Date`/`Time`/`Datetime` structs into this port's
26//!   canonical ISO-string spelling (see "Native temporal types" below).
27//! * **Integer digit cap** -- `toml_edit` itself rejects any integer
28//!   literal that doesn't fit in `i64` with a generic "integer number
29//!   overflowed" parse error that discards the offending literal's text;
30//!   this module recovers the raw digit run from the error's byte span and
31//!   re-derives the same 4300-digit-cap-vs-genuine-overflow distinction
32//!   `json.rs`/`yaml.rs` already make (see "Integer digit cap" below for
33//!   why this needed hand-written recovery rather than being free from the
34//!   crate).
35//! * **Depth guard, shape-check reuse** -- see their own sections below.
36//!
37//! ## No `null` (the one unrepresentable-value case TOML has)
38//!
39//! Spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24): writing a node containing a
40//! null-valued field now fails the write **unconditionally**
41//! (`write.unsupported-value`, via [`crate::report::unsupported_value_error`])
42//! regardless of `strict`, rather than the old "drop the field and record a
43//! warning" behavior. TOML has no null token at all -- dropping the edge
44//! didn't just alter what's represented at that position, it erased the
45//! edge's existence entirely, with zero trace on read-back that a labeled
46//! edge with that path was ever there (confirmed live; retired the
47//! `null.omitted` code -- see `strip_nulls`'s doc comment). `check_toml`
48//! still reports the same condition as a `write.unsupported-value`
49//! `Severity::Error` adjustment for preview purposes, since it never
50//! produces output to begin with.
51//!
52//! If stripping nulls leaves a document whose root isn't a table (object),
53//! [`write_toml`] raises `WriteError` unconditionally -- **not** part of
54//! the report, mirrors Python's `write_toml` raising
55//! `WriteError("TOML needs a top-level table (the root must be an
56//! object)")` outside of `finish_write` entirely (so it fires even when
57//! `report` is supplied and even though the message never enters the
58//! accumulated `WriteReport`).
59//!
60//! ## Integer digit cap (omnist-ts#54 / json.rs / yaml.rs precedent) --
61//! **not** a natural 64-bit range check
62//!
63//! The issue's own framing floated "TOML integers are spec'd as 64-bit, so
64//! this may be a natural range check rather than the 4300-digit-cap
65//! mechanism used elsewhere" -- **live-checked against `tomllib` (Python's
66//! stdlib TOML reader, which `omnist.formats.read_toml` wraps directly) and
67//! found not to be the case**: `tomllib.loads("x = " + "9"*4300)` parses
68//! successfully to a full-precision Python `int` (no 64-bit truncation, no
69//! error) -- while `tomllib.loads("x = " + "9"*4301)` raises `ValueError:
70//! Exceeds the limit (4300 digits) for integer string conversion`, the
71//! *identical* CPython `int(str)`-conversion guard `read_json`'s comment
72//! documents (`sys.set_int_max_str_digits`), not a TOML-spec-mandated
73//! 64-bit bounds check at all -- **for decimal literals only**. Hex/octal/
74//! binary literals are a genuine exception, not a false one: live-confirmed
75//! `tomllib.loads("x = 0x" + "f" * 5000)` (and even 10000 `f`s) parses
76//! successfully with **no error at all**, matching CPython's own documented
77//! carve-out (`sys.set_int_max_str_digits` explicitly exempts power-of-two
78//! bases) -- an earlier draft of this comment claimed hex/octal/binary hit
79//! the identical digit-limit `ValueError`, which was wrong and has been
80//! corrected. So Python's TOML integer handling is `json.rs`'s/`yaml.rs`'s
81//! existing 4300-digit-cap pattern for decimal literals, and *uncapped* for
82//! hex/octal/binary.
83//!
84//! This port does **not** replicate that decimal/non-decimal split: every
85//! radix goes through the same `toml_overflow_error` recovery and the same
86//! 4300-digit cap, because `toml_edit` itself enforces a strict `i64` range
87//! at parse time regardless of radix (see below) -- there is no path in this
88//! implementation for an oversized hex/octal/binary literal to reach
89//! `Scalar::Int` uncapped the way Python's arbitrary-precision `int` does.
90//! This is a **disclosed divergence from Python**, not parity: kept
91//! deliberately rather than special-cased away, because (a) TOML 1.0 itself
92//! specifies 64-bit signed integers and `toml_edit` enforces 64-bit bounds
93//! at parse time, so an "uncapped hex" path would still fail for anything
94//! over `i64::MAX`, and (b) capping the digit run uniformly preserves the
95//! same superlinear-conversion DoS protection `json.rs`/`yaml.rs` apply,
96//! without carving out a radix-specific exemption.
97//!
98//! Where this module's implementation had to diverge from `json.rs`'s
99//! straight-line reuse: **`toml_edit` itself enforces a strict 64-bit
100//! range** at parse time (`"9"*20` / `i64::MAX + 1` both fail with a
101//! generic "integer number overflowed" `TomlError` that does not expose the
102//! original digit run), which is *stricter* than Python's real behavior for
103//! anything between 20 and 4300 digits. To keep this port's observable
104//! integer-literal error behavior matching Python's (not `toml_edit`'s
105//! internal, incidentally-stricter parse limit), `toml_overflow_error`
106//! recovers the raw literal text from the failed parse's byte span
107//! (`TomlError::span`) and re-derives the digit count itself, producing the
108//! same two-tier message `json.rs`/`yaml.rs` give ("exceeds the 4300-digit
109//! cap" vs "out of range for a 64-bit integer") instead of surfacing
110//! `toml_edit`'s own message directly.
111//!
112//! ## Native temporal types (the opposite direction from JSON's problem)
113//!
114//! Unlike JSON (no temporal type at all) and like YAML (a native but looser
115//! timestamp grammar), TOML has **four** first-class temporal literal forms
116//! (local date, local time, local datetime, offset datetime) that are
117//! *stricter*-shaped than YAML's -- `toml_edit`'s own parser already fully
118//! validates calendar/clock fields (leap years, per-month day counts, valid
119//! hour/minute/offset ranges: live-confirmed `2024-02-30`, `2024-13-01`,
120//! `25:00:00`, `00:60:00`, and a `+25:00` offset are all **parse
121//! errors**, not accepted-then-rejected-later), so [`read_toml`] does not
122//! need to re-validate calendar/clock fields the way `yaml.rs`'s
123//! `normalize_timestamp` must (YAML's own crate does no such validation).
124//!
125//! [`crate::document::Scalar`] has real `Date`/`Time`/`Datetime` variants
126//! (issue #105) -- `toml_value_to_value` reads `toml_edit`'s own already
127//! fully-validated `Datetime` struct's `date`/`time` presence to construct
128//! the right one directly (real provenance, not a shape guess), and
129//! `format_datetime` renders the canonical ISO spelling either way:
130//! zero-padded, `T`-joined, a bare `Z` offset normalized to `+00:00`
131//! (matching `yaml.rs`'s identical normalization -- this port's canonical
132//! temporal strings never contain a literal `Z`, only a numeric offset,
133//! which is what `crate::schema::is_iso_datetime`'s regex expects).
134//! Fractional seconds beyond microsecond precision are **truncated, not
135//! rounded**, to six digits -- live-confirmed against `tomllib`:
136//! `00:32:00.9999999` (7 nines) reads as `datetime.time(0, 32, 0, 999999)`
137//! (truncated, not rounded to `1000000` and carried), matching this
138//! module's `nanosecond / 1000` integer-truncating conversion exactly.
139//!
140//! **UTC-offset preservation** (the omnist-ts#51-pattern check this issue
141//! calls for): an offset datetime's numeric offset is preserved exactly in
142//! the canonical string (`-07:00` stays `-07:00`across read+write), so this
143//! module does not repeat the OML writer's offset-erasure bug -- confirmed
144//! by this module's `round_trips_offset_datetime_preserving_negative_offset`
145//! and `_positive_offset` tests.
146//!
147//! On the **write** side, a genuine `Scalar::Date`/`Datetime` writes as a
148//! *native* TOML temporal literal (unquoted) unconditionally -- no
149//! shape-check, since the variant itself is the provenance signal (issue
150//! #105, the same fix issue #99 already applied to OML). An ordinary
151//! `Scalar::Str` **always writes quoted**, however date-shaped its text --
152//! this now matches Python's `write_toml` exactly (previously diverged:
153//! Python's document model retains a real `datetime.date`/`time`/
154//! `datetime` object end-to-end, so a plain `str` that merely looks like a
155//! date -- live-confirmed: `tomli_w.dumps({'a': '1979-05-27'})` -- always
156//! wrote as the quoted string `a = "1979-05-27"`, never a native literal;
157//! this port's pre-#105 `Scalar` had no way to make that distinction, so
158//! it wrote bare unconditionally whenever the text merely *looked*
159//! temporal-shaped -- a real bug, confirmed live and fixed, not a
160//! permitted variation). A `Scalar::Time` carrying a UTC offset is the one
161//! remaining case with no native TOML spelling at all (TOML's *local
162//! time* literal has no offset field) -- see `write_scalar`'s own
163//! `has_offset` fallback.
164//!
165//! ## Depth guard reuse
166//!
167//! [`read_toml`] parses TOML text into a [`crate::document::Value`], then
168//! builds a [`Doc`] via [`Doc::of`] -- which calls
169//! `crate::document::check_write_depth` internally (see `document.rs`).
170//! [`write_toml`]/[`check_toml`]/`strip_nulls` walk an already-built
171//! `Doc` (via `Doc::to_grouped`), whose every node was depth-checked at
172//! construction time -- exactly `json.rs`'s reasoning (not `yaml.rs`'s,
173//! which pre-processes raw structures *before* `Doc::of` ever runs) --
174//! there is nothing left to re-guard on the way out, so `strip_nulls` does
175//! not take or check a depth parameter at all.
176//!
177//! `toml_edit` additionally enforces **its own, separate recursion cap**
178//! while parsing -- empirically found (see this module's tests) to reject
179//! TOML text nested roughly 81 levels deep (inline tables), well below this
180//! crate's own 200-level `MAX_DEPTH`. This means a *read*-side test can
181//! only ever observe `toml_edit`'s own `ParseError` firing first, never
182//! this crate's `DocumentError` -- the depth-guard-reuse obligation is
183//! instead demonstrated the way `json.rs`/`yaml.rs` already do, by building
184//! an over-deep [`Value`] directly and confirming [`Doc::of`] rejects it
185//! (see `deeply_nested_document_write_reuses_doc_construction_depth_guard`).
186//!
187//! ## Writer output shape (architecture freedom, per issue #1)
188//!
189//! This module always emits nested tables and table-arrays as **inline**
190//! TOML (`{ k = v }` / `[ v, v ]`), never `[section]`/`[[section]]` headers.
191//! This is a deliberate divergence from `tomli_w`'s (and most hand-written
192//! TOML's) header-based style, chosen because it is unambiguous, needs no
193//! header-nesting state machine, and is fully spec-valid TOML -- the "one
194//! constraint" from issue #1 is observable *behavior* (what a round trip
195//! produces), not byte-for-byte resemblance to `tomli_w`'s pretty-printing
196//! choices, and inline tables/arrays parse back to an identical `Doc`
197//! either way.
198
199use crate::WriteError;
200use crate::document::{Doc, Value};
201use crate::error::{OmnistError, ParseError};
202use crate::formats::float_fmt;
203use crate::formats::int_cap::{MAX_INT_DIGITS, out_of_range_message, over_cap_message};
204use crate::formats::string_escape::{TOML_ESCAPES, write_quoted};
205use crate::formats::textpos::line_col_bytes;
206use crate::report::{Severity, WriteReport};
207use indexmap::IndexMap;
208use toml_edit::{Item, TableLike};
209
210// Same guard, same constant as `json.rs`'s/`yaml.rs`'s -- see this
211// module's doc comment. Constant and message constructors now live in
212// [`crate::formats::int_cap`] (issue #49).
213
214// ============================================================== Reader
215
216/// Parse TOML text into a [`Doc`].
217///
218/// TOML documents are always tables at the top level (there is no bare-
219/// scalar-document form in the grammar), so unlike `read_json`/`read_yaml`
220/// this never needs to special-case a non-object root on the way in.
221/// Nesting past [`crate::document::MAX_DEPTH`] surfaces as
222/// [`crate::error::DocumentError`] via [`Doc::of`], matching the other
223/// format readers.
224pub fn read_toml(text: &str) -> Result<Doc, OmnistError> {
225    let parsed: toml_edit::DocumentMut = text
226        .parse()
227        .map_err(|e: toml_edit::TomlError| toml_parse_error(text, &e))?;
228    let value = table_like_to_value(parsed.as_table())?;
229    Ok(Doc::of(&value)?)
230}
231
232/// Turns a `toml_edit` parse failure into this crate's [`ParseError`].
233/// Detects the crate's generic integer-overflow message specially (see
234/// this module's doc comment on the integer digit cap) and otherwise
235/// reports the crate's own message at the failure's line/column.
236fn toml_parse_error(text: &str, e: &toml_edit::TomlError) -> ParseError {
237    // `toml_edit::TomlError::span()` is documented as optional, but
238    // empirically (see this module's tests) every genuine parse failure --
239    // an empty/unquoted key, an unclosed array/string, a missing `=`, an
240    // integer overflow -- carries a real span; there is no reachable case
241    // from parsing text (as opposed to this crate's own mutation API,
242    // which this module never uses) that omits one.
243    let span = e
244        .span()
245        .expect("toml_edit's TomlError always carries a span for a genuine text-parse failure");
246    if e.message().contains("overflow") {
247        return toml_overflow_error(text, span);
248    }
249    let (line, col) = line_col_bytes(text, span.start);
250    ParseError::new(line, col, format!("invalid TOML: {}", e.message()))
251}
252
253/// Recovers the raw digit run from an integer literal `toml_edit` refused
254/// to parse (its own error discards the literal's text), and re-derives
255/// `json.rs`/`yaml.rs`'s exact two-tier message: over the 4300-digit cap
256/// gets the security-motivated cap message; under the cap (but still not
257/// representable in `i64`, `toml_edit`'s actual failure condition) gets the
258/// "out of range for a 64-bit integer" message -- see this module's doc
259/// comment for why this recovery is needed at all, and for why this
260/// applies uniformly across radixes even though Python's own tomllib
261/// leaves hex/octal/binary literals uncapped (a disclosed divergence,
262/// not a parity claim).
263fn toml_overflow_error(text: &str, span: std::ops::Range<usize>) -> ParseError {
264    let (line, col) = line_col_bytes(text, span.start);
265    let raw = &text[span];
266    let digits: String = raw.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
267    let digit_count = digits
268        .trim_start_matches("0x")
269        .trim_start_matches("0X")
270        .len();
271    if digit_count > MAX_INT_DIGITS {
272        return ParseError::new(line, col, over_cap_message("invalid TOML: ", digit_count));
273    }
274    ParseError::new(line, col, out_of_range_message("invalid TOML: ", raw))
275}
276
277/// Converts a `toml_edit` table (top-level document or inline table) into a
278/// [`Value::Object`], recursing into every entry.
279fn table_like_to_value(t: &dyn TableLike) -> Result<Value, ParseError> {
280    let mut map = IndexMap::new();
281    for (k, item) in t.iter() {
282        map.insert(k.to_string(), item_to_value(item)?);
283    }
284    Ok(Value::Object(map))
285}
286
287fn item_to_value(item: &Item) -> Result<Value, ParseError> {
288    match item {
289        // `Item::None` is only ever produced by `toml_edit`'s *mutation*
290        // API (`Entry`/`Index::or_insert(Item::None)`, confirmed by reading
291        // the crate's source) -- never by parsing text, which is the only
292        // way `read_toml` ever constructs an `Item`. White-box-tested
293        // directly (see this module's tests) rather than left an
294        // unreachable branch with no proof.
295        Item::None => unreachable!(
296            "Item::None is only produced by toml_edit's mutation API, never by parsing text"
297        ),
298        Item::Value(v) => toml_value_to_value(v),
299        Item::Table(t) => table_like_to_value(t),
300        Item::ArrayOfTables(arr) => {
301            let mut out = Vec::with_capacity(arr.len());
302            for t in arr.iter() {
303                out.push(table_like_to_value(t)?);
304            }
305            Ok(Value::Array(out))
306        }
307    }
308}
309
310fn toml_value_to_value(v: &toml_edit::Value) -> Result<Value, ParseError> {
311    match v {
312        toml_edit::Value::String(s) => Ok(Value::Str(s.value().clone())),
313        // `toml_edit`'s own `Integer` is `i64`-backed (the TOML 1.0 format
314        // spec itself specifies 64-bit signed integers) -- a >19-digit
315        // literal in TOML *source text* is rejected by `toml_edit`'s own
316        // parser before this function ever runs, a genuine external
317        // format-level constraint distinct from omnist's own Scalar
318        // representation (issue #104; see docs/formats/toml.md).
319        toml_edit::Value::Integer(i) => Ok(Value::Int((*i.value()).into())),
320        toml_edit::Value::Float(f) => Ok(Value::Float(*f.value())),
321        toml_edit::Value::Boolean(b) => Ok(Value::Bool(*b.value())),
322        // `toml_edit` already validates and types TOML's four native
323        // temporal forms itself (calendar/clock fields, `2024-02-30`
324        // etc., are rejected in its own parser) -- `dt.value()`'s
325        // `date`/`time` presence tells us exactly which of the three
326        // kinds this is, real provenance rather than a shape guess
327        // (issue #105; previously collapsed straight to `Value::Str`,
328        // discarding real type information `toml_edit` had already
329        // computed).
330        toml_edit::Value::Datetime(dt) => {
331            let canonical = format_datetime(dt.value());
332            let inner = dt.value();
333            Ok(match (inner.date.is_some(), inner.time.is_some()) {
334                (true, true) => Value::Datetime(canonical),
335                (true, false) => Value::Date(canonical),
336                (false, true) => Value::Time(canonical),
337                // `toml_edit`'s own grammar always sets at least one of
338                // `date`/`time` -- a defensive, non-panicking fallback
339                // rather than `unreachable!()`, since this is an
340                // assumption about a dependency's invariant, not this
341                // crate's own.
342                (false, false) => Value::Str(canonical),
343            })
344        }
345        toml_edit::Value::Array(arr) => {
346            let mut out = Vec::with_capacity(arr.len());
347            for item in arr.iter() {
348                out.push(toml_value_to_value(item)?);
349            }
350            Ok(Value::Array(out))
351        }
352        toml_edit::Value::InlineTable(it) => table_like_to_value(it),
353    }
354}
355
356/// Canonicalizes a parsed `toml_edit` [`toml_edit::Datetime`] into this
357/// port's ISO-string spelling -- see this module's doc comment on native
358/// temporal types.
359fn format_datetime(dt: &toml_edit::Datetime) -> String {
360    let mut out = String::new();
361    if let Some(d) = &dt.date {
362        out.push_str(&format!("{:04}-{:02}-{:02}", d.year, d.month, d.day));
363    }
364    if let Some(t) = &dt.time {
365        if dt.date.is_some() {
366            out.push('T');
367        }
368        out.push_str(&format!(
369            "{:02}:{:02}:{:02}",
370            t.hour,
371            t.minute,
372            t.second.unwrap_or(0)
373        ));
374        if let Some(ns) = t.nanosecond {
375            let micros = ns / 1000;
376            if micros > 0 {
377                out.push('.');
378                out.push_str(&format!("{micros:06}"));
379            }
380        }
381    }
382    if let Some(off) = &dt.offset {
383        match off {
384            toml_edit::Offset::Z => out.push_str("+00:00"),
385            toml_edit::Offset::Custom { minutes } => {
386                let sign = if *minutes < 0 { '-' } else { '+' };
387                let m = minutes.unsigned_abs();
388                out.push_str(&format!("{sign}{:02}:{:02}", m / 60, m % 60));
389            }
390        }
391    }
392    out
393}
394
395// ============================================================== Writer
396
397/// Project a [`Doc`] to TOML text. See this module's doc comment for the
398/// null-adjustment, integer-cap, temporal, and output-shape decisions.
399pub fn write_toml(
400    doc: &Doc,
401    strict: bool,
402    report: Option<&mut WriteReport>,
403) -> Result<String, WriteError> {
404    let mut rep = WriteReport::new();
405    add_interleaving_diagnostic(doc, &mut rep);
406    let grouped = doc.to_grouped();
407    let stripped = strip_nulls(grouped, "$")?;
408    let Value::Object(map) = &stripped else {
409        return Err(WriteError::new(
410            "TOML needs a top-level table (the root must be an object)",
411        ));
412    };
413    let mut out = String::new();
414    write_table_body(map, &mut out);
415    crate::report::finish_write(out, rep, strict, report)
416}
417
418/// `format.interleaving-lost` (spec Sec8.3.8, D-3) is a whole-document
419/// diagnostic that depends on the original `Doc`'s edge order, lost by the
420/// time `to_grouped` runs -- so it is detected separately via
421/// `Doc::has_interleaving_loss` rather than folded into the grouped-`Value`
422/// scanners below.
423fn add_interleaving_diagnostic(doc: &Doc, rep: &mut WriteReport) {
424    if doc.has_interleaving_loss() {
425        rep.add(
426            "$",
427            "format.interleaving-lost",
428            "cross-label interleaving could not be written; same-label edges were grouped",
429            Severity::Warning,
430        );
431    }
432}
433
434/// Report what writing TOML would adjust, without producing output.
435pub fn check_toml(doc: &Doc) -> WriteReport {
436    let mut rep = WriteReport::new();
437    add_interleaving_diagnostic(doc, &mut rep);
438    let grouped = doc.to_grouped();
439    check_toml_grouped(&grouped, "$", &mut rep);
440    rep
441}
442
443fn check_toml_grouped(node: &Value, path: &str, rep: &mut WriteReport) {
444    match node {
445        Value::Object(map) => {
446            for (label, child) in map {
447                match child {
448                    Value::Null => {
449                        rep.add(
450                            crate::report::child_path(path, label, 0),
451                            "write.unsupported-value",
452                            "null value has no TOML representation (TOML has no null token)",
453                            Severity::Error,
454                        );
455                    }
456                    Value::Array(items) => {
457                        for (i, item) in items.iter().enumerate() {
458                            let p = crate::report::child_path(path, label, i);
459                            if matches!(item, Value::Null) {
460                                rep.add(
461                                    p,
462                                    "write.unsupported-value",
463                                    "null value has no TOML representation (TOML has no null token)",
464                                    Severity::Error,
465                                );
466                            } else {
467                                check_toml_grouped(item, &p, rep);
468                            }
469                        }
470                    }
471                    other => {
472                        let p = crate::report::child_path(path, label, 0);
473                        check_toml_grouped(other, &p, rep);
474                    }
475                }
476            }
477        }
478        Value::Array(items) => {
479            for (i, item) in items.iter().enumerate() {
480                let p = crate::report::child_path(path, "", i);
481                if matches!(item, Value::Null) {
482                    rep.add(
483                        p,
484                        "write.unsupported-value",
485                        "null value has no TOML representation (TOML has no null token)",
486                        Severity::Error,
487                    );
488                } else {
489                    check_toml_grouped(item, &p, rep);
490                }
491            }
492        }
493        _ => {}
494    }
495}
496
497/// Marker type implementing [`crate::formats::Codec`] for TOML -- adapts
498/// [`read_toml`]/[`write_toml`]/[`check_toml`] to the registry's uniform
499/// shape with the documented defaults (`strict: false`, no report). The
500/// root-shape error `write_toml` raises for a non-object root fires from
501/// inside `write_toml` itself, outside `finish_write`, exactly as before --
502/// this impl only calls `write_toml`, it doesn't reimplement it.
503pub(crate) struct Toml;
504
505impl crate::formats::Codec for Toml {
506    const NAME: &'static str = "toml";
507
508    fn read(text: &str) -> Result<Doc, OmnistError> {
509        read_toml(text)
510    }
511
512    fn write(doc: &Doc) -> Result<String, OmnistError> {
513        write_toml(doc, false, None).map_err(Into::into)
514    }
515
516    fn check(doc: &Doc) -> WriteReport {
517        check_toml(doc)
518    }
519}
520
521/// Fails the write unconditionally (`write.unsupported-value`, spec
522/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) the moment a null-valued field or
523/// array item is found -- TOML has no null token at all, and the old
524/// "drop the edge and warn" behavior erased the edge's existence entirely
525/// with no trace on read-back (confirmed live, arguably a sharper case of
526/// the same collision problem as the XML label-sanitization fix). This
527/// used to be named `strip_nulls` and mirror Python's `_strip_nulls`
528/// null-dropping path-numbering; renamed since it no longer drops
529/// anything -- it now returns the first null path it finds as an error.
530fn strip_nulls(node: Value, path: &str) -> Result<Value, WriteError> {
531    match node {
532        Value::Object(map) => {
533            let mut out = IndexMap::new();
534            for (label, child) in map {
535                match child {
536                    Value::Null => {
537                        let p = crate::report::child_path(path, &label, 0);
538                        return Err(crate::report::unsupported_value_error(
539                            &p,
540                            "null value has no TOML representation (TOML has no null token)",
541                        ));
542                    }
543                    Value::Array(items) => {
544                        let mut kept = Vec::with_capacity(items.len());
545                        for (i, item) in items.into_iter().enumerate() {
546                            let p = crate::report::child_path(path, &label, i);
547                            if matches!(item, Value::Null) {
548                                return Err(crate::report::unsupported_value_error(
549                                    &p,
550                                    "null value has no TOML representation (TOML has no null                                      token)",
551                                ));
552                            }
553                            kept.push(strip_nulls(item, &p)?);
554                        }
555                        out.insert(label, Value::Array(kept));
556                    }
557                    other => {
558                        let p = crate::report::child_path(path, &label, 0);
559                        out.insert(label, strip_nulls(other, &p)?);
560                    }
561                }
562            }
563            Ok(Value::Object(out))
564        }
565        other => Ok(other),
566    }
567}
568
569fn write_table_body(map: &IndexMap<String, Value>, out: &mut String) {
570    for (k, v) in map {
571        write_key(k, out);
572        out.push_str(" = ");
573        write_inline_value(v, out);
574        out.push('\n');
575    }
576}
577
578fn write_inline_value(v: &Value, out: &mut String) {
579    match v {
580        Value::Object(map) => {
581            if map.is_empty() {
582                out.push_str("{}");
583                return;
584            }
585            out.push_str("{ ");
586            let mut first = true;
587            for (k, child) in map {
588                if !first {
589                    out.push_str(", ");
590                }
591                first = false;
592                write_key(k, out);
593                out.push_str(" = ");
594                write_inline_value(child, out);
595            }
596            out.push_str(" }");
597        }
598        Value::Array(items) => {
599            if items.is_empty() {
600                out.push_str("[]");
601                return;
602            }
603            out.push('[');
604            let mut first = true;
605            for item in items {
606                if !first {
607                    out.push_str(", ");
608                }
609                first = false;
610                write_inline_value(item, out);
611            }
612            out.push(']');
613        }
614        scalar => write_scalar(scalar, out),
615    }
616}
617
618fn write_scalar(v: &Value, out: &mut String) {
619    match v {
620        Value::Null => unreachable!("null values are stripped before writing (strip_nulls)"),
621        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
622        Value::Int(i) => out.push_str(&i.to_string()),
623        Value::Float(x) => write_float(*x, out),
624        // Always quoted -- no shape-guessing (issue #105, the same fix
625        // issue #99 already applied to OML). A genuinely temporal-kinded
626        // value is a `Date`/`Time`/`Datetime` variant, not a
627        // shape-matched `Str`; see the arms below.
628        Value::Str(s) => write_toml_string(s, out),
629        Value::Date(s) | Value::Datetime(s) => out.push_str(s),
630        // TOML has no "local time with an offset" literal form (only
631        // *date*time can carry an offset) -- a `Time` value that happens
632        // to carry one (YAML's own `TIME_RE` allows it, see issue #99's
633        // `bare_time_literal_with_utc_offset_reads_as_a_genuine_temporal_leaf`)
634        // has no native TOML spelling, so it's written as a quoted
635        // string instead, the same fallback this module already used
636        // before real provenance existed -- see `has_offset`.
637        Value::Time(s) => {
638            if has_offset(s) {
639                write_toml_string(s, out);
640            } else {
641                out.push_str(s);
642            }
643        }
644        Value::Object(_) | Value::Array(_) => {
645            unreachable!("write_scalar is only ever called on a leaf")
646        }
647    }
648}
649
650/// See `formats::float_fmt` (issue #47) for the shared render-then-inspect
651/// core (issue #46's fix); this is just TOML's spelling table.
652fn write_float(x: f64, out: &mut String) {
653    float_fmt::write_float(x, "nan", "inf", "-inf", out);
654}
655
656/// Whether an [`is_iso_time`]-shaped string also carries a `+HH:MM`/
657/// `-HH:MM` offset -- a bare TOML local-time literal has no offset, so a
658/// string shaped like "time with an offset" (an unusual value that isn't a
659/// real TOML literal at all) is written as a quoted string instead.
660fn has_offset(s: &str) -> bool {
661    s.contains('+') || s.contains('-')
662}
663
664fn write_key(k: &str, out: &mut String) {
665    if is_bare_key(k) {
666        out.push_str(k);
667    } else {
668        write_toml_string(k, out);
669    }
670}
671
672fn is_bare_key(k: &str) -> bool {
673    !k.is_empty()
674        && k.chars()
675            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
676}
677
678fn write_toml_string(s: &str, out: &mut String) {
679    write_quoted(s, &TOML_ESCAPES, out);
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::document::Scalar;
686
687    fn doc_of(v: Value) -> Doc {
688        Doc::of(&v).unwrap()
689    }
690
691    fn obj(pairs: Vec<(&str, Value)>) -> Value {
692        Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
693    }
694
695    #[test]
696    fn check_toml_reports_null_omitted_across_objects_and_arrays() {
697        let doc = doc_of(obj(vec![
698            ("null_field", Value::Null),
699            (
700                "nested",
701                obj(vec![
702                    ("inner_null", Value::Null),
703                    ("valid", Value::Int(1.into())),
704                ]),
705            ),
706            (
707                "arr",
708                Value::Array(vec![
709                    Value::Null,
710                    Value::Int(2.into()),
711                    obj(vec![("arr_inner_null", Value::Null)]),
712                ]),
713            ),
714        ]));
715        let rep = check_toml(&doc);
716        assert_eq!(rep.adjustments().len(), 4);
717        assert_eq!(rep.adjustments()[0].path, "$.null_field");
718        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
719    }
720
721    #[test]
722    fn check_toml_grouped_handles_array_root() {
723        let arr = Value::Array(vec![Value::Null, Value::Int(42.into())]);
724        let mut rep = WriteReport::new();
725        check_toml_grouped(&arr, "$", &mut rep);
726        assert_eq!(rep.adjustments().len(), 1);
727        assert_eq!(rep.adjustments()[0].path, "$.");
728    }
729
730    #[test]
731    fn toml_value_to_value_defensive_fallback_on_a_bare_offset_datetime() {
732        // `toml_edit`'s own grammar never emits a `Datetime` with neither
733        // `date` nor `time` set through real TOML source text, but its
734        // `Datetime` struct's fields are public, so this hand-built case
735        // is directly constructible -- real, tested coverage of the
736        // defensive `Value::Str` fallback (issue #105), not speculative
737        // dead code.
738        let dt = toml_edit::Datetime {
739            date: None,
740            time: None,
741            offset: None,
742        };
743        let v = toml_edit::Value::Datetime(toml_edit::Formatted::new(dt));
744        assert_eq!(toml_value_to_value(&v).unwrap(), Value::Str(String::new()));
745    }
746
747    // ---------------------------------------------------------- reader: scalars
748
749    #[test]
750    fn reads_every_native_scalar_kind() {
751        let doc = read_toml("a = 1\nb = \"s\"\nc = true\nd = 1.5\ne = false\n").unwrap();
752        let root = doc.root();
753        assert_eq!(
754            *root.get_one("a").unwrap().value().unwrap(),
755            Scalar::Int((1).into())
756        );
757        assert_eq!(
758            *root.get_one("b").unwrap().value().unwrap(),
759            Scalar::Str("s".to_string())
760        );
761        assert_eq!(
762            *root.get_one("c").unwrap().value().unwrap(),
763            Scalar::Bool(true)
764        );
765        assert_eq!(
766            *root.get_one("d").unwrap().value().unwrap(),
767            Scalar::Float(1.5)
768        );
769        assert_eq!(
770            *root.get_one("e").unwrap().value().unwrap(),
771            Scalar::Bool(false)
772        );
773    }
774
775    #[test]
776    fn reads_nested_tables_and_arrays() {
777        let doc = read_toml("arr = [1, 2, 3]\n[nested]\nx = 1\n").unwrap();
778        let root = doc.root();
779        let items: Vec<_> = root.get("arr");
780        assert_eq!(items.len(), 3);
781        let nested = root.get_one("nested").unwrap();
782        assert_eq!(
783            *nested.get_one("x").unwrap().value().unwrap(),
784            Scalar::Int((1).into())
785        );
786    }
787
788    #[test]
789    fn reads_array_of_tables() {
790        let doc = read_toml("[[items]]\nx = 1\n[[items]]\nx = 2\n").unwrap();
791        let root = doc.root();
792        let items: Vec<_> = root.get("items");
793        assert_eq!(items.len(), 2);
794        assert_eq!(
795            *items[0].get_one("x").unwrap().value().unwrap(),
796            Scalar::Int((1).into())
797        );
798        assert_eq!(
799            *items[1].get_one("x").unwrap().value().unwrap(),
800            Scalar::Int((2).into())
801        );
802    }
803
804    #[test]
805    fn invalid_toml_is_a_parse_error() {
806        let err = read_toml("a = ").unwrap_err();
807        assert!(matches!(err, OmnistError::Parse(_)));
808    }
809
810    // ------------------------------------------------------- temporal literals
811
812    #[test]
813    fn reads_local_date() {
814        let doc = read_toml("d = 1979-05-27\n").unwrap();
815        assert_eq!(
816            *doc.root().get_one("d").unwrap().value().unwrap(),
817            Scalar::Date("1979-05-27".to_string())
818        );
819    }
820
821    #[test]
822    fn reads_local_time_with_fraction() {
823        let doc = read_toml("t = 00:32:00.999999\n").unwrap();
824        assert_eq!(
825            *doc.root().get_one("t").unwrap().value().unwrap(),
826            Scalar::Time("00:32:00.999999".to_string())
827        );
828    }
829
830    #[test]
831    fn reads_local_time_without_fraction() {
832        let doc = read_toml("t = 07:32:00\n").unwrap();
833        assert_eq!(
834            *doc.root().get_one("t").unwrap().value().unwrap(),
835            Scalar::Time("07:32:00".to_string())
836        );
837    }
838
839    #[test]
840    fn truncates_fraction_beyond_microseconds_matching_python() {
841        // Live-confirmed: tomllib truncates (not rounds) a 7-digit fraction
842        // to microseconds -- 00:32:00.9999999 -> time(0, 32, 0, 999999).
843        let doc = read_toml("t = 00:32:00.9999999\n").unwrap();
844        assert_eq!(
845            *doc.root().get_one("t").unwrap().value().unwrap(),
846            Scalar::Time("00:32:00.999999".to_string())
847        );
848    }
849
850    #[test]
851    fn sub_microsecond_fraction_truncates_to_no_fraction() {
852        // Live-confirmed: tomllib gives time(7, 32) (no fraction) for this.
853        let doc = read_toml("t = 07:32:00.000000001\n").unwrap();
854        assert_eq!(
855            *doc.root().get_one("t").unwrap().value().unwrap(),
856            Scalar::Time("07:32:00".to_string())
857        );
858    }
859
860    #[test]
861    fn reads_local_datetime() {
862        let doc = read_toml("dt = 1979-05-27T07:32:00\n").unwrap();
863        assert_eq!(
864            *doc.root().get_one("dt").unwrap().value().unwrap(),
865            Scalar::Datetime("1979-05-27T07:32:00".to_string())
866        );
867    }
868
869    #[test]
870    fn reads_offset_datetime_z_normalizes_to_numeric_offset() {
871        let doc = read_toml("dt = 1979-05-27T07:32:00Z\n").unwrap();
872        assert_eq!(
873            *doc.root().get_one("dt").unwrap().value().unwrap(),
874            Scalar::Datetime("1979-05-27T07:32:00+00:00".to_string())
875        );
876    }
877
878    #[test]
879    fn round_trips_offset_datetime_preserving_negative_offset() {
880        let doc = read_toml("dt = 1979-05-27T07:32:00-07:00\n").unwrap();
881        assert_eq!(
882            *doc.root().get_one("dt").unwrap().value().unwrap(),
883            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
884        );
885        let text = write_toml(&doc, false, None).unwrap();
886        assert_eq!(text, "dt = 1979-05-27T07:32:00-07:00\n");
887        let doc2 = read_toml(&text).unwrap();
888        assert_eq!(
889            *doc2.root().get_one("dt").unwrap().value().unwrap(),
890            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
891        );
892    }
893
894    #[test]
895    fn round_trips_offset_datetime_preserving_positive_offset_and_fraction() {
896        let src = "dt = 1979-05-27T07:32:00.999999+07:00\n";
897        let doc = read_toml(src).unwrap();
898        let text = write_toml(&doc, false, None).unwrap();
899        assert_eq!(text, src);
900    }
901
902    #[test]
903    fn space_separated_datetime_reads_as_t_joined_canonical_string() {
904        let doc = read_toml("dt = 1979-05-27 07:32:00-07:00\n").unwrap();
905        assert_eq!(
906            *doc.root().get_one("dt").unwrap().value().unwrap(),
907            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
908        );
909    }
910
911    // ------------------------------------------------------------ round trips
912
913    #[test]
914    fn round_trips_every_native_scalar_kind() {
915        let v = obj(vec![
916            ("a", Value::Int((42).into())),
917            ("b", Value::Str("hello".to_string())),
918            ("c", Value::Bool(true)),
919            ("d", Value::Float(1.5)),
920            ("e", Value::Bool(false)),
921        ]);
922        let doc = doc_of(v);
923        let text = write_toml(&doc, false, None).unwrap();
924        let doc2 = read_toml(&text).unwrap();
925        let root = doc2.root();
926        assert_eq!(
927            *root.get_one("a").unwrap().value().unwrap(),
928            Scalar::Int((42).into())
929        );
930        assert_eq!(
931            *root.get_one("b").unwrap().value().unwrap(),
932            Scalar::Str("hello".to_string())
933        );
934        assert_eq!(
935            *root.get_one("c").unwrap().value().unwrap(),
936            Scalar::Bool(true)
937        );
938        assert_eq!(
939            *root.get_one("d").unwrap().value().unwrap(),
940            Scalar::Float(1.5)
941        );
942        assert_eq!(
943            *root.get_one("e").unwrap().value().unwrap(),
944            Scalar::Bool(false)
945        );
946    }
947
948    #[test]
949    fn round_trips_integral_float_at_and_above_1e17_boundary_issue_46() {
950        // Regression test for issue #46 (see json.rs's twin test for the
951        // full explanation): an integral-valued float >= 1e17 used to
952        // render as a bare digit run and re-read as `Scalar::Int`.
953        for x in [1.0e17, 1.0e18, -1.23e17, 9.9e16_f64] {
954            let doc = doc_of(obj(vec![("a", Value::Float(x))]));
955            let text = write_toml(&doc, false, None).unwrap();
956            let back = read_toml(&text).unwrap();
957            assert_eq!(
958                *back.root().get_one("a").unwrap().value().unwrap(),
959                Scalar::Float(x),
960                "x={x} text={text}"
961            );
962        }
963    }
964
965    #[test]
966    fn round_trips_local_date_time_and_datetime() {
967        // Genuinely temporal-kinded values (issue #105) write as TOML's
968        // native literals and read back as the same real variant -- see
969        // `plain_string_that_looks_like_a_date_stays_quoted` below for the
970        // companion case (a plain string merely shaped like one of these).
971        let v = obj(vec![
972            ("d", Value::Date("1979-05-27".to_string())),
973            ("t", Value::Time("07:32:00".to_string())),
974            ("dt", Value::Datetime("1979-05-27T07:32:00".to_string())),
975        ]);
976        let doc = doc_of(v);
977        let text = write_toml(&doc, false, None).unwrap();
978        assert!(text.contains("d = 1979-05-27\n"));
979        assert!(text.contains("t = 07:32:00\n"));
980        assert!(text.contains("dt = 1979-05-27T07:32:00\n"));
981        let doc2 = read_toml(&text).unwrap();
982        let root = doc2.root();
983        assert_eq!(
984            *root.get_one("d").unwrap().value().unwrap(),
985            Scalar::Date("1979-05-27".to_string())
986        );
987        assert_eq!(
988            *root.get_one("t").unwrap().value().unwrap(),
989            Scalar::Time("07:32:00".to_string())
990        );
991        assert_eq!(
992            *root.get_one("dt").unwrap().value().unwrap(),
993            Scalar::Datetime("1979-05-27T07:32:00".to_string())
994        );
995    }
996
997    #[test]
998    fn a_genuine_time_value_carrying_an_offset_writes_as_a_quoted_string() {
999        // TOML has no "local time with an offset" literal (only *date*time
1000        // can carry one) -- a real `Value::Time` that happens to carry a
1001        // UTC offset (OML's own time grammar allows this) has no native
1002        // TOML spelling, so `write_scalar` falls back to a quoted string
1003        // (see `has_offset` and the `Value::Time` write arm).
1004        let v = obj(vec![("t", Value::Time("07:32:00+02:00".to_string()))]);
1005        let doc = doc_of(v);
1006        let text = write_toml(&doc, false, None).unwrap();
1007        assert!(text.contains("t = \"07:32:00+02:00\"\n"));
1008    }
1009
1010    #[test]
1011    fn round_trips_nested_table_and_array() {
1012        let v = obj(vec![
1013            (
1014                "nested",
1015                obj(vec![
1016                    ("x", Value::Int((1).into())),
1017                    ("y", Value::Str("z".to_string())),
1018                ]),
1019            ),
1020            (
1021                "arr",
1022                Value::Array(vec![
1023                    Value::Int((1).into()),
1024                    Value::Int((2).into()),
1025                    Value::Int((3).into()),
1026                ]),
1027            ),
1028        ]);
1029        let doc = doc_of(v);
1030        let text = write_toml(&doc, false, None).unwrap();
1031        let doc2 = read_toml(&text).unwrap();
1032        let root = doc2.root();
1033        let nested = root.get_one("nested").unwrap();
1034        assert_eq!(
1035            *nested.get_one("x").unwrap().value().unwrap(),
1036            Scalar::Int((1).into())
1037        );
1038        assert_eq!(
1039            *nested.get_one("y").unwrap().value().unwrap(),
1040            Scalar::Str("z".to_string())
1041        );
1042        assert_eq!(root.get("arr").len(), 3);
1043    }
1044
1045    #[test]
1046    fn round_trips_array_of_tables() {
1047        let v = obj(vec![(
1048            "items",
1049            Value::Array(vec![
1050                obj(vec![("x", Value::Int((1).into()))]),
1051                obj(vec![("x", Value::Int((2).into()))]),
1052            ]),
1053        )]);
1054        let doc = doc_of(v);
1055        let text = write_toml(&doc, false, None).unwrap();
1056        let doc2 = read_toml(&text).unwrap();
1057        let items: Vec<_> = doc2.root().get("items");
1058        assert_eq!(items.len(), 2);
1059        assert_eq!(
1060            *items[0].get_one("x").unwrap().value().unwrap(),
1061            Scalar::Int((1).into())
1062        );
1063        assert_eq!(
1064            *items[1].get_one("x").unwrap().value().unwrap(),
1065            Scalar::Int((2).into())
1066        );
1067    }
1068
1069    #[test]
1070    fn round_trips_nan_and_infinity_natively() {
1071        let v = obj(vec![
1072            ("a", Value::Float(f64::NAN)),
1073            ("b", Value::Float(f64::INFINITY)),
1074            ("c", Value::Float(f64::NEG_INFINITY)),
1075        ]);
1076        let doc = doc_of(v);
1077        let text = write_toml(&doc, false, None).unwrap();
1078        assert!(text.contains("a = nan\n"));
1079        assert!(text.contains("b = inf\n"));
1080        assert!(text.contains("c = -inf\n"));
1081        let doc2 = read_toml(&text).unwrap();
1082        let root = doc2.root();
1083        assert!(matches!(
1084            root.get_one("a").unwrap().value().unwrap(),
1085            Scalar::Float(x) if x.is_nan()
1086        ));
1087        assert_eq!(
1088            *root.get_one("b").unwrap().value().unwrap(),
1089            Scalar::Float(f64::INFINITY)
1090        );
1091        assert_eq!(
1092            *root.get_one("c").unwrap().value().unwrap(),
1093            Scalar::Float(f64::NEG_INFINITY)
1094        );
1095        // no adjustment needed -- TOML holds special floats natively.
1096        assert!(check_toml(&doc).is_empty());
1097    }
1098
1099    // ------------------------------------------------------------ null (write.unsupported-value)
1100
1101    // Was `lenient_write_drops_null_field_and_records_adjustment` before
1102    // spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24): a null-valued leaf now
1103    // fails the write unconditionally (`write.unsupported-value`) instead
1104    // of being dropped with a warning -- the drop erased the edge's
1105    // existence entirely with no trace on read-back. `strict` no longer
1106    // changes the outcome (this used to be the lenient case; the old
1107    // `strict_write_raises_on_null_even_though_severity_is_warning` test
1108    // below now asserts the identical failure for `strict: true`).
1109    #[test]
1110    fn write_fails_unconditionally_on_null_field_lenient() {
1111        let v = obj(vec![("a", Value::Int((1).into())), ("b", Value::Null)]);
1112        let doc = doc_of(v);
1113        let mut rep = WriteReport::new();
1114        let err = write_toml(&doc, false, Some(&mut rep)).unwrap_err();
1115        assert!(err.to_string().contains("write.unsupported-value"));
1116        assert!(err.to_string().contains("$.b"));
1117        assert!(rep.is_empty());
1118        assert!(err.report().is_none());
1119    }
1120
1121    // Was `lenient_write_drops_null_array_item_shifting_index`: a null
1122    // array item now fails the write instead of being dropped and
1123    // shifting later items down.
1124    #[test]
1125    fn write_fails_unconditionally_on_null_array_item() {
1126        let v = obj(vec![(
1127            "c",
1128            Value::Array(vec![
1129                Value::Int((1).into()),
1130                Value::Null,
1131                Value::Int((2).into()),
1132            ]),
1133        )]);
1134        let doc = doc_of(v);
1135        let err = write_toml(&doc, false, None).unwrap_err();
1136        assert!(err.to_string().contains("write.unsupported-value"));
1137        assert!(err.to_string().contains("$.c[1]"));
1138    }
1139
1140    #[test]
1141    fn null_in_nested_table_records_nested_path_in_check() {
1142        let v = obj(vec![(
1143            "nested",
1144            obj(vec![("x", Value::Null), ("y", Value::Int((5).into()))]),
1145        )]);
1146        let doc = doc_of(v);
1147        let rep = check_toml(&doc);
1148        assert_eq!(rep.len(), 1);
1149        assert_eq!(rep.adjustments()[0].path, "$.nested.x");
1150        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
1151    }
1152
1153    // Renamed from `strict_write_raises_on_null_even_though_severity_is_warning`:
1154    // `strict` no longer distinguishes anything here -- both modes fail
1155    // identically and unconditionally, and the error carries no report
1156    // (the unconditional-failure path returns before any report is built).
1157    #[test]
1158    fn write_fails_unconditionally_on_null_strict() {
1159        let v = obj(vec![("a", Value::Int((1).into())), ("b", Value::Null)]);
1160        let doc = doc_of(v);
1161        let err = write_toml(&doc, true, None).unwrap_err();
1162        assert!(err.to_string().contains("$.b"));
1163        assert!(err.to_string().contains("write.unsupported-value"));
1164        assert!(err.report().is_none());
1165    }
1166
1167    #[test]
1168    fn check_toml_reports_without_producing_output() {
1169        let v = obj(vec![("a", Value::Null)]);
1170        let doc = doc_of(v);
1171        let rep = check_toml(&doc);
1172        assert_eq!(rep.len(), 1);
1173        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
1174    }
1175
1176    // ------------------------------------------------------------ top-level shape
1177
1178    #[test]
1179    fn non_table_root_is_a_write_error() {
1180        let doc = doc_of(Value::Int((5).into()));
1181        let err = write_toml(&doc, false, None).unwrap_err();
1182        assert!(err.to_string().contains("top-level table"));
1183        assert!(err.report().is_none());
1184    }
1185
1186    #[test]
1187    fn non_table_root_is_a_write_error_even_with_a_report_supplied() {
1188        let doc = doc_of(Value::Int((5).into()));
1189        let mut rep = WriteReport::new();
1190        let err = write_toml(&doc, false, Some(&mut rep)).unwrap_err();
1191        assert!(err.to_string().contains("top-level table"));
1192        assert!(rep.is_empty());
1193    }
1194
1195    #[test]
1196    fn empty_object_root_writes_empty_text() {
1197        let doc = doc_of(Value::Object(IndexMap::new()));
1198        let text = write_toml(&doc, false, None).unwrap();
1199        assert_eq!(text, "");
1200    }
1201
1202    // ------------------------------------------------------------ integer cap
1203
1204    #[test]
1205    fn integer_at_4300_digits_reads_but_overflows_i64() {
1206        // Live-confirmed: tomllib itself accepts a 4300-digit literal (no
1207        // digit-cap error) but `toml_edit`'s own read-side integer type is
1208        // i64-backed (see this module's doc comment) -- so this port's
1209        // read still fails, just with the "out of range" message, not the
1210        // "digit cap exceeded" one, matching json.rs's own precedent for
1211        // the same representational gap. `Scalar::Int` itself is
1212        // arbitrary-precision (`BigInt`, issue #104) -- the ceiling here is
1213        // `toml_edit`'s, not this crate's own representation.
1214        let text = format!("x = {}\n", "9".repeat(4300));
1215        let err = read_toml(&text).unwrap_err();
1216        assert!(matches!(err, OmnistError::Parse(ref e) if e.message.contains("out of range")));
1217    }
1218
1219    #[test]
1220    fn huge_hex_literal_is_capped_unlike_pythons_uncapped_tomllib() {
1221        // Live-confirmed divergence: tomllib.loads("x = 0x" + "f"*5000)
1222        // parses successfully in Python with no error at all (CPython's
1223        // digit cap explicitly exempts power-of-two bases). This port does
1224        // not replicate that exemption -- toml_edit enforces a strict i64
1225        // range at parse time regardless of radix, so this hits the same
1226        // digit-cap recovery path as a decimal literal and is rejected. See
1227        // this module's doc comment for why that's a disclosed, deliberate
1228        // divergence rather than a parity claim.
1229        let text = format!("x = 0x{}\n", "f".repeat(5000));
1230        let err = read_toml(&text).unwrap_err();
1231        assert!(matches!(
1232            err,
1233            OmnistError::Parse(ref e) if e.message.contains("exceeding the 4300-digit limit")
1234        ));
1235    }
1236
1237    #[test]
1238    fn integer_over_4300_digits_is_the_digit_cap_error() {
1239        let text = format!("x = {}\n", "9".repeat(4301));
1240        let err = read_toml(&text).unwrap_err();
1241        assert!(matches!(
1242            err,
1243            OmnistError::Parse(ref e) if e.message.contains("exceeding the 4300-digit limit")
1244        ));
1245    }
1246
1247    #[test]
1248    fn integer_literal_under_digit_cap_but_over_i64_range_is_out_of_range_error() {
1249        // Live-confirmed: tomllib.loads("x = 9223372036854775808") parses
1250        // fine in Python (arbitrary-precision int, no error at all -- this
1251        // is well under the 4300-digit cap). `toml_edit`'s own read-side
1252        // integer type is i64-only (see this module's doc comment), so the
1253        // same literal is rejected here as out of range -- a disclosed
1254        // limit of the underlying `toml_edit` crate on the read path, not
1255        // this crate's own `Scalar::Int` (arbitrary-precision `BigInt`,
1256        // issue #104), and not parity with Python's real behavior, matching
1257        // json.rs's own precedent for the same representational gap.
1258        let text = "x = 9223372036854775808\n"; // i64::MAX + 1, 19 digits
1259        let err = read_toml(text).unwrap_err();
1260        assert!(matches!(
1261            err,
1262            OmnistError::Parse(ref e) if e.message.contains("out of range for a 64-bit integer")
1263        ));
1264    }
1265
1266    #[test]
1267    fn integer_at_i64_max_and_min_round_trip() {
1268        let text = format!("a = {}\nb = {}\n", i64::MAX, i64::MIN);
1269        let doc = read_toml(&text).unwrap();
1270        let root = doc.root();
1271        assert_eq!(
1272            *root.get_one("a").unwrap().value().unwrap(),
1273            Scalar::Int((i64::MAX).into())
1274        );
1275        assert_eq!(
1276            *root.get_one("b").unwrap().value().unwrap(),
1277            Scalar::Int((i64::MIN).into())
1278        );
1279    }
1280
1281    // ------------------------------------------------------------ depth guard
1282
1283    #[test]
1284    fn toml_edit_s_own_recursion_cap_fires_before_our_200_depth_guard_on_read() {
1285        // Empirically found (see this module's doc comment): toml_edit has
1286        // its own internal recursion cap around 80-81 levels of nested
1287        // inline tables -- well below this crate's own MAX_DEPTH (200) --
1288        // so deeply-nested TOML *text* trips the crate's own ParseError
1289        // long before our own DocumentError guard could ever see it. This
1290        // is a real, distinct protection layer from this crate's own depth
1291        // guard, not the same one -- see the next test for the guard this
1292        // module actually reuses.
1293        let mut text = String::from("x = ");
1294        for _ in 0..250 {
1295            text.push_str("{ a = ");
1296        }
1297        text.push('1');
1298        for _ in 0..250 {
1299            text.push_str(" }");
1300        }
1301        text.push('\n');
1302        let err = read_toml(&text).unwrap_err();
1303        assert!(matches!(err, OmnistError::Parse(_)));
1304    }
1305
1306    #[test]
1307    fn deeply_nested_document_write_reuses_doc_construction_depth_guard() {
1308        // Doc::of already rejects nesting past MAX_DEPTH at construction
1309        // time (see this module's doc comment) -- confirms write_toml/
1310        // check_toml never even see an over-deep Doc to begin with, exactly
1311        // json.rs's/yaml.rs's own precedent test for this same guard.
1312        let mut v = Value::Int((0).into());
1313        for _ in 0..=crate::document::MAX_DEPTH {
1314            v = obj(vec![("a", v)]);
1315        }
1316        assert!(Doc::of(&v).is_err());
1317    }
1318
1319    // ------------------------------------------------------------ keys
1320
1321    #[test]
1322    fn writes_quoted_key_for_non_bare_label() {
1323        let v = obj(vec![("has space", Value::Int((1).into()))]);
1324        let doc = doc_of(v);
1325        let text = write_toml(&doc, false, None).unwrap();
1326        assert_eq!(text, "\"has space\" = 1\n");
1327        let doc2 = read_toml(&text).unwrap();
1328        assert_eq!(
1329            *doc2.root().get_one("has space").unwrap().value().unwrap(),
1330            Scalar::Int((1).into())
1331        );
1332    }
1333
1334    #[test]
1335    fn string_with_control_char_and_quote_escapes_on_write() {
1336        let v = obj(vec![(
1337            "a",
1338            Value::Str("line\nbreak \"q\" \t tab".to_string()),
1339        )]);
1340        let doc = doc_of(v);
1341        let text = write_toml(&doc, false, None).unwrap();
1342        let doc2 = read_toml(&text).unwrap();
1343        assert_eq!(
1344            *doc2.root().get_one("a").unwrap().value().unwrap(),
1345            Scalar::Str("line\nbreak \"q\" \t tab".to_string())
1346        );
1347    }
1348
1349    #[test]
1350    fn time_shaped_string_with_offset_is_not_a_real_toml_time_and_stays_quoted() {
1351        // "07:32:00+01:00" matches is_iso_time's shape (offset is optional
1352        // in that regex) but isn't a real TOML local-time literal (which
1353        // has no offset) -- so this module writes it quoted, not as a
1354        // (invalid) bare local-time-with-offset token.
1355        let v = obj(vec![("a", Value::Str("07:32:00+01:00".to_string()))]);
1356        let doc = doc_of(v);
1357        let text = write_toml(&doc, false, None).unwrap();
1358        assert_eq!(text, "a = \"07:32:00+01:00\"\n");
1359    }
1360
1361    #[test]
1362    fn plain_string_that_looks_like_a_date_stays_quoted() {
1363        // Issue #105 (the same fix issue #99 already applied to OML): a
1364        // plain string that merely *looks* date-shaped must stay quoted
1365        // on write -- writing it bare would silently promote it to a
1366        // genuine TOML native date literal on the next read (a different
1367        // Document). Previously diverged from Python here (which always
1368        // kept it a quoted string, since Python's document model
1369        // distinguishes a real `datetime.date` from a `str` at runtime);
1370        // now matches.
1371        let v = obj(vec![("a", Value::Str("1979-05-27".to_string()))]);
1372        let doc = doc_of(v);
1373        let text = write_toml(&doc, false, None).unwrap();
1374        assert_eq!(text, "a = \"1979-05-27\"\n");
1375        let doc2 = read_toml(&text).unwrap();
1376        assert_eq!(
1377            *doc2.root().get_one("a").unwrap().value().unwrap(),
1378            Scalar::Str("1979-05-27".to_string())
1379        );
1380    }
1381
1382    #[test]
1383    fn float_integral_value_still_gets_a_decimal_point() {
1384        let v = obj(vec![("a", Value::Float(1.0))]);
1385        let doc = doc_of(v);
1386        let text = write_toml(&doc, false, None).unwrap();
1387        assert_eq!(text, "a = 1.0\n");
1388    }
1389
1390    #[test]
1391    fn float_non_integral_value_writes_default_repr() {
1392        let v = obj(vec![("a", Value::Float(1.25))]);
1393        let doc = doc_of(v);
1394        let text = write_toml(&doc, false, None).unwrap();
1395        assert_eq!(text, "a = 1.25\n");
1396    }
1397
1398    // ------------------------------------------------------- coverage: white-box
1399
1400    #[test]
1401    fn write_scalar_panics_on_null() {
1402        // `write_scalar` is only ever called on a leaf that has already
1403        // been through `strip_nulls` via the public `write_toml` path --
1404        // white-box confirming that documented invariant directly, same
1405        // rationale as yaml.rs's identical precedent test.
1406        let result = std::panic::catch_unwind(|| {
1407            let mut out = String::new();
1408            write_scalar(&Value::Null, &mut out);
1409        });
1410        assert!(result.is_err());
1411    }
1412
1413    #[test]
1414    fn write_scalar_panics_on_a_non_leaf_value() {
1415        let result = std::panic::catch_unwind(|| {
1416            let mut out = String::new();
1417            write_scalar(&Value::Object(IndexMap::new()), &mut out);
1418        });
1419        assert!(result.is_err());
1420    }
1421
1422    #[test]
1423    fn item_to_value_panics_on_item_none() {
1424        // Item::None is only produced by toml_edit's mutation API (see this
1425        // module's doc comment) -- read_toml never constructs one, so this
1426        // white-box-tests the documented invariant directly.
1427        let result = std::panic::catch_unwind(|| {
1428            let _ = item_to_value(&Item::None);
1429        });
1430        assert!(result.is_err());
1431    }
1432
1433    #[test]
1434    fn nested_empty_table_writes_as_inline_empty_table() {
1435        let v = obj(vec![("t", Value::Object(IndexMap::new()))]);
1436        let doc = doc_of(v);
1437        let text = write_toml(&doc, false, None).unwrap();
1438        assert_eq!(text, "t = {}\n");
1439    }
1440
1441    #[test]
1442    fn write_inline_value_on_a_bare_empty_array_writes_the_empty_token() {
1443        // A zero-item array can never come from a real Doc -- the Document
1444        // model represents "array" as repeated same-label edges, so an
1445        // empty array has *zero* edges and the field disappears entirely
1446        // at `Doc::of` construction time (never round-trips back as an
1447        // empty-array `Value`). White-box exercising `write_inline_value`'s
1448        // empty-array arm directly, same rationale and pattern as yaml.rs's
1449        // `write_node_on_a_bare_empty_array_writes_the_flow_empty_token`.
1450        let mut out = String::new();
1451        write_inline_value(&Value::Array(vec![]), &mut out);
1452        assert_eq!(out, "[]");
1453    }
1454
1455    #[test]
1456    fn line_col_reports_line_two_for_an_error_after_a_newline() {
1457        // Forces line_col's newline-counting branch and its `Some(i)`
1458        // column-offset arm, neither reachable from any single-line error.
1459        let err = read_toml("a = 1\nb = \n").unwrap_err();
1460        assert!(
1461            matches!(err, OmnistError::Parse(ref e) if e.line == 2),
1462            "got {err:?}"
1463        );
1464    }
1465
1466    #[test]
1467    fn write_toml_string_escapes_every_control_char_form() {
1468        let v = obj(vec![(
1469            "a",
1470            Value::Str("back\\slash cr\r back\u{08}space form\u{0c}feed ctl\u{01}".to_string()),
1471        )]);
1472        let doc = doc_of(v);
1473        let text = write_toml(&doc, false, None).unwrap();
1474        assert_eq!(
1475            text,
1476            "a = \"back\\\\slash cr\\r back\\bspace form\\ffeed ctl\\u0001\"\n"
1477        );
1478        let doc2 = read_toml(&text).unwrap();
1479        assert_eq!(
1480            *doc2.root().get_one("a").unwrap().value().unwrap(),
1481            Scalar::Str("back\\slash cr\r back\u{08}space form\u{0c}feed ctl\u{01}".to_string())
1482        );
1483    }
1484
1485    // ---------------------------------------------- D-3: format.interleaving-lost
1486    // (issue #156, spec Sec8.3.8. Same MUST as formats-json/basic/
1487    // cross-label-interleaving-lost-and-reported -- TOML shares JSON's
1488    // `to_grouped` grouping, so the loss and the report are identical
1489    // in shape; see json.rs's mirrored tests.)
1490
1491    fn interleaved_doc() -> Doc {
1492        Doc::from_raw(crate::document::RawNode::Edges(vec![
1493            (
1494                "m".to_string(),
1495                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
1496            ),
1497            (
1498                "x".to_string(),
1499                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
1500            ),
1501            (
1502                "m".to_string(),
1503                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
1504            ),
1505        ]))
1506        .unwrap()
1507    }
1508
1509    fn contiguous_repeat_doc() -> Doc {
1510        Doc::from_raw(crate::document::RawNode::Edges(vec![
1511            (
1512                "m".to_string(),
1513                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
1514            ),
1515            (
1516                "m".to_string(),
1517                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
1518            ),
1519            (
1520                "x".to_string(),
1521                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
1522            ),
1523        ]))
1524        .unwrap()
1525    }
1526
1527    #[test]
1528    fn reports_interleaving_lost_on_write() {
1529        let doc = interleaved_doc();
1530        let mut report = crate::report::WriteReport::new();
1531        write_toml(&doc, false, Some(&mut report)).unwrap();
1532        let adjustments = report.adjustments();
1533        assert_eq!(adjustments.len(), 1);
1534        assert_eq!(adjustments[0].path, "$");
1535        assert_eq!(adjustments[0].code, "format.interleaving-lost");
1536        assert_eq!(adjustments[0].severity, crate::report::Severity::Warning);
1537    }
1538
1539    #[test]
1540    fn check_toml_reports_interleaving_lost() {
1541        let rep = check_toml(&interleaved_doc());
1542        assert_eq!(rep.adjustments().len(), 1);
1543        assert_eq!(rep.adjustments()[0].code, "format.interleaving-lost");
1544    }
1545
1546    #[test]
1547    fn contiguous_repeated_label_does_not_report_interleaving_lost() {
1548        let doc = contiguous_repeat_doc();
1549        let mut report = crate::report::WriteReport::new();
1550        write_toml(&doc, false, Some(&mut report)).unwrap();
1551        assert!(report.is_empty());
1552        assert!(check_toml(&doc).is_empty());
1553    }
1554}