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//! * **Big integers and overflowing floats** -- `toml_edit` holds an
28//!   integer in 64 bits; this module reads the literals it refuses itself
29//!   (see "Integers beyond 64 bits" below).
30//! * **Depth guard, shape-check reuse** -- see their own sections below.
31//!
32//! ## No `null` (the one unrepresentable-value case TOML has)
33//!
34//! Spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24): writing a node containing a
35//! null-valued field now fails the write **unconditionally**
36//! (`write.unsupported-value`, via [`crate::report::unsupported_value_error`])
37//! regardless of `strict`, rather than the old "drop the field and record a
38//! warning" behavior. TOML has no null token at all -- dropping the edge
39//! didn't just alter what's represented at that position, it erased the
40//! edge's existence entirely, with zero trace on read-back that a labeled
41//! edge with that path was ever there (confirmed live; retired the
42//! `null.omitted` code -- see `strip_nulls`'s doc comment). `check_toml`
43//! still reports the same condition as a `write.unsupported-value`
44//! `Severity::Error` adjustment for preview purposes, since it never
45//! produces output to begin with.
46//!
47//! If stripping nulls leaves a document whose root isn't a table (object),
48//! [`write_toml`] raises `WriteError` unconditionally -- **not** part of
49//! the report, mirrors Python's `write_toml` raising
50//! `WriteError("TOML needs a top-level table (the root must be an
51//! object)")` outside of `finish_write` entirely (so it fires even when
52//! `report` is supplied and even though the message never enters the
53//! accumulated `WriteReport`).
54//!
55//! ## Integers beyond 64 bits (D-2, D-28) and overflowing floats (D-29)
56//!
57//! A TOML integer is arbitrary precision here, in every radix, and the digit
58//! limit counts the decimal digits of its value. `toml_edit` holds an
59//! integer in `i64` and refuses a longer literal with an "overflow" error
60//! that carries a byte span but no value. [`read_toml`] therefore reads such
61//! a literal itself (`recover_number`): sign, radix prefix, underscores and
62//! all. It replaces the literal in a copy of the text by a stand-in of the
63//! same width (`0`, or `0.0` for a float), parses again, and swaps the real
64//! value back in by byte offset when the `toml_edit` tree is converted. The
65//! copy keeps every other offset in place, so a syntax error anywhere in the
66//! text is still reported at its own position and still wins over a limit.
67//!
68//! A decimal literal already longer than the limit in its own spelling is not
69//! converted to a number: the shared [`IntGuard`] placeholder (`10^max`) goes
70//! in instead, so the Document constructor refuses it with
71//! `document.limit.int-digits` at the right path without the reader paying
72//! the superlinear digit-string conversion. Every other literal is converted
73//! exactly and checked by value (so `0xFFF` is four digits, 4095).
74//!
75//! A float literal that overflows `f64` (`1e999`) is refused by `toml_edit`
76//! the same way; it reads as an infinity (`str::parse::<f64>` gives one).
77//! This is a disclosed difference from Python only in that Python's
78//! `tomllib` exempts hex/octal/binary literals from CPython's 4,300-digit
79//! string-conversion guard, while this port applies D-28 to every radix.
80//!
81//! ## Native temporal types (the opposite direction from JSON's problem)
82//!
83//! Unlike JSON (no temporal type at all) and like YAML (a native but looser
84//! timestamp grammar), TOML has **four** first-class temporal literal forms
85//! (local date, local time, local datetime, offset datetime) that are
86//! *stricter*-shaped than YAML's -- `toml_edit`'s own parser already fully
87//! validates calendar/clock fields (leap years, per-month day counts, valid
88//! hour/minute/offset ranges: live-confirmed `2024-02-30`, `2024-13-01`,
89//! `25:00:00`, `00:60:00`, and a `+25:00` offset are all **parse
90//! errors**, not accepted-then-rejected-later), so [`read_toml`] does not
91//! need to re-validate calendar/clock fields the way `yaml.rs`'s
92//! `normalize_timestamp` must (YAML's own crate does no such validation).
93//!
94//! [`crate::document::Scalar`] has real `Date`/`Time`/`Datetime` variants
95//! (issue #105) -- `toml_value_to_value` reads `toml_edit`'s own already
96//! fully-validated `Datetime` struct's `date`/`time` presence to construct
97//! the right one directly (real provenance, not a shape guess), and
98//! `format_datetime` renders the canonical ISO spelling either way:
99//! zero-padded, `T`-joined, a bare `Z` offset normalized to `+00:00`
100//! (matching `yaml.rs`'s identical normalization -- this port's canonical
101//! temporal strings never contain a literal `Z`, only a numeric offset,
102//! which is what `crate::schema::is_iso_datetime`'s regex expects).
103//! Fractional seconds beyond microsecond precision are **truncated, not
104//! rounded**, to six digits -- live-confirmed against `tomllib`:
105//! `00:32:00.9999999` (7 nines) reads as `datetime.time(0, 32, 0, 999999)`
106//! (truncated, not rounded to `1000000` and carried), matching this
107//! module's `nanosecond / 1000` integer-truncating conversion exactly.
108//!
109//! **UTC-offset preservation** (the omnist-ts#51-pattern check this issue
110//! calls for): an offset datetime's numeric offset is preserved exactly in
111//! the canonical string (`-07:00` stays `-07:00`across read+write), so this
112//! module does not repeat the OML writer's offset-erasure bug -- confirmed
113//! by this module's `round_trips_offset_datetime_preserving_negative_offset`
114//! and `_positive_offset` tests.
115//!
116//! On the **write** side, a genuine `Scalar::Date`/`Datetime` writes as a
117//! *native* TOML temporal literal (unquoted) unconditionally -- no
118//! shape-check, since the variant itself is the provenance signal (issue
119//! #105, the same fix issue #99 already applied to OML). An ordinary
120//! `Scalar::Str` **always writes quoted**, however date-shaped its text --
121//! this now matches Python's `write_toml` exactly (previously diverged:
122//! Python's document model retains a real `datetime.date`/`time`/
123//! `datetime` object end-to-end, so a plain `str` that merely looks like a
124//! date -- live-confirmed: `tomli_w.dumps({'a': '1979-05-27'})` -- always
125//! wrote as the quoted string `a = "1979-05-27"`, never a native literal;
126//! this port's pre-#105 `Scalar` had no way to make that distinction, so
127//! it wrote bare unconditionally whenever the text merely *looked*
128//! temporal-shaped -- a real bug, confirmed live and fixed, not a
129//! permitted variation). A `Scalar::Time` carrying a UTC offset is the one
130//! remaining case with no native TOML spelling at all (TOML's *local
131//! time* literal has no offset field) -- see `write_scalar`'s own
132//! `has_offset` fallback.
133//!
134//! ## Depth guard reuse
135//!
136//! [`read_toml`] parses TOML text into a [`crate::document::Value`], then
137//! builds a [`Doc`] via [`Doc::of`] -- which calls
138//! `crate::document::check_write_depth` internally (see `document.rs`).
139//! [`write_toml`]/[`check_toml`]/`strip_nulls` walk an already-built
140//! `Doc` (via `Doc::to_grouped`), whose every node was depth-checked at
141//! construction time -- exactly `json.rs`'s reasoning (not `yaml.rs`'s,
142//! which pre-processes raw structures *before* `Doc::of` ever runs) --
143//! there is nothing left to re-guard on the way out, so `strip_nulls` does
144//! not take or check a depth parameter at all.
145//!
146//! `toml_edit` additionally enforces **its own, separate recursion cap**
147//! while parsing -- empirically found (see this module's tests) to reject
148//! TOML text nested roughly 81 levels deep (inline tables), well below this
149//! crate's own 200-level `MAX_DEPTH`. This means a *read*-side test can
150//! only ever observe `toml_edit`'s own `ParseError` firing first, never
151//! this crate's `DocumentError` -- the depth-guard-reuse obligation is
152//! instead demonstrated the way `json.rs`/`yaml.rs` already do, by building
153//! an over-deep [`Value`] directly and confirming [`Doc::of`] rejects it
154//! (see `deeply_nested_document_write_reuses_doc_construction_depth_guard`).
155//!
156//! ## Writer output shape (architecture freedom, per issue #1)
157//!
158//! This module always emits nested tables and table-arrays as **inline**
159//! TOML (`{ k = v }` / `[ v, v ]`), never `[section]`/`[[section]]` headers.
160//! This is a deliberate divergence from `tomli_w`'s (and most hand-written
161//! TOML's) header-based style, chosen because it is unambiguous, needs no
162//! header-nesting state machine, and is fully spec-valid TOML -- the "one
163//! constraint" from issue #1 is observable *behavior* (what a round trip
164//! produces), not byte-for-byte resemblance to `tomli_w`'s pretty-printing
165//! choices, and inline tables/arrays parse back to an identical `Doc`
166//! either way.
167
168use crate::WriteError;
169use crate::document::{Doc, Value};
170use crate::error::DocumentError;
171use crate::error::{OmnistError, ParseError};
172use crate::formats::float_fmt;
173use crate::formats::string_escape::{TOML_ESCAPES, write_quoted};
174use crate::formats::textpos::line_col_bytes;
175use crate::limits::{IntGuard, Limits, Resolved};
176use crate::report::{Severity, WriteReport};
177use indexmap::IndexMap;
178use num_bigint::BigInt;
179use std::collections::HashMap;
180use toml_edit::{Item, TableLike};
181
182// Same guard, same constant as `json.rs`'s/`yaml.rs`'s -- see this
183// module's doc comment. Constant and message constructors now live in
184// [`crate::formats::int_cap`] (issue #49).
185
186// ============================================================== Reader
187
188/// Parse TOML text into a [`Doc`].
189///
190/// TOML documents are always tables at the top level (there is no bare-
191/// scalar-document form in the grammar), so unlike `read_json`/`read_yaml`
192/// this never needs to special-case a non-object root on the way in.
193/// Nesting past [`crate::document::MAX_DEPTH`] surfaces as
194/// [`crate::error::DocumentError`] via [`Doc::of`], matching the other
195/// format readers.
196pub fn read_toml(text: &str) -> Result<Doc, OmnistError> {
197    read_toml_resolved(text, Resolved::DEFAULT)
198}
199
200/// [`read_toml`] under explicit [`Limits`].
201///
202/// Returns the [`Limits::validate`] error unchanged when the limits are out
203/// of range. A limit crossed is a [`crate::error::DocumentError`]
204/// (`document.limit.depth` / `document.limit.nodes` at `$`,
205/// `document.limit.int-digits` at the path of the over-long integer), never
206/// a [`ParseError`].
207///
208/// TOML text is parsed by `toml_edit`, which enforces a recursion cap of its
209/// own (about 80 levels of inline-table nesting) and holds every integer in
210/// 64 bits, so a `max_depth` above that cap, or a `max_int_digits` above 19,
211/// does not make this reader accept anything `toml_edit` refuses.
212pub fn read_toml_with(text: &str, limits: &Limits) -> Result<Doc, OmnistError> {
213    read_toml_resolved(text, limits.resolve()?)
214}
215
216fn read_toml_resolved(text: &str, limits: Resolved) -> Result<Doc, OmnistError> {
217    // D-23: first, on the input as received (BOM counted).
218    limits.check_input_size(text.len())?;
219    // D-15/D-21: one leading BOM is stripped, a second is rejected at 1:1.
220    // toml_edit would reject the second on its own grammar; the pre-check
221    // makes that uniform and puts the position/code where the spec says.
222    let text = crate::bom::strip_leading_bom(text)
223        .map_err(|_| ParseError::codec_syntax(1, 1, crate::bom::DOUBLED_BOM_MESSAGE))?;
224    let mut guard = IntGuard::new(limits.max_int_digits);
225    let mut lexemes: HashMap<usize, Value> = HashMap::new();
226    let mut patched = text.to_string();
227    // `toml_edit` holds an integer in 64 bits and refuses a number beyond that
228    // (or a float that overflows), with no value to read. Each such literal is
229    // read here instead (D-2, D-28, D-29), all in one scan, and stood in for by
230    // a small one of the same width, so every other offset keeps its place; the
231    // real value is swapped back in by offset when the tree is converted. One
232    // scan, not one reparse per literal, keeps a file full of them linear.
233    for (start, end) in prescan_numbers(text) {
234        if let Some(value) = number_value(&text[start..end], &mut guard) {
235            blank(&mut patched, start, end, &value);
236            lexemes.insert(start, value);
237        }
238    }
239    let parsed = toml_edit::Document::parse(patched.as_str())
240        .map_err(|e| toml_parse_error(&patched, &e, &limits))?;
241    let value = table_like_to_value(parsed.as_table(), &lexemes, &patched)?;
242    Ok(Doc::of_resolved(&value, limits).map_err(|e| limits.whole_document(e))?)
243}
244
245/// Turns a `toml_edit` parse failure into this crate's [`ParseError`],
246/// reporting the crate's own message at the failure's line/column.
247fn toml_parse_error(text: &str, e: &toml_edit::TomlError, limits: &Resolved) -> OmnistError {
248    // omnist-rs#197: `toml_edit`'s own recursion cap (about 80 levels) is
249    // nesting past a bound, so it is `document.limit.depth` at `$`. Its error
250    // carries a span for an inline table or array but NOT for a table header
251    // (`[a.a.a. ...]`), so this check comes before anything reads the span.
252    // Two spellings: "cannot recurse further; max recursion depth met" (with
253    // a span) and "recursion limit" (a table header, without one).
254    if ["max recursion depth", "recursion limit"]
255        .iter()
256        .any(|m| e.message().contains(m))
257    {
258        return toml_recursion_error(limits).into();
259    }
260    // `TomlError::span()` is optional. Every other genuine parse failure
261    // seen carries one, but a missing one must never panic the reader on
262    // hostile input (that was #197): fall back to the start of the input.
263    let span = e.span().unwrap_or(0..0);
264    let (line, col) = line_col_bytes(text, span.start);
265    // A number literal `toml_edit` refuses as an overflow and this module's
266    // scan did not take (a bare radix prefix such as `0x`, which has no
267    // digits) keeps the message it had before 0.10.0-alpha: it names the
268    // literal, not the library's generic wording.
269    if e.message().contains("overflow")
270        && let Some(literal) = text.get(span.clone()).filter(|l| !l.is_empty())
271    {
272        return ParseError::codec_syntax(
273            line,
274            col,
275            format!(
276                "invalid TOML: integer literal {literal:?} is out of range for a 64-bit integer"
277            ),
278        )
279        .into();
280    }
281    ParseError::codec_syntax(line, col, format!("invalid TOML: {}", e.message())).into()
282}
283
284/// The `document.limit.depth` error for `toml_edit`'s own recursion cap. The
285/// configured maximum depth may be larger (200 by default): the parser
286/// library refuses first, so the message names it rather than claiming the
287/// configured maximum was reached.
288fn toml_recursion_error(limits: &Resolved) -> DocumentError {
289    DocumentError::with_code(
290        "$",
291        "document.limit.depth",
292        format!(
293            "nesting exceeds toml_edit's own recursion limit (about 80 levels), which applies \
294             below the configured maximum depth ({})",
295            limits.max_depth
296        ),
297    )
298}
299
300fn is_number_char(c: char) -> bool {
301    c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '-' | '.')
302}
303
304/// Replaces `start..end` of `patched` by a same-width stand-in for `value`
305/// (`0.0` for a float, `0` for an integer, padded with spaces).
306fn blank(patched: &mut String, start: usize, end: usize, value: &Value) {
307    let placeholder = if matches!(value, Value::Float(_)) {
308        "0.0"
309    } else {
310        "0"
311    };
312    let mut filler = String::from(placeholder);
313    filler.push_str(&" ".repeat(end - start - placeholder.len()));
314    patched.replace_range(start..end, &filler);
315}
316
317/// The value of a number literal `toml_edit` cannot hold (D-2, D-28, D-29): an integer past 64
318/// bits in any radix, or a float whose magnitude overflows. `None` for
319/// anything else, including a malformed literal.
320fn number_value(lexeme: &str, guard: &mut IntGuard) -> Option<Value> {
321    let cleaned: String = lexeme.chars().filter(|&c| c != '_').collect();
322    let (neg, body) = match cleaned.strip_prefix('-') {
323        Some(rest) => (true, rest),
324        None => (false, cleaned.strip_prefix('+').unwrap_or(&cleaned)),
325    };
326    let radix = match body.get(..2) {
327        Some("0x") => 16,
328        Some("0o") => 8,
329        Some("0b") => 2,
330        _ => 10,
331    };
332    if radix == 10 && body.contains(['.', 'e', 'E']) {
333        return Some(Value::Float(cleaned.parse().ok()?));
334    }
335    let digits = if radix == 10 { body } else { &body[2..] };
336    let magnitude = if radix == 10 && digits.len() > guard.max() {
337        guard.placeholder()
338    } else {
339        BigInt::parse_bytes(digits.as_bytes(), radix)?
340    };
341    Some(Value::Int(if neg { -magnitude } else { magnitude }))
342}
343
344static NUMBER_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
345    regex::Regex::new(concat!(
346        r"^(?:[+-]?(?:0|[1-9](?:_?[0-9])*)",
347        r"|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*",
348        r"|0o[0-7](?:_?[0-7])*",
349        r"|0b[01](?:_?[01])*",
350        r"|[+-]?(?:0|[1-9](?:_?[0-9])*)(?:\.[0-9](?:_?[0-9])*)?(?:[eE][+-]?[0-9](?:_?[0-9])*)?)$"
351    ))
352    .unwrap()
353});
354
355/// The byte ranges of the value-position number literals in `text` that
356/// `toml_edit` would refuse, found in one scan: a well-formed integer long
357/// enough to leave 64 bits, or a well-formed float that overflows to an
358/// infinity. Strings and comments are skipped, and so are keys (a token
359/// before `=` or `.`, anything outside a value). A well-formed literal it
360/// takes has the same value `toml_edit` would have given it.
361fn prescan_numbers(text: &str) -> Vec<(usize, usize)> {
362    let b = text.as_bytes();
363    let mut out = Vec::new();
364    let (mut i, mut depth, mut value) = (0, 0usize, false);
365    while i < b.len() {
366        match b[i] {
367            b'#' => {
368                while i < b.len() && b[i] != b'\n' {
369                    i += 1;
370                }
371            }
372            q @ (b'"' | b'\'') => {
373                let multi = b[i..].starts_with(&[q, q, q]);
374                i += if multi { 3 } else { 1 };
375                while i < b.len() {
376                    if q == b'"' && b[i] == b'\\' {
377                        i += 2;
378                    } else if multi && b[i..].starts_with(&[q, q, q]) {
379                        i += 3;
380                        break;
381                    } else if !multi && (b[i] == q || b[i] == b'\n') {
382                        i += 1;
383                        break;
384                    } else {
385                        i += 1;
386                    }
387                }
388                continue;
389            }
390            b'=' if depth == 0 => value = true,
391            b'[' | b'{' if value => depth += 1,
392            b']' | b'}' if value => depth = depth.saturating_sub(1),
393            b'\n' if depth == 0 => value = false,
394            c if value && is_number_char(char::from(c)) => {
395                let start = i;
396                while i < b.len() && is_number_char(char::from(b[i])) {
397                    i += 1;
398                }
399                let lexeme = &text[start..i];
400                let after = text[i..].trim_start_matches([' ', '\t']);
401                let is_key = after.starts_with('=');
402                if !is_key && NUMBER_RE.is_match(lexeme) {
403                    let prefixed = ["0x", "0o", "0b"].iter().any(|p| lexeme.starts_with(p));
404                    let float = !prefixed && lexeme.contains(['.', 'e', 'E']);
405                    let take = if float {
406                        lexeme
407                            .replace('_', "")
408                            .parse::<f64>()
409                            .is_ok_and(f64::is_infinite)
410                    } else {
411                        lexeme.len() >= 18
412                    };
413                    if take {
414                        out.push((start, i));
415                    }
416                }
417                continue;
418            }
419            _ => {}
420        }
421        i += 1;
422    }
423    out
424}
425
426/// Converts a `toml_edit` table (top-level document or inline table) into a
427/// [`Value::Object`], recursing into every entry.
428fn table_like_to_value(
429    t: &dyn TableLike,
430    lexemes: &HashMap<usize, Value>,
431    src: &str,
432) -> Result<Value, ParseError> {
433    let mut map = IndexMap::new();
434    for (k, item) in t.iter() {
435        map.insert(k.to_string(), item_to_value(item, lexemes, src)?);
436    }
437    Ok(Value::Object(map))
438}
439
440fn item_to_value(
441    item: &Item,
442    lexemes: &HashMap<usize, Value>,
443    src: &str,
444) -> Result<Value, ParseError> {
445    match item {
446        // `Item::None` is only ever produced by `toml_edit`'s *mutation*
447        // API (`Entry`/`Index::or_insert(Item::None)`, confirmed by reading
448        // the crate's source) -- never by parsing text, which is the only
449        // way `read_toml` ever constructs an `Item`. White-box-tested
450        // directly (see this module's tests) rather than left an
451        // unreachable branch with no proof.
452        Item::None => unreachable!(
453            "Item::None is only produced by toml_edit's mutation API, never by parsing text"
454        ),
455        Item::Value(v) => toml_value_to_value(v, lexemes, src),
456        Item::Table(t) => table_like_to_value(t, lexemes, src),
457        Item::ArrayOfTables(arr) => {
458            let mut out = Vec::with_capacity(arr.len());
459            for t in arr.iter() {
460                out.push(table_like_to_value(t, lexemes, src)?);
461            }
462            Ok(Value::Array(out))
463        }
464    }
465}
466
467fn toml_value_to_value(
468    v: &toml_edit::Value,
469    lexemes: &HashMap<usize, Value>,
470    src: &str,
471) -> Result<Value, ParseError> {
472    // A number `toml_edit` refused (see `read_toml_resolved`) was read by
473    // this module and stood in for; its value is found again by offset.
474    if let Some(read) = v.span().and_then(|s| lexemes.get(&s.start)) {
475        return Ok(read.clone());
476    }
477    match v {
478        toml_edit::Value::String(s) => Ok(Value::Str(s.value().clone())),
479        // `toml_edit`'s own `Integer` is `i64`-backed (the TOML 1.0 format
480        // spec itself specifies 64-bit signed integers) -- a >19-digit
481        // literal in TOML *source text* is rejected by `toml_edit`'s own
482        // parser before this function ever runs, a genuine external
483        // format-level constraint distinct from omnist's own Scalar
484        // representation (issue #104; see docs/formats/toml.md).
485        toml_edit::Value::Integer(i) => Ok(Value::Int((*i.value()).into())),
486        toml_edit::Value::Float(f) => Ok(Value::Float(*f.value())),
487        toml_edit::Value::Boolean(b) => Ok(Value::Bool(*b.value())),
488        // `toml_edit` already validates and types TOML's four native
489        // temporal forms itself (calendar/clock fields, `2024-02-30`
490        // etc., are rejected in its own parser) -- `dt.value()`'s
491        // `date`/`time` presence tells us exactly which of the three
492        // kinds this is, real provenance rather than a shape guess
493        // (issue #105; previously collapsed straight to `Value::Str`,
494        // discarding real type information `toml_edit` had already
495        // computed).
496        toml_edit::Value::Datetime(dt) => {
497            check_fraction_digits(v, src)?;
498            let canonical = format_datetime(dt.value());
499            let inner = dt.value();
500            Ok(match (inner.date.is_some(), inner.time.is_some()) {
501                (true, true) => Value::Datetime(canonical),
502                (true, false) => Value::Date(canonical),
503                (false, true) => Value::Time(canonical),
504                // `toml_edit`'s own grammar always sets at least one of
505                // `date`/`time` -- a defensive, non-panicking fallback
506                // rather than `unreachable!()`, since this is an
507                // assumption about a dependency's invariant, not this
508                // crate's own.
509                (false, false) => Value::Str(canonical),
510            })
511        }
512        toml_edit::Value::Array(arr) => {
513            let mut out = Vec::with_capacity(arr.len());
514            for item in arr.iter() {
515                out.push(toml_value_to_value(item, lexemes, src)?);
516            }
517            Ok(Value::Array(out))
518        }
519        toml_edit::Value::InlineTable(it) => table_like_to_value(it, lexemes, src),
520    }
521}
522
523/// The Document model holds a fractional second of at most six digits (the
524/// OML grammar's `1*6DIGIT`). `toml_edit` keeps nine and drops the rest
525/// without a word, so the literal's own text is checked: a non-zero digit
526/// beyond the sixth is a value the Document cannot hold, which fails rather
527/// than being altered (fail, don't invent). Trailing zeros are insignificant.
528fn check_fraction_digits(v: &toml_edit::Value, src: &str) -> Result<(), ParseError> {
529    let span = v.span().unwrap_or(0..0);
530    let lit = src.get(span.clone()).unwrap_or("");
531    if crate::formats::fraction_inexact(lit) {
532        let (line, col) = line_col_bytes(src, span.start);
533        return Err(ParseError::codec_syntax(
534            line,
535            col,
536            "invalid TOML: a fractional second has more than six digits (the Document model holds at most microseconds); it would be altered",
537        ));
538    }
539    Ok(())
540}
541
542/// Canonicalizes a parsed `toml_edit` [`toml_edit::Datetime`] into this
543/// port's ISO-string spelling -- see this module's doc comment on native
544/// temporal types.
545fn format_datetime(dt: &toml_edit::Datetime) -> String {
546    let mut out = String::new();
547    if let Some(d) = &dt.date {
548        out.push_str(&format!("{:04}-{:02}-{:02}", d.year, d.month, d.day));
549    }
550    if let Some(t) = &dt.time {
551        if dt.date.is_some() {
552            out.push('T');
553        }
554        out.push_str(&format!(
555            "{:02}:{:02}:{:02}",
556            t.hour,
557            t.minute,
558            t.second.unwrap_or(0)
559        ));
560        if let Some(ns) = t.nanosecond {
561            let micros = ns / 1000;
562            if micros > 0 {
563                out.push('.');
564                out.push_str(&format!("{micros:06}"));
565            }
566        }
567    }
568    if let Some(off) = &dt.offset {
569        match off {
570            toml_edit::Offset::Z => out.push_str("+00:00"),
571            toml_edit::Offset::Custom { minutes } => {
572                let sign = if *minutes < 0 { '-' } else { '+' };
573                let m = minutes.unsigned_abs();
574                out.push_str(&format!("{sign}{:02}:{:02}", m / 60, m % 60));
575            }
576        }
577    }
578    out
579}
580
581// ============================================================== Writer
582
583/// Project a [`Doc`] to TOML text. See this module's doc comment for the
584/// null-adjustment, integer-cap, temporal, and output-shape decisions.
585pub fn write_toml(
586    doc: &Doc,
587    strict: bool,
588    report: Option<&mut WriteReport>,
589) -> Result<String, WriteError> {
590    let mut rep = WriteReport::new();
591    add_interleaving_diagnostic(doc, &mut rep);
592    let grouped = doc.to_grouped();
593    let stripped = strip_nulls(grouped, "$")?;
594    let Value::Object(map) = &stripped else {
595        return Err(WriteError::new(
596            "TOML needs a top-level table (the root must be an object)",
597        ));
598    };
599    check_toml_grouped(&stripped, "$", &mut rep);
600    let mut out = String::new();
601    write_table_body(map, &mut out);
602    crate::report::finish_write(out, rep, strict, report)
603}
604
605/// `format.interleaving-lost` (spec Sec8.3.8, D-3) is a whole-document
606/// diagnostic that depends on the original `Doc`'s edge order, lost by the
607/// time `to_grouped` runs -- so it is detected separately via
608/// `Doc::has_interleaving_loss` rather than folded into the grouped-`Value`
609/// scanners below.
610fn add_interleaving_diagnostic(doc: &Doc, rep: &mut WriteReport) {
611    if doc.has_interleaving_loss() {
612        rep.add(
613            "$",
614            "format.interleaving-lost",
615            "cross-label interleaving could not be written; same-label edges were grouped",
616            Severity::Warning,
617        );
618    }
619}
620
621/// Report what writing TOML would adjust, without producing output.
622pub fn check_toml(doc: &Doc) -> WriteReport {
623    let mut rep = WriteReport::new();
624    add_interleaving_diagnostic(doc, &mut rep);
625    let grouped = doc.to_grouped();
626    check_toml_grouped(&grouped, "$", &mut rep);
627    rep
628}
629
630fn check_toml_grouped(node: &Value, path: &str, rep: &mut WriteReport) {
631    match node {
632        Value::Object(map) => {
633            for (label, child) in map {
634                match child {
635                    Value::Null => {
636                        rep.add(
637                            crate::report::child_path(path, label, None),
638                            "write.unsupported-value",
639                            "null value has no TOML representation (TOML has no null token)",
640                            Severity::Error,
641                        );
642                    }
643                    Value::Array(items) => {
644                        for (i, item) in items.iter().enumerate() {
645                            let p = crate::report::child_path(
646                                path,
647                                label,
648                                crate::report::occurrence_index(i, items.len()),
649                            );
650                            if matches!(item, Value::Null) {
651                                rep.add(
652                                    p,
653                                    "write.unsupported-value",
654                                    "null value has no TOML representation (TOML has no null token)",
655                                    Severity::Error,
656                                );
657                            } else {
658                                check_toml_grouped(item, &p, rep);
659                            }
660                        }
661                    }
662                    other => {
663                        let p = crate::report::child_path(path, label, None);
664                        check_toml_grouped(other, &p, rep);
665                    }
666                }
667            }
668        }
669        Value::Array(items) => {
670            for (i, item) in items.iter().enumerate() {
671                let p = crate::report::child_path(
672                    path,
673                    "",
674                    crate::report::occurrence_index(i, items.len()),
675                );
676                if matches!(item, Value::Null) {
677                    rep.add(
678                        p,
679                        "write.unsupported-value",
680                        "null value has no TOML representation (TOML has no null token)",
681                        Severity::Error,
682                    );
683                } else {
684                    check_toml_grouped(item, &p, rep);
685                }
686            }
687        }
688        // E-37 rule 4: TOML has no offset time, so a `time` with an offset is
689        // written as a double-quoted string.
690        Value::Time(s) | Value::Datetime(s) if super::fraction_inexact(s) => {
691            rep.add(
692                path,
693                "format.temporal-stringified",
694                "fractional second has more than six digits; wrote as a quoted ISO-8601 string",
695                Severity::Warning,
696            );
697        }
698        Value::Time(s) if has_offset(s) => {
699            rep.add(
700                path,
701                "format.temporal-stringified",
702                "time with an offset has no native TOML literal; wrote as a quoted ISO-8601 string",
703                Severity::Warning,
704            );
705        }
706        _ => {}
707    }
708}
709
710/// Marker type implementing [`crate::formats::Codec`] for TOML -- adapts
711/// [`read_toml`]/[`write_toml`]/[`check_toml`] to the registry's uniform
712/// shape with the documented defaults (`strict: false`, no report). The
713/// root-shape error `write_toml` raises for a non-object root fires from
714/// inside `write_toml` itself, outside `finish_write`, exactly as before --
715/// this impl only calls `write_toml`, it doesn't reimplement it.
716pub(crate) struct Toml;
717
718impl crate::formats::Codec for Toml {
719    const NAME: &'static str = "toml";
720
721    fn read(text: &str) -> Result<Doc, OmnistError> {
722        read_toml(text)
723    }
724
725    fn write(doc: &Doc) -> Result<String, OmnistError> {
726        write_toml(doc, false, None).map_err(Into::into)
727    }
728
729    fn check(doc: &Doc) -> WriteReport {
730        check_toml(doc)
731    }
732}
733
734/// Fails the write unconditionally (`write.unsupported-value`, spec
735/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) the moment a null-valued field or
736/// array item is found -- TOML has no null token at all, and the old
737/// "drop the edge and warn" behavior erased the edge's existence entirely
738/// with no trace on read-back (confirmed live, arguably a sharper case of
739/// the same collision problem as the XML label-sanitization fix). This
740/// used to be named `strip_nulls` and mirror Python's `_strip_nulls`
741/// null-dropping path-numbering; renamed since it no longer drops
742/// anything -- it now returns the first null path it finds as an error.
743fn strip_nulls(node: Value, path: &str) -> Result<Value, WriteError> {
744    match node {
745        Value::Object(map) => {
746            let mut out = IndexMap::new();
747            for (label, child) in map {
748                match child {
749                    Value::Null => {
750                        let p = crate::report::child_path(path, &label, None);
751                        return Err(crate::report::unsupported_value_error(
752                            &p,
753                            "null value has no TOML representation (TOML has no null token)",
754                        ));
755                    }
756                    Value::Array(items) => {
757                        let mut kept = Vec::with_capacity(items.len());
758                        let count = items.len();
759                        for (i, item) in items.into_iter().enumerate() {
760                            let p = crate::report::child_path(
761                                path,
762                                &label,
763                                crate::report::occurrence_index(i, count),
764                            );
765                            if matches!(item, Value::Null) {
766                                return Err(crate::report::unsupported_value_error(
767                                    &p,
768                                    "null value has no TOML representation (TOML has no null                                      token)",
769                                ));
770                            }
771                            kept.push(strip_nulls(item, &p)?);
772                        }
773                        out.insert(label, Value::Array(kept));
774                    }
775                    other => {
776                        let p = crate::report::child_path(path, &label, None);
777                        out.insert(label, strip_nulls(other, &p)?);
778                    }
779                }
780            }
781            Ok(Value::Object(out))
782        }
783        other => Ok(other),
784    }
785}
786
787fn write_table_body(map: &IndexMap<String, Value>, out: &mut String) {
788    for (k, v) in map {
789        write_key(k, out);
790        out.push_str(" = ");
791        write_inline_value(v, out);
792        out.push('\n');
793    }
794}
795
796fn write_inline_value(v: &Value, out: &mut String) {
797    match v {
798        Value::Object(map) => {
799            if map.is_empty() {
800                out.push_str("{}");
801                return;
802            }
803            out.push_str("{ ");
804            let mut first = true;
805            for (k, child) in map {
806                if !first {
807                    out.push_str(", ");
808                }
809                first = false;
810                write_key(k, out);
811                out.push_str(" = ");
812                write_inline_value(child, out);
813            }
814            out.push_str(" }");
815        }
816        Value::Array(items) => {
817            if items.is_empty() {
818                out.push_str("[]");
819                return;
820            }
821            out.push('[');
822            let mut first = true;
823            for item in items {
824                if !first {
825                    out.push_str(", ");
826                }
827                first = false;
828                write_inline_value(item, out);
829            }
830            out.push(']');
831        }
832        scalar => write_scalar(scalar, out),
833    }
834}
835
836fn write_scalar(v: &Value, out: &mut String) {
837    match v {
838        Value::Null => unreachable!("null values are stripped before writing (strip_nulls)"),
839        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
840        Value::Int(i) => out.push_str(&i.to_string()),
841        Value::Float(x) => write_float(*x, out),
842        // Always quoted -- no shape-guessing (issue #105, the same fix
843        // issue #99 already applied to OML). A genuinely temporal-kinded
844        // value is a `Date`/`Time`/`Datetime` variant, not a
845        // shape-matched `Str`; see the arms below.
846        Value::Str(s) => write_toml_string(s, out),
847        Value::Date(s) => out.push_str(s),
848        Value::Datetime(s) => {
849            let text = super::temporal_text(s, true);
850            if super::fraction_inexact(&text) {
851                write_toml_string(&text, out);
852            } else {
853                out.push_str(&text);
854            }
855        }
856        // TOML has no "local time with an offset" literal form (only
857        // *date*time can carry an offset) -- a `Time` value that happens
858        // to carry one (YAML's own `TIME_RE` allows it, see issue #99's
859        // `bare_time_literal_with_utc_offset_reads_as_a_genuine_temporal_leaf`)
860        // has no native TOML spelling, so it's written as a quoted
861        // string instead, the same fallback this module already used
862        // before real provenance existed -- see `has_offset`.
863        Value::Time(s) => {
864            let text = super::temporal_text(s, false);
865            if has_offset(s) || super::fraction_inexact(&text) {
866                write_toml_string(&text, out);
867            } else {
868                out.push_str(&text);
869            }
870        }
871        Value::Object(_) | Value::Array(_) => {
872            unreachable!("write_scalar is only ever called on a leaf")
873        }
874    }
875}
876
877/// See `formats::float_fmt` (issue #47) for the shared render-then-inspect
878/// core (issue #46's fix); this is just TOML's spelling table.
879fn write_float(x: f64, out: &mut String) {
880    float_fmt::write_float(x, "nan", "inf", "-inf", out);
881}
882
883/// Whether an [`is_iso_time`]-shaped string also carries a `+HH:MM`/
884/// `-HH:MM` offset -- a bare TOML local-time literal has no offset, so a
885/// string shaped like "time with an offset" (an unusual value that isn't a
886/// real TOML literal at all) is written as a quoted string instead.
887fn has_offset(s: &str) -> bool {
888    s.contains(['+', '-', 'Z', 'z'])
889}
890
891fn write_key(k: &str, out: &mut String) {
892    if is_bare_key(k) {
893        out.push_str(k);
894    } else {
895        write_toml_string(k, out);
896    }
897}
898
899fn is_bare_key(k: &str) -> bool {
900    !k.is_empty()
901        && k.chars()
902            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
903}
904
905fn write_toml_string(s: &str, out: &mut String) {
906    write_quoted(s, &TOML_ESCAPES, out);
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912    use crate::document::Scalar;
913
914    fn doc_of(v: Value) -> Doc {
915        Doc::of(&v).unwrap()
916    }
917
918    fn obj(pairs: Vec<(&str, Value)>) -> Value {
919        Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
920    }
921
922    #[test]
923    fn check_toml_reports_null_omitted_across_objects_and_arrays() {
924        let doc = doc_of(obj(vec![
925            ("null_field", Value::Null),
926            (
927                "nested",
928                obj(vec![
929                    ("inner_null", Value::Null),
930                    ("valid", Value::Int(1.into())),
931                ]),
932            ),
933            (
934                "arr",
935                Value::Array(vec![
936                    Value::Null,
937                    Value::Int(2.into()),
938                    obj(vec![("arr_inner_null", Value::Null)]),
939                ]),
940            ),
941        ]));
942        let rep = check_toml(&doc);
943        assert_eq!(rep.adjustments().len(), 4);
944        assert_eq!(rep.adjustments()[0].path, "$.null_field");
945        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
946    }
947
948    #[test]
949    fn check_toml_grouped_handles_array_root() {
950        let arr = Value::Array(vec![Value::Null, Value::Int(42.into())]);
951        let mut rep = WriteReport::new();
952        check_toml_grouped(&arr, "$", &mut rep);
953        assert_eq!(rep.adjustments().len(), 1);
954        // The array's items sit under an empty label, which E-36 quotes.
955        assert_eq!(rep.adjustments()[0].path, "$[\"\"][0]");
956    }
957
958    #[test]
959    fn toml_value_to_value_defensive_fallback_on_a_bare_offset_datetime() {
960        // `toml_edit`'s own grammar never emits a `Datetime` with neither
961        // `date` nor `time` set through real TOML source text, but its
962        // `Datetime` struct's fields are public, so this hand-built case
963        // is directly constructible -- real, tested coverage of the
964        // defensive `Value::Str` fallback (issue #105), not speculative
965        // dead code.
966        let dt = toml_edit::Datetime {
967            date: None,
968            time: None,
969            offset: None,
970        };
971        let v = toml_edit::Value::Datetime(toml_edit::Formatted::new(dt));
972        assert_eq!(
973            toml_value_to_value(&v, &HashMap::new(), "").unwrap(),
974            Value::Str(String::new())
975        );
976    }
977
978    // ---------------------------------------------------------- reader: scalars
979
980    #[test]
981    fn reads_every_native_scalar_kind() {
982        let doc = read_toml("a = 1\nb = \"s\"\nc = true\nd = 1.5\ne = false\n").unwrap();
983        let root = doc.root();
984        assert_eq!(
985            *root.get_one("a").unwrap().value().unwrap(),
986            Scalar::Int((1).into())
987        );
988        assert_eq!(
989            *root.get_one("b").unwrap().value().unwrap(),
990            Scalar::Str("s".to_string())
991        );
992        assert_eq!(
993            *root.get_one("c").unwrap().value().unwrap(),
994            Scalar::Bool(true)
995        );
996        assert_eq!(
997            *root.get_one("d").unwrap().value().unwrap(),
998            Scalar::Float(1.5)
999        );
1000        assert_eq!(
1001            *root.get_one("e").unwrap().value().unwrap(),
1002            Scalar::Bool(false)
1003        );
1004    }
1005
1006    #[test]
1007    fn reads_nested_tables_and_arrays() {
1008        let doc = read_toml("arr = [1, 2, 3]\n[nested]\nx = 1\n").unwrap();
1009        let root = doc.root();
1010        let items: Vec<_> = root.get("arr");
1011        assert_eq!(items.len(), 3);
1012        let nested = root.get_one("nested").unwrap();
1013        assert_eq!(
1014            *nested.get_one("x").unwrap().value().unwrap(),
1015            Scalar::Int((1).into())
1016        );
1017    }
1018
1019    #[test]
1020    fn reads_array_of_tables() {
1021        let doc = read_toml("[[items]]\nx = 1\n[[items]]\nx = 2\n").unwrap();
1022        let root = doc.root();
1023        let items: Vec<_> = root.get("items");
1024        assert_eq!(items.len(), 2);
1025        assert_eq!(
1026            *items[0].get_one("x").unwrap().value().unwrap(),
1027            Scalar::Int((1).into())
1028        );
1029        assert_eq!(
1030            *items[1].get_one("x").unwrap().value().unwrap(),
1031            Scalar::Int((2).into())
1032        );
1033    }
1034
1035    #[test]
1036    fn invalid_toml_is_a_parse_error() {
1037        let err = read_toml("a = ").unwrap_err();
1038        assert!(matches!(err, OmnistError::Parse(_)));
1039    }
1040
1041    // ------------------------------------------------------- temporal literals
1042
1043    #[test]
1044    fn reads_local_date() {
1045        let doc = read_toml("d = 1979-05-27\n").unwrap();
1046        assert_eq!(
1047            *doc.root().get_one("d").unwrap().value().unwrap(),
1048            Scalar::Date("1979-05-27".to_string())
1049        );
1050    }
1051
1052    #[test]
1053    fn reads_local_time_with_fraction() {
1054        let doc = read_toml("t = 00:32:00.999999\n").unwrap();
1055        assert_eq!(
1056            *doc.root().get_one("t").unwrap().value().unwrap(),
1057            Scalar::Time("00:32:00.999999".to_string())
1058        );
1059    }
1060
1061    #[test]
1062    fn reads_local_time_without_fraction() {
1063        let doc = read_toml("t = 07:32:00\n").unwrap();
1064        assert_eq!(
1065            *doc.root().get_one("t").unwrap().value().unwrap(),
1066            Scalar::Time("07:32:00".to_string())
1067        );
1068    }
1069
1070    #[test]
1071    fn a_nonzero_digit_beyond_microseconds_fails_instead_of_truncating() {
1072        // The Document holds at most six fractional digits: a seventh
1073        // non-zero digit is not silently dropped (fail, don't invent).
1074        for text in [
1075            "t = 00:32:00.9999999\n",
1076            "t = 07:32:00.000000001\n",
1077            "t = 07:32:00.1234567891\n",
1078        ] {
1079            let err = read_toml(text).unwrap_err().to_string();
1080            assert!(err.contains("six digits"), "{text:?}: {err}");
1081        }
1082    }
1083
1084    #[test]
1085    fn an_inexact_fraction_is_written_quoted_and_reported() {
1086        for (v, text_in) in [
1087            (
1088                Value::Time("07:32:00.1234567".to_string()),
1089                "07:32:00.1234567",
1090            ),
1091            (
1092                Value::Datetime("1979-05-27T07:32:00.1234567+01:00".to_string()),
1093                "1979-05-27T07:32:00.1234567+01:00",
1094            ),
1095        ] {
1096            let doc = doc_of(obj(vec![("t", v)]));
1097            let mut report = WriteReport::new();
1098            let text = write_toml(&doc, false, Some(&mut report)).unwrap();
1099            assert_eq!(text, format!("t = \"{text_in}\"\n"));
1100            assert_eq!(report.adjustments().len(), 1);
1101            assert_eq!(report.adjustments()[0].code, "format.temporal-stringified");
1102            assert_eq!(check_toml(&doc).adjustments().len(), 1);
1103            assert!(read_toml(&text).is_ok());
1104        }
1105        // Zeros past the sixth digit are dropped, the literal stays native.
1106        let doc = doc_of(obj(vec![(
1107            "t",
1108            Value::Time("07:32:00.1234560".to_string()),
1109        )]));
1110        assert_eq!(
1111            write_toml(&doc, false, None).unwrap(),
1112            "t = 07:32:00.123456\n"
1113        );
1114    }
1115
1116    #[test]
1117    fn an_all_zero_fraction_reads_as_no_fraction() {
1118        for text in ["t = 07:32:00.000\n", "t = 07:32:00.0000000\n"] {
1119            let doc = read_toml(text).unwrap();
1120            assert_eq!(
1121                *doc.root().get_one("t").unwrap().value().unwrap(),
1122                Scalar::Time("07:32:00".to_string()),
1123                "{text:?}"
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn zeros_beyond_microseconds_are_insignificant() {
1130        let doc = read_toml("t = 00:32:00.9999990\n").unwrap();
1131        assert_eq!(
1132            *doc.root().get_one("t").unwrap().value().unwrap(),
1133            Scalar::Time("00:32:00.999999".to_string())
1134        );
1135    }
1136
1137    #[test]
1138    fn reads_local_datetime() {
1139        let doc = read_toml("dt = 1979-05-27T07:32:00\n").unwrap();
1140        assert_eq!(
1141            *doc.root().get_one("dt").unwrap().value().unwrap(),
1142            Scalar::Datetime("1979-05-27T07:32:00".to_string())
1143        );
1144    }
1145
1146    #[test]
1147    fn reads_offset_datetime_z_normalizes_to_numeric_offset() {
1148        let doc = read_toml("dt = 1979-05-27T07:32:00Z\n").unwrap();
1149        assert_eq!(
1150            *doc.root().get_one("dt").unwrap().value().unwrap(),
1151            Scalar::Datetime("1979-05-27T07:32:00+00:00".to_string())
1152        );
1153    }
1154
1155    #[test]
1156    fn round_trips_offset_datetime_preserving_negative_offset() {
1157        let doc = read_toml("dt = 1979-05-27T07:32:00-07:00\n").unwrap();
1158        assert_eq!(
1159            *doc.root().get_one("dt").unwrap().value().unwrap(),
1160            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
1161        );
1162        let text = write_toml(&doc, false, None).unwrap();
1163        assert_eq!(text, "dt = 1979-05-27T07:32:00-07:00\n");
1164        let doc2 = read_toml(&text).unwrap();
1165        assert_eq!(
1166            *doc2.root().get_one("dt").unwrap().value().unwrap(),
1167            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
1168        );
1169    }
1170
1171    #[test]
1172    fn round_trips_offset_datetime_preserving_positive_offset_and_fraction() {
1173        let src = "dt = 1979-05-27T07:32:00.999999+07:00\n";
1174        let doc = read_toml(src).unwrap();
1175        let text = write_toml(&doc, false, None).unwrap();
1176        assert_eq!(text, src);
1177    }
1178
1179    #[test]
1180    fn space_separated_datetime_reads_as_t_joined_canonical_string() {
1181        let doc = read_toml("dt = 1979-05-27 07:32:00-07:00\n").unwrap();
1182        assert_eq!(
1183            *doc.root().get_one("dt").unwrap().value().unwrap(),
1184            Scalar::Datetime("1979-05-27T07:32:00-07:00".to_string())
1185        );
1186    }
1187
1188    // ------------------------------------------------------------ round trips
1189
1190    #[test]
1191    fn round_trips_every_native_scalar_kind() {
1192        let v = obj(vec![
1193            ("a", Value::Int((42).into())),
1194            ("b", Value::Str("hello".to_string())),
1195            ("c", Value::Bool(true)),
1196            ("d", Value::Float(1.5)),
1197            ("e", Value::Bool(false)),
1198        ]);
1199        let doc = doc_of(v);
1200        let text = write_toml(&doc, false, None).unwrap();
1201        let doc2 = read_toml(&text).unwrap();
1202        let root = doc2.root();
1203        assert_eq!(
1204            *root.get_one("a").unwrap().value().unwrap(),
1205            Scalar::Int((42).into())
1206        );
1207        assert_eq!(
1208            *root.get_one("b").unwrap().value().unwrap(),
1209            Scalar::Str("hello".to_string())
1210        );
1211        assert_eq!(
1212            *root.get_one("c").unwrap().value().unwrap(),
1213            Scalar::Bool(true)
1214        );
1215        assert_eq!(
1216            *root.get_one("d").unwrap().value().unwrap(),
1217            Scalar::Float(1.5)
1218        );
1219        assert_eq!(
1220            *root.get_one("e").unwrap().value().unwrap(),
1221            Scalar::Bool(false)
1222        );
1223    }
1224
1225    #[test]
1226    fn round_trips_integral_float_at_and_above_1e17_boundary_issue_46() {
1227        // Regression test for issue #46 (see json.rs's twin test for the
1228        // full explanation): an integral-valued float >= 1e17 used to
1229        // render as a bare digit run and re-read as `Scalar::Int`.
1230        for x in [1.0e17, 1.0e18, -1.23e17, 9.9e16_f64] {
1231            let doc = doc_of(obj(vec![("a", Value::Float(x))]));
1232            let text = write_toml(&doc, false, None).unwrap();
1233            let back = read_toml(&text).unwrap();
1234            assert_eq!(
1235                *back.root().get_one("a").unwrap().value().unwrap(),
1236                Scalar::Float(x),
1237                "x={x} text={text}"
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn round_trips_local_date_time_and_datetime() {
1244        // Genuinely temporal-kinded values (issue #105) write as TOML's
1245        // native literals and read back as the same real variant -- see
1246        // `plain_string_that_looks_like_a_date_stays_quoted` below for the
1247        // companion case (a plain string merely shaped like one of these).
1248        let v = obj(vec![
1249            ("d", Value::Date("1979-05-27".to_string())),
1250            ("t", Value::Time("07:32:00".to_string())),
1251            ("dt", Value::Datetime("1979-05-27T07:32:00".to_string())),
1252        ]);
1253        let doc = doc_of(v);
1254        let text = write_toml(&doc, false, None).unwrap();
1255        assert!(text.contains("d = 1979-05-27\n"));
1256        assert!(text.contains("t = 07:32:00\n"));
1257        assert!(text.contains("dt = 1979-05-27T07:32:00\n"));
1258        let doc2 = read_toml(&text).unwrap();
1259        let root = doc2.root();
1260        assert_eq!(
1261            *root.get_one("d").unwrap().value().unwrap(),
1262            Scalar::Date("1979-05-27".to_string())
1263        );
1264        assert_eq!(
1265            *root.get_one("t").unwrap().value().unwrap(),
1266            Scalar::Time("07:32:00".to_string())
1267        );
1268        assert_eq!(
1269            *root.get_one("dt").unwrap().value().unwrap(),
1270            Scalar::Datetime("1979-05-27T07:32:00".to_string())
1271        );
1272    }
1273
1274    #[test]
1275    fn a_genuine_time_value_carrying_an_offset_writes_as_a_quoted_string() {
1276        // TOML has no "local time with an offset" literal (only *date*time
1277        // can carry one) -- a real `Value::Time` that happens to carry a
1278        // UTC offset (OML's own time grammar allows this) has no native
1279        // TOML spelling, so `write_scalar` falls back to a quoted string
1280        // (see `has_offset` and the `Value::Time` write arm).
1281        let v = obj(vec![("t", Value::Time("07:32:00+02:00".to_string()))]);
1282        let doc = doc_of(v);
1283        let text = write_toml(&doc, false, None).unwrap();
1284        assert!(text.contains("t = \"07:32:00+02:00\"\n"));
1285    }
1286
1287    #[test]
1288    fn round_trips_nested_table_and_array() {
1289        let v = obj(vec![
1290            (
1291                "nested",
1292                obj(vec![
1293                    ("x", Value::Int((1).into())),
1294                    ("y", Value::Str("z".to_string())),
1295                ]),
1296            ),
1297            (
1298                "arr",
1299                Value::Array(vec![
1300                    Value::Int((1).into()),
1301                    Value::Int((2).into()),
1302                    Value::Int((3).into()),
1303                ]),
1304            ),
1305        ]);
1306        let doc = doc_of(v);
1307        let text = write_toml(&doc, false, None).unwrap();
1308        let doc2 = read_toml(&text).unwrap();
1309        let root = doc2.root();
1310        let nested = root.get_one("nested").unwrap();
1311        assert_eq!(
1312            *nested.get_one("x").unwrap().value().unwrap(),
1313            Scalar::Int((1).into())
1314        );
1315        assert_eq!(
1316            *nested.get_one("y").unwrap().value().unwrap(),
1317            Scalar::Str("z".to_string())
1318        );
1319        assert_eq!(root.get("arr").len(), 3);
1320    }
1321
1322    #[test]
1323    fn round_trips_array_of_tables() {
1324        let v = obj(vec![(
1325            "items",
1326            Value::Array(vec![
1327                obj(vec![("x", Value::Int((1).into()))]),
1328                obj(vec![("x", Value::Int((2).into()))]),
1329            ]),
1330        )]);
1331        let doc = doc_of(v);
1332        let text = write_toml(&doc, false, None).unwrap();
1333        let doc2 = read_toml(&text).unwrap();
1334        let items: Vec<_> = doc2.root().get("items");
1335        assert_eq!(items.len(), 2);
1336        assert_eq!(
1337            *items[0].get_one("x").unwrap().value().unwrap(),
1338            Scalar::Int((1).into())
1339        );
1340        assert_eq!(
1341            *items[1].get_one("x").unwrap().value().unwrap(),
1342            Scalar::Int((2).into())
1343        );
1344    }
1345
1346    #[test]
1347    fn round_trips_nan_and_infinity_natively() {
1348        let v = obj(vec![
1349            ("a", Value::Float(f64::NAN)),
1350            ("b", Value::Float(f64::INFINITY)),
1351            ("c", Value::Float(f64::NEG_INFINITY)),
1352        ]);
1353        let doc = doc_of(v);
1354        let text = write_toml(&doc, false, None).unwrap();
1355        assert!(text.contains("a = nan\n"));
1356        assert!(text.contains("b = inf\n"));
1357        assert!(text.contains("c = -inf\n"));
1358        let doc2 = read_toml(&text).unwrap();
1359        let root = doc2.root();
1360        assert!(matches!(
1361            root.get_one("a").unwrap().value().unwrap(),
1362            Scalar::Float(x) if x.is_nan()
1363        ));
1364        assert_eq!(
1365            *root.get_one("b").unwrap().value().unwrap(),
1366            Scalar::Float(f64::INFINITY)
1367        );
1368        assert_eq!(
1369            *root.get_one("c").unwrap().value().unwrap(),
1370            Scalar::Float(f64::NEG_INFINITY)
1371        );
1372        // no adjustment needed -- TOML holds special floats natively.
1373        assert!(check_toml(&doc).is_empty());
1374    }
1375
1376    // ------------------------------------------------------------ null (write.unsupported-value)
1377
1378    // Was `lenient_write_drops_null_field_and_records_adjustment` before
1379    // spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24): a null-valued leaf now
1380    // fails the write unconditionally (`write.unsupported-value`) instead
1381    // of being dropped with a warning -- the drop erased the edge's
1382    // existence entirely with no trace on read-back. `strict` no longer
1383    // changes the outcome (this used to be the lenient case; the old
1384    // `strict_write_raises_on_null_even_though_severity_is_warning` test
1385    // below now asserts the identical failure for `strict: true`).
1386    #[test]
1387    fn write_fails_unconditionally_on_null_field_lenient() {
1388        let v = obj(vec![("a", Value::Int((1).into())), ("b", Value::Null)]);
1389        let doc = doc_of(v);
1390        let mut rep = WriteReport::new();
1391        let err = write_toml(&doc, false, Some(&mut rep)).unwrap_err();
1392        assert!(err.to_string().contains("write.unsupported-value"));
1393        assert!(err.to_string().contains("$.b"));
1394        assert!(rep.is_empty());
1395        assert!(err.report().is_none());
1396    }
1397
1398    // Was `lenient_write_drops_null_array_item_shifting_index`: a null
1399    // array item now fails the write instead of being dropped and
1400    // shifting later items down.
1401    #[test]
1402    fn write_fails_unconditionally_on_null_array_item() {
1403        let v = obj(vec![(
1404            "c",
1405            Value::Array(vec![
1406                Value::Int((1).into()),
1407                Value::Null,
1408                Value::Int((2).into()),
1409            ]),
1410        )]);
1411        let doc = doc_of(v);
1412        let err = write_toml(&doc, false, None).unwrap_err();
1413        assert!(err.to_string().contains("write.unsupported-value"));
1414        assert!(err.to_string().contains("$.c[1]"));
1415    }
1416
1417    #[test]
1418    fn null_in_nested_table_records_nested_path_in_check() {
1419        let v = obj(vec![(
1420            "nested",
1421            obj(vec![("x", Value::Null), ("y", Value::Int((5).into()))]),
1422        )]);
1423        let doc = doc_of(v);
1424        let rep = check_toml(&doc);
1425        assert_eq!(rep.len(), 1);
1426        assert_eq!(rep.adjustments()[0].path, "$.nested.x");
1427        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
1428    }
1429
1430    // Renamed from `strict_write_raises_on_null_even_though_severity_is_warning`:
1431    // `strict` no longer distinguishes anything here -- both modes fail
1432    // identically and unconditionally, and the error carries no report
1433    // (the unconditional-failure path returns before any report is built).
1434    #[test]
1435    fn write_fails_unconditionally_on_null_strict() {
1436        let v = obj(vec![("a", Value::Int((1).into())), ("b", Value::Null)]);
1437        let doc = doc_of(v);
1438        let err = write_toml(&doc, true, None).unwrap_err();
1439        assert!(err.to_string().contains("$.b"));
1440        assert!(err.to_string().contains("write.unsupported-value"));
1441        assert!(err.report().is_none());
1442    }
1443
1444    #[test]
1445    fn check_toml_reports_without_producing_output() {
1446        let v = obj(vec![("a", Value::Null)]);
1447        let doc = doc_of(v);
1448        let rep = check_toml(&doc);
1449        assert_eq!(rep.len(), 1);
1450        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
1451    }
1452
1453    // ------------------------------------------------------------ top-level shape
1454
1455    #[test]
1456    fn non_table_root_is_a_write_error() {
1457        let doc = doc_of(Value::Int((5).into()));
1458        let err = write_toml(&doc, false, None).unwrap_err();
1459        assert!(err.to_string().contains("top-level table"));
1460        assert!(err.report().is_none());
1461    }
1462
1463    #[test]
1464    fn non_table_root_is_a_write_error_even_with_a_report_supplied() {
1465        let doc = doc_of(Value::Int((5).into()));
1466        let mut rep = WriteReport::new();
1467        let err = write_toml(&doc, false, Some(&mut rep)).unwrap_err();
1468        assert!(err.to_string().contains("top-level table"));
1469        assert!(rep.is_empty());
1470    }
1471
1472    #[test]
1473    fn empty_object_root_writes_empty_text() {
1474        let doc = doc_of(Value::Object(IndexMap::new()));
1475        let text = write_toml(&doc, false, None).unwrap();
1476        assert_eq!(text, "");
1477    }
1478
1479    // ------------------------------------------------------------ integer cap
1480
1481    #[test]
1482    fn a_bare_radix_prefix_names_the_literal_as_out_of_range() {
1483        for (text, literal) in [
1484            (
1485                "a = 0x
1486", "0x",
1487            ),
1488            (
1489                "a = 0o
1490", "0o",
1491            ),
1492            (
1493                "a = [0b]
1494",
1495                "0b",
1496            ),
1497        ] {
1498            let err = read_toml(text).unwrap_err();
1499            assert!(
1500                matches!(&err, OmnistError::Parse(e)
1501                    if e.message.contains(&format!("integer literal {literal:?} is out of range"))),
1502                "{text:?} gave {err:?}"
1503            );
1504        }
1505    }
1506
1507    /// The scalar a TOML text holds at a top-level label.
1508    fn scalar_at(text: &str, label: &str) -> Scalar {
1509        read_toml(text)
1510            .unwrap()
1511            .root()
1512            .get_one(label)
1513            .unwrap()
1514            .value()
1515            .unwrap()
1516            .clone()
1517    }
1518
1519    /// The integer a TOML text holds at `x`, as its decimal digits.
1520    fn int_at(text: &str, label: &str) -> String {
1521        let got = scalar_at(text, label);
1522        assert!(matches!(got, Scalar::Int(_)), "{got:?}");
1523        got.to_string()
1524    }
1525
1526    fn assert_int_digits(err: &OmnistError, path: &str) {
1527        assert!(
1528            matches!(err, OmnistError::Document(e)
1529                if e.code.as_deref() == Some("document.limit.int-digits")
1530                    && e.path == path
1531                    && e.message.contains("4300-digit limit")),
1532            "expected document.limit.int-digits at {path}, got {err:?}"
1533        );
1534    }
1535
1536    #[test]
1537    fn integer_at_4300_digits_reads_exactly() {
1538        // D-2: integers are arbitrary precision, so a literal `toml_edit`
1539        // cannot hold in 64 bits still reads, exactly, up to the digit limit.
1540        let digits = "9".repeat(4300);
1541        assert_eq!(int_at(&format!("x = {digits}\n"), "x"), digits);
1542        assert_eq!(
1543            int_at(&format!("x = -{digits}\n"), "x"),
1544            format!("-{digits}")
1545        );
1546    }
1547
1548    #[test]
1549    fn huge_hex_literal_is_limited_by_its_decimal_digits() {
1550        // D-28: the limit counts the decimal digits of the VALUE. 3,600 hex
1551        // digits are about 4,335 decimal digits, over the default 4,300.
1552        let text = format!("x = 0x{}\n", "f".repeat(3600));
1553        assert_int_digits(&read_toml(&text).unwrap_err(), "$.x");
1554        // Far past it, the same.
1555        let text = format!("x = 0x{}\n", "f".repeat(5000));
1556        assert_int_digits(&read_toml(&text).unwrap_err(), "$.x");
1557    }
1558
1559    #[test]
1560    fn a_long_binary_literal_within_the_decimal_limit_reads() {
1561        // 4,301 binary digits are about 1,295 decimal digits: within the limit.
1562        let text = format!("x = 0b{}\n", "1".repeat(4301));
1563        let expected: BigInt = (BigInt::from(1) << 4301) - 1;
1564        assert_eq!(scalar_at(&text, "x"), Scalar::Int(expected));
1565    }
1566
1567    #[test]
1568    fn integer_over_4300_digits_is_the_digit_cap_error() {
1569        let text = format!("x = {}\n", "9".repeat(4301));
1570        assert_int_digits(&read_toml(&text).unwrap_err(), "$.x");
1571        let text = format!("x = -{}\n", "9".repeat(4301));
1572        assert_int_digits(&read_toml(&text).unwrap_err(), "$.x");
1573    }
1574
1575    #[test]
1576    fn integer_beyond_64_bits_reads_exactly_in_every_radix() {
1577        let big = "9223372036854775808"; // i64::MAX + 1
1578        assert_eq!(int_at(&format!("x = {big}\n"), "x"), big);
1579        assert_eq!(
1580            int_at("x = -9223372036854775809\n", "x"),
1581            "-9223372036854775809"
1582        );
1583        assert_eq!(int_at("x = 9_223_372_036_854_775_808\n", "x"), big);
1584        assert_eq!(int_at("x = +9223372036854775808\n", "x"), big);
1585        assert_eq!(
1586            int_at("x = 0xFFFFFFFFFFFFFFFFFF\n", "x"),
1587            "4722366482869645213695"
1588        );
1589        assert_eq!(
1590            int_at("x = 0o7777777777777777777777\n", "x"),
1591            "73786976294838206463"
1592        );
1593        assert_eq!(
1594            int_at(
1595                "x = 0b1111111111111111111111111111111111111111111111111111111111111111111111\n",
1596                "x"
1597            )
1598            .to_string(),
1599            "1180591620717411303423"
1600        );
1601    }
1602
1603    #[test]
1604    fn a_decimal_literal_over_a_lowered_limit_is_refused_at_its_path() {
1605        let limits = crate::limits::Limits::default().with_max_int_digits(20);
1606        let err = read_toml_with("a = 1\nb = 123456789012345678901\n", &limits).unwrap_err();
1607        assert!(matches!(
1608            err,
1609            OmnistError::Document(ref e)
1610                if e.code.as_deref() == Some("document.limit.int-digits") && e.path == "$.b"
1611        ));
1612        let ok = read_toml_with("b = 12345678901234567890\n", &limits).unwrap();
1613        assert!(ok.root().get_one("b").is_ok());
1614    }
1615
1616    #[test]
1617    fn big_integers_are_found_by_path_inside_arrays_tables_and_inline_tables() {
1618        let big = "9".repeat(30);
1619        let text =
1620            format!("a = [1, {big}]\n[t]\nx = {{ y = {big} }}\n[[r]]\nz = {big}\n[[r]]\nz = 2\n");
1621        let doc = read_toml(&text).unwrap();
1622        let t = doc.root().get_one("t").unwrap();
1623        let x = t.get_one("x").unwrap();
1624        assert_eq!(
1625            x.get_one("y").unwrap().value(),
1626            Ok(&Scalar::Int(big.parse().unwrap()))
1627        );
1628        // The same literals over a lowered limit: the path is the element's.
1629        let limits = crate::limits::Limits::default().with_max_int_digits(20);
1630        for (text, path) in [
1631            (format!("a = [1, {big}]\n"), "$.a[1]"),
1632            (format!("x = {{ y = {big} }}\n"), "$.x.y"),
1633            (format!("[[r]]\nz = {big}\n[[r]]\nz = 2\n"), "$.r[0].z"),
1634            (format!("[t]\nz = {big}\n"), "$.t.z"),
1635        ] {
1636            let err = read_toml_with(&text, &limits).unwrap_err();
1637            assert!(
1638                matches!(&err, OmnistError::Document(e) if e.path == path
1639                    && e.code.as_deref() == Some("document.limit.int-digits")),
1640                "{path}: {err:?}"
1641            );
1642        }
1643    }
1644
1645    #[test]
1646    fn a_syntax_error_wins_over_a_big_integer_wherever_it_is() {
1647        let big = "9".repeat(5000);
1648        // After the literal: reported at its own position.
1649        let err = read_toml(&format!("a = {big}\nb = = 1\n")).unwrap_err();
1650        assert!(matches!(err, OmnistError::Parse(ref e) if e.line == 2));
1651        // Before it: the same.
1652        let err = read_toml(&format!("b = = 1\na = {big}\n")).unwrap_err();
1653        assert!(matches!(err, OmnistError::Parse(ref e) if e.line == 1));
1654        // Several literals, one past the limit, one not.
1655        let text = format!("a = {big}\nb = 99999999999999999999\nc = 1\n");
1656        assert_int_digits(&read_toml(&text).unwrap_err(), "$.a");
1657    }
1658
1659    #[test]
1660    fn an_overflowing_float_reads_as_an_infinity() {
1661        // D-29.
1662        assert_eq!(scalar_at("a = 1e999\n", "a"), Scalar::Float(f64::INFINITY));
1663        assert_eq!(
1664            scalar_at("a = -1e999\n", "a"),
1665            Scalar::Float(f64::NEG_INFINITY)
1666        );
1667        assert_eq!(scalar_at("a = +1e999\n", "a"), Scalar::Float(f64::INFINITY));
1668        assert_eq!(
1669            scalar_at("a = 1.5e999\n", "a"),
1670            Scalar::Float(f64::INFINITY)
1671        );
1672        assert_eq!(
1673            scalar_at("a = 1_0e9_99\n", "a"),
1674            Scalar::Float(f64::INFINITY)
1675        );
1676        assert_eq!(
1677            scalar_at("a = [1e999]\n", "a"),
1678            Scalar::Float(f64::INFINITY)
1679        );
1680        // And a float that merely underflows is still a finite zero.
1681        assert_eq!(scalar_at("a = 1e-999\n", "a"), Scalar::Float(0.0));
1682    }
1683
1684    #[test]
1685    fn many_big_integers_read_in_linear_time() {
1686        // One reparse per refused literal made this quadratic: 8,000 of them
1687        // took 38 s in a release build. Now one scan finds them all.
1688        let n = 20_000;
1689        let big = "99999999999999999999";
1690        let start = std::time::Instant::now();
1691        let array = format!("a = [{}]\n", vec![big; n].join(", "));
1692        assert_eq!(read_toml(&array).unwrap().root().count("a"), n);
1693        let tables: String = (0..n)
1694            .map(|i| format!("t{i} = {{ x = {big} }}\n"))
1695            .collect();
1696        assert!(read_toml(&tables).is_ok());
1697        let floats = format!("f = [{}]\n", vec!["1e999"; n].join(", "));
1698        assert!(read_toml(&floats).is_ok());
1699        // Generous: an instrumented debug build does all three in a few seconds.
1700        assert!(start.elapsed().as_secs() < 60, "{:?}", start.elapsed());
1701    }
1702
1703    #[test]
1704    fn the_big_number_scan_skips_keys_strings_comments_and_headers() {
1705        let big = "99999999999999999999";
1706        let text = format!(
1707            "# {big}\n\
1708             {big} = 1\n\
1709             k = \"{big}\" # {big}\n\
1710             s = '{big}'\n\
1711             m = \"\"\"\n{big} \\\" {big}\n\"\"\"\n\
1712             e = \"a\\\\\"\n\
1713             l = '''\n{big}\n'''\n\
1714             [88888888888888888888]\n\
1715             v = {big}\n\
1716             [[x]]\n\
1717             w = [\n  {big},\n  {{ {big} = 2, y = {big} }},\n  1e999,\n]\n"
1718        );
1719        let doc = read_toml(&text).unwrap();
1720        // The key is a key, still spelled out; the strings are strings.
1721        assert_eq!(scalar_at(&text, big), Scalar::Int(1.into()));
1722        assert_eq!(scalar_at(&text, "k"), Scalar::Str(big.to_string()));
1723        assert_eq!(scalar_at(&text, "s"), Scalar::Str(big.to_string()));
1724        let h = doc.root().get_one("88888888888888888888").unwrap();
1725        assert_eq!(h.get_one("v").unwrap().value().unwrap().to_string(), big);
1726        // Unbalanced closers and a stray quote never panic the scan.
1727        for bad in ["a = ]]]\n", "a = \"", "a = '''", "a = \"\\"] {
1728            assert!(read_toml(bad).is_err());
1729        }
1730    }
1731
1732    #[test]
1733    fn an_overflowing_float_with_a_long_spelling_reads_as_an_infinity() {
1734        // A float literal with more characters than the digit limit used to
1735        // be mistaken for an over-long integer.
1736        let limits = crate::limits::Limits::default().with_max_int_digits(30);
1737        let doc = read_toml_with("k = 1.0000000000000000000000000001e999\n", &limits).unwrap();
1738        assert_eq!(
1739            *doc.root().get_one("k").unwrap().value().unwrap(),
1740            Scalar::Float(f64::INFINITY)
1741        );
1742    }
1743
1744    #[test]
1745    fn integer_at_i64_max_and_min_round_trip() {
1746        let text = format!("a = {}\nb = {}\n", i64::MAX, i64::MIN);
1747        let doc = read_toml(&text).unwrap();
1748        let root = doc.root();
1749        assert_eq!(
1750            *root.get_one("a").unwrap().value().unwrap(),
1751            Scalar::Int((i64::MAX).into())
1752        );
1753        assert_eq!(
1754            *root.get_one("b").unwrap().value().unwrap(),
1755            Scalar::Int((i64::MIN).into())
1756        );
1757    }
1758
1759    // ------------------------------------------------------------ depth guard
1760
1761    #[test]
1762    fn toml_edit_s_own_recursion_cap_fires_before_our_200_depth_guard_on_read() {
1763        // Empirically found (see this module's doc comment): toml_edit has
1764        // its own internal recursion cap around 80-81 levels of nested
1765        // inline tables -- well below this crate's own MAX_DEPTH (200) --
1766        // so deeply-nested TOML *text* trips the crate's own ParseError
1767        // long before our own DocumentError guard could ever see it. This
1768        // is a real, distinct protection layer from this crate's own depth
1769        // guard, not the same one -- see the next test for the guard this
1770        // module actually reuses.
1771        let mut text = String::from("x = ");
1772        for _ in 0..250 {
1773            text.push_str("{ a = ");
1774        }
1775        text.push('1');
1776        for _ in 0..250 {
1777            text.push_str(" }");
1778        }
1779        text.push('\n');
1780        let err = read_toml(&text).unwrap_err();
1781        assert_depth_refusal(&err);
1782    }
1783
1784    /// omnist-rs#197: `toml_edit`'s own recursion cap (about 80 levels) is
1785    /// reported as `document.limit.depth` at `$`, whether its error carries a
1786    /// span (inline tables, arrays) or not (table headers), and never
1787    /// panics.
1788    fn assert_depth_refusal(err: &OmnistError) {
1789        assert!(
1790            matches!(err, OmnistError::Document(e)
1791                if e.code.as_deref() == Some("document.limit.depth")
1792                    && e.path == "$"
1793                    && e.message.contains("toml_edit")),
1794            "expected document.limit.depth, got {err:?}"
1795        );
1796    }
1797
1798    fn header_chain(n: usize, open: &str, close: &str) -> String {
1799        format!("{open}{}{close}\n", vec!["a"; n].join("."))
1800    }
1801
1802    #[test]
1803    fn deeply_nested_table_headers_are_a_depth_error_not_a_panic() {
1804        // 80 levels read; 81 and beyond (200 and 201 are the depths the issue
1805        // measured, 1000 is far past the port's own limit) are refused.
1806        for (open, close) in [("[", "]"), ("[[", "]]")] {
1807            let ok = read_toml(&header_chain(80, open, close));
1808            assert!(ok.is_ok(), "80 levels of {open}: {ok:?}");
1809            for n in [81, 90, 199, 200, 201, 250, 1000] {
1810                let err = read_toml(&header_chain(n, open, close)).unwrap_err();
1811                assert_depth_refusal(&err);
1812            }
1813        }
1814        // The same through the explicit-limits reader, whatever the limits.
1815        let limits = crate::limits::Limits::default().with_max_depth(250);
1816        let err = read_toml_with(&header_chain(200, "[", "]"), &limits).unwrap_err();
1817        assert_depth_refusal(&err);
1818    }
1819
1820    #[test]
1821    fn deeply_nested_inline_tables_arrays_and_dotted_keys_are_a_depth_error() {
1822        for n in [100usize, 200, 1000] {
1823            let inline = format!("a = {}1{}\n", "{a = ".repeat(n), "}".repeat(n));
1824            assert_depth_refusal(&read_toml(&inline).unwrap_err());
1825            let array = format!("a = {}1{}\n", "[".repeat(n), "]".repeat(n));
1826            assert_depth_refusal(&read_toml(&array).unwrap_err());
1827            let dotted = format!("{} = 1\n", vec!["a"; n].join("."));
1828            assert_depth_refusal(&read_toml(&dotted).unwrap_err());
1829        }
1830    }
1831
1832    /// An over-long integer ahead of a header chain past the cap: the literal
1833    /// is blanked and the text parsed again, which then hits the recursion
1834    /// cap with no span. Still a coded depth error, no panic.
1835    #[test]
1836    fn an_over_cap_integer_before_a_too_deep_header_is_a_depth_error() {
1837        let digits = "9".repeat(5000);
1838        let text = format!("n = {digits}\n{}", header_chain(100, "[", "]"));
1839        assert_depth_refusal(&read_toml(&text).unwrap_err());
1840    }
1841
1842    #[test]
1843    fn deeply_nested_document_write_reuses_doc_construction_depth_guard() {
1844        // Doc::of already rejects nesting past MAX_DEPTH at construction
1845        // time (see this module's doc comment) -- confirms write_toml/
1846        // check_toml never even see an over-deep Doc to begin with, exactly
1847        // json.rs's/yaml.rs's own precedent test for this same guard.
1848        let mut v = Value::Int((0).into());
1849        for _ in 0..=crate::document::MAX_DEPTH {
1850            v = obj(vec![("a", v)]);
1851        }
1852        assert!(Doc::of(&v).is_err());
1853    }
1854
1855    // ------------------------------------------------------------ keys
1856
1857    #[test]
1858    fn writes_quoted_key_for_non_bare_label() {
1859        let v = obj(vec![("has space", Value::Int((1).into()))]);
1860        let doc = doc_of(v);
1861        let text = write_toml(&doc, false, None).unwrap();
1862        assert_eq!(text, "\"has space\" = 1\n");
1863        let doc2 = read_toml(&text).unwrap();
1864        assert_eq!(
1865            *doc2.root().get_one("has space").unwrap().value().unwrap(),
1866            Scalar::Int((1).into())
1867        );
1868    }
1869
1870    #[test]
1871    fn string_with_control_char_and_quote_escapes_on_write() {
1872        let v = obj(vec![(
1873            "a",
1874            Value::Str("line\nbreak \"q\" \t tab".to_string()),
1875        )]);
1876        let doc = doc_of(v);
1877        let text = write_toml(&doc, false, None).unwrap();
1878        let doc2 = read_toml(&text).unwrap();
1879        assert_eq!(
1880            *doc2.root().get_one("a").unwrap().value().unwrap(),
1881            Scalar::Str("line\nbreak \"q\" \t tab".to_string())
1882        );
1883    }
1884
1885    #[test]
1886    fn time_shaped_string_with_offset_is_not_a_real_toml_time_and_stays_quoted() {
1887        // "07:32:00+01:00" matches is_iso_time's shape (offset is optional
1888        // in that regex) but isn't a real TOML local-time literal (which
1889        // has no offset) -- so this module writes it quoted, not as a
1890        // (invalid) bare local-time-with-offset token.
1891        let v = obj(vec![("a", Value::Str("07:32:00+01:00".to_string()))]);
1892        let doc = doc_of(v);
1893        let text = write_toml(&doc, false, None).unwrap();
1894        assert_eq!(text, "a = \"07:32:00+01:00\"\n");
1895    }
1896
1897    #[test]
1898    fn plain_string_that_looks_like_a_date_stays_quoted() {
1899        // Issue #105 (the same fix issue #99 already applied to OML): a
1900        // plain string that merely *looks* date-shaped must stay quoted
1901        // on write -- writing it bare would silently promote it to a
1902        // genuine TOML native date literal on the next read (a different
1903        // Document). Previously diverged from Python here (which always
1904        // kept it a quoted string, since Python's document model
1905        // distinguishes a real `datetime.date` from a `str` at runtime);
1906        // now matches.
1907        let v = obj(vec![("a", Value::Str("1979-05-27".to_string()))]);
1908        let doc = doc_of(v);
1909        let text = write_toml(&doc, false, None).unwrap();
1910        assert_eq!(text, "a = \"1979-05-27\"\n");
1911        let doc2 = read_toml(&text).unwrap();
1912        assert_eq!(
1913            *doc2.root().get_one("a").unwrap().value().unwrap(),
1914            Scalar::Str("1979-05-27".to_string())
1915        );
1916    }
1917
1918    #[test]
1919    fn float_integral_value_still_gets_a_decimal_point() {
1920        let v = obj(vec![("a", Value::Float(1.0))]);
1921        let doc = doc_of(v);
1922        let text = write_toml(&doc, false, None).unwrap();
1923        assert_eq!(text, "a = 1.0\n");
1924    }
1925
1926    #[test]
1927    fn float_non_integral_value_writes_default_repr() {
1928        let v = obj(vec![("a", Value::Float(1.25))]);
1929        let doc = doc_of(v);
1930        let text = write_toml(&doc, false, None).unwrap();
1931        assert_eq!(text, "a = 1.25\n");
1932    }
1933
1934    // ------------------------------------------------------- coverage: white-box
1935
1936    #[test]
1937    fn write_scalar_panics_on_null() {
1938        // `write_scalar` is only ever called on a leaf that has already
1939        // been through `strip_nulls` via the public `write_toml` path --
1940        // white-box confirming that documented invariant directly, same
1941        // rationale as yaml.rs's identical precedent test.
1942        let result = std::panic::catch_unwind(|| {
1943            let mut out = String::new();
1944            write_scalar(&Value::Null, &mut out);
1945        });
1946        assert!(result.is_err());
1947    }
1948
1949    #[test]
1950    fn write_scalar_panics_on_a_non_leaf_value() {
1951        let result = std::panic::catch_unwind(|| {
1952            let mut out = String::new();
1953            write_scalar(&Value::Object(IndexMap::new()), &mut out);
1954        });
1955        assert!(result.is_err());
1956    }
1957
1958    #[test]
1959    fn item_to_value_panics_on_item_none() {
1960        // Item::None is only produced by toml_edit's mutation API (see this
1961        // module's doc comment) -- read_toml never constructs one, so this
1962        // white-box-tests the documented invariant directly.
1963        let result = std::panic::catch_unwind(|| {
1964            let _ = item_to_value(&Item::None, &HashMap::new(), "");
1965        });
1966        assert!(result.is_err());
1967    }
1968
1969    #[test]
1970    fn nested_empty_table_writes_as_inline_empty_table() {
1971        let v = obj(vec![("t", Value::Object(IndexMap::new()))]);
1972        let doc = doc_of(v);
1973        let text = write_toml(&doc, false, None).unwrap();
1974        assert_eq!(text, "t = {}\n");
1975    }
1976
1977    #[test]
1978    fn write_inline_value_on_a_bare_empty_array_writes_the_empty_token() {
1979        // A zero-item array can never come from a real Doc -- the Document
1980        // model represents "array" as repeated same-label edges, so an
1981        // empty array has *zero* edges and the field disappears entirely
1982        // at `Doc::of` construction time (never round-trips back as an
1983        // empty-array `Value`). White-box exercising `write_inline_value`'s
1984        // empty-array arm directly, same rationale and pattern as yaml.rs's
1985        // `write_node_on_a_bare_empty_array_writes_the_flow_empty_token`.
1986        let mut out = String::new();
1987        write_inline_value(&Value::Array(vec![]), &mut out);
1988        assert_eq!(out, "[]");
1989    }
1990
1991    #[test]
1992    fn line_col_reports_line_two_for_an_error_after_a_newline() {
1993        // Forces line_col's newline-counting branch and its `Some(i)`
1994        // column-offset arm, neither reachable from any single-line error.
1995        let err = read_toml("a = 1\nb = \n").unwrap_err();
1996        assert!(
1997            matches!(err, OmnistError::Parse(ref e) if e.line == 2),
1998            "got {err:?}"
1999        );
2000    }
2001
2002    #[test]
2003    fn write_toml_string_escapes_every_control_char_form() {
2004        let v = obj(vec![(
2005            "a",
2006            Value::Str("back\\slash cr\r back\u{08}space form\u{0c}feed ctl\u{01}".to_string()),
2007        )]);
2008        let doc = doc_of(v);
2009        let text = write_toml(&doc, false, None).unwrap();
2010        assert_eq!(
2011            text,
2012            "a = \"back\\\\slash cr\\r back\\bspace form\\ffeed ctl\\u0001\"\n"
2013        );
2014        let doc2 = read_toml(&text).unwrap();
2015        assert_eq!(
2016            *doc2.root().get_one("a").unwrap().value().unwrap(),
2017            Scalar::Str("back\\slash cr\r back\u{08}space form\u{0c}feed ctl\u{01}".to_string())
2018        );
2019    }
2020
2021    // ---------------------------------------------- D-3: format.interleaving-lost
2022    // (issue #156, spec Sec8.3.8. Same MUST as formats-json/basic/
2023    // cross-label-interleaving-lost-and-reported -- TOML shares JSON's
2024    // `to_grouped` grouping, so the loss and the report are identical
2025    // in shape; see json.rs's mirrored tests.)
2026
2027    fn interleaved_doc() -> Doc {
2028        Doc::from_raw(crate::document::RawNode::Edges(vec![
2029            (
2030                "m".to_string(),
2031                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
2032            ),
2033            (
2034                "x".to_string(),
2035                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
2036            ),
2037            (
2038                "m".to_string(),
2039                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
2040            ),
2041        ]))
2042        .unwrap()
2043    }
2044
2045    fn contiguous_repeat_doc() -> Doc {
2046        Doc::from_raw(crate::document::RawNode::Edges(vec![
2047            (
2048                "m".to_string(),
2049                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
2050            ),
2051            (
2052                "m".to_string(),
2053                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
2054            ),
2055            (
2056                "x".to_string(),
2057                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
2058            ),
2059        ]))
2060        .unwrap()
2061    }
2062
2063    #[test]
2064    fn reports_interleaving_lost_on_write() {
2065        let doc = interleaved_doc();
2066        let mut report = crate::report::WriteReport::new();
2067        write_toml(&doc, false, Some(&mut report)).unwrap();
2068        let adjustments = report.adjustments();
2069        assert_eq!(adjustments.len(), 1);
2070        assert_eq!(adjustments[0].path, "$");
2071        assert_eq!(adjustments[0].code, "format.interleaving-lost");
2072        assert_eq!(adjustments[0].severity, crate::report::Severity::Warning);
2073    }
2074
2075    #[test]
2076    fn check_toml_reports_interleaving_lost() {
2077        let rep = check_toml(&interleaved_doc());
2078        assert_eq!(rep.adjustments().len(), 1);
2079        assert_eq!(rep.adjustments()[0].code, "format.interleaving-lost");
2080    }
2081
2082    #[test]
2083    fn contiguous_repeated_label_does_not_report_interleaving_lost() {
2084        let doc = contiguous_repeat_doc();
2085        let mut report = crate::report::WriteReport::new();
2086        write_toml(&doc, false, Some(&mut report)).unwrap();
2087        assert!(report.is_empty());
2088        assert!(check_toml(&doc).is_empty());
2089    }
2090}