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