Skip to main content

omnist/
report.rs

1//! Adjustment reports for lossy writes.
2//!
3//! Ported from `~/dev/omnist/omnist/report.py`. Writing a [`crate::document::Doc`]
4//! to a format that can't hold every value losslessly (JSON has no native
5//! date/time type and no `NaN`/`Infinity`; TOML has no `null`) means the
6//! writer has to *adjust* the data. Each adjustment is recorded as an
7//! [`Adjustment`] in a [`WriteReport`] rather than lost silently. The same
8//! report drives three behaviours, matching the Python reference exactly:
9//!
10//! * **lenient** (default) -- adjust and move on; the caller may ignore the
11//!   report.
12//! * **inspect** -- pass a `report: Option<&mut WriteReport>` to a writer (or
13//!   call a format's `check_*`) to see what changed without stopping.
14//! * **strict** (`strict: true`) -- [`finish_write`] returns
15//!   [`crate::error::WriteError`] (carrying the report) if anything had to
16//!   be adjusted.
17//!
18//! Each adjustment has a [`Severity`]: `Warning` (conventional/recoverable --
19//! a date written as a string) or `Error` (likely to surprise or corrupt --
20//! `NaN` written as JSON `null`). `strict` ignores severity and raises on
21//! anything, matching Python's `finish_write`.
22
23use crate::error::WriteError;
24
25/// The path-numbering rule every codec scanner applies (spec E-10): a label
26/// that occurs more than once in a node is indexed on EVERY occurrence, the
27/// first included (`"{path}.{label}[0]"`, `"{path}.{label}[1]"`); a label
28/// that occurs once has no index (`"{path}.{label}"`). `index` is `Some(i)`
29/// exactly when the label repeats ([`occurrence_index`] decides). Lives next
30/// to [`Adjustment`], whose `path` field this format feeds.
31///
32/// Used by scanners that build the full path eagerly (`toml.rs::strip_nulls`,
33/// `xml.rs::scan_xml_cursor`, whose own recursion doesn't fit the shared
34/// `formats::visit_grouped` walker -- see that function's doc comment). The
35/// grouped-`Value` walkers (`json`/`yaml`) instead reuse a single path buffer
36/// via [`push_child_path`], never allocating a `String` per edge.
37pub(crate) fn child_path(path: &str, label: &str, index: Option<usize>) -> String {
38    let mut s = String::with_capacity(path.len() + label.len() + 8);
39    s.push_str(path);
40    push_child_path(&mut s, label, index);
41    s
42}
43
44/// Same rule as [`child_path`], but writes into a caller-owned buffer instead
45/// of allocating a new `String`. Callers that walk a whole tree can push a
46/// segment, recurse, then `buf.truncate` back -- one buffer, reused for
47/// every edge, instead of one allocation per edge.
48pub(crate) fn push_child_path(buf: &mut String, label: &str, index: Option<usize>) {
49    use std::fmt::Write;
50    push_label_segment(buf, label);
51    if let Some(index) = index {
52        write!(buf, "[{index}]").expect("writing to a String never fails");
53    }
54}
55
56/// Whether a character forces a label to be written bracket-quoted in a
57/// Document path (spec E-36): `.` `[` `]` `"` `\`, a control character
58/// (U+0000..U+001F), U+007F, U+0085, U+2028 or U+2029.
59fn is_unsafe_path_char(c: char) -> bool {
60    matches!(c, '.' | '[' | ']' | '"' | '\\')
61        || c <= '\u{1f}'
62        || matches!(c, '\u{7f}' | '\u{85}' | '\u{2028}' | '\u{2029}')
63}
64
65/// Appends one label segment to a Document path buffer per spec E-36: bare
66/// `.label`, or `["label"]` (no dot) with the pinned escapes when the label
67/// is empty or contains an unsafe character. The single renderer every
68/// Document-path builder uses.
69pub(crate) fn push_label_segment(buf: &mut String, label: &str) {
70    use std::fmt::Write;
71    if !label.is_empty() && !label.chars().any(is_unsafe_path_char) {
72        buf.push('.');
73        buf.push_str(label);
74        return;
75    }
76    buf.push_str("[\"");
77    for c in label.chars() {
78        match c {
79            '"' => buf.push_str("\\\""),
80            '\\' => buf.push_str("\\\\"),
81            '\n' => buf.push_str("\\n"),
82            '\r' => buf.push_str("\\r"),
83            '\t' => buf.push_str("\\t"),
84            c if is_unsafe_path_char(c) && !matches!(c, '.' | '[' | ']') => {
85                write!(buf, "\\u{:04x}", c as u32).expect("writing to a String never fails");
86            }
87            c => buf.push(c),
88        }
89    }
90    buf.push_str("\"]");
91}
92
93/// The E-10 index of the `i`-th (0-based) occurrence of a label that occurs
94/// `count` times in its node: `Some(i)` when `count > 1`, `None` when the
95/// label occurs once. The count is of the node's edges, never of the
96/// declared cardinality.
97pub(crate) fn occurrence_index(i: usize, count: usize) -> Option<usize> {
98    (count > 1).then_some(i)
99}
100
101/// How surprising/lossy a single [`Adjustment`] is. `strict` mode raises on
102/// either severity; only [`WriteReport::is_ok`] (Python's `__bool__`)
103/// distinguishes them.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Severity {
106    /// Recoverable adjustment (e.g. date written as string).
107    Warning,
108    /// Lossy or corrupting adjustment (e.g. NaN written as null).
109    Error,
110}
111
112/// One thing a writer changed to make the data fit the target format.
113/// Mirrors Python's `Adjustment` `NamedTuple` field-for-field.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Adjustment {
116    /// Same path style as validation, e.g. `"$.order.total"`.
117    pub path: String,
118    /// Stable, machine-checkable code, e.g. `"format.value-stringified"`.
119    pub code: String,
120    /// Human-readable sentence.
121    pub message: String,
122    /// The severity level of this adjustment.
123    pub severity: Severity,
124}
125
126/// Everything a writer adjusted. Mirrors Python's `WriteReport`: truthy
127/// (see [`WriteReport::is_ok`]) when there are no error-severity entries --
128/// warnings alone are fine.
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct WriteReport {
131    adjustments: Vec<Adjustment>,
132}
133
134impl WriteReport {
135    /// Create an empty `WriteReport`.
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Record one adjustment.
141    pub fn add(
142        &mut self,
143        path: impl Into<String>,
144        code: impl Into<String>,
145        message: impl Into<String>,
146        severity: Severity,
147    ) {
148        self.adjustments.push(Adjustment {
149            path: path.into(),
150            code: code.into(),
151            message: message.into(),
152            severity,
153        });
154    }
155
156    /// Rewrites the path prefix `from` to `to` on the adjustments recorded
157    /// at positions `range` (those whose path is `from` itself or continues
158    /// it with `.` or `[`). The XML reader uses it to index the first
159    /// occurrence of a label once a later sibling shows the label repeats
160    /// (E-10): the diagnostics of the first occurrence's whole subtree were
161    /// recorded under its bare path.
162    pub(crate) fn reindex_prefix(&mut self, range: std::ops::Range<usize>, from: &str, to: &str) {
163        for a in &mut self.adjustments[range] {
164            if let Some(rest) = a.path.strip_prefix(from)
165                && (rest.is_empty() || rest.starts_with(['.', '[']))
166            {
167                a.path = format!("{to}{rest}");
168            }
169        }
170    }
171
172    /// All recorded adjustments, in the order they were added.
173    pub fn adjustments(&self) -> &[Adjustment] {
174        &self.adjustments
175    }
176
177    /// Only the `Warning`-severity adjustments.
178    pub fn warnings(&self) -> Vec<&Adjustment> {
179        self.adjustments
180            .iter()
181            .filter(|a| a.severity == Severity::Warning)
182            .collect()
183    }
184
185    /// Only the `Error`-severity adjustments.
186    pub fn errors(&self) -> Vec<&Adjustment> {
187        self.adjustments
188            .iter()
189            .filter(|a| a.severity == Severity::Error)
190            .collect()
191    }
192
193    /// Python's `__bool__`: `true` (safe) iff there are no error-severity
194    /// entries -- warnings alone don't flip this.
195    pub fn is_ok(&self) -> bool {
196        self.errors().is_empty()
197    }
198
199    /// Returns `true` iff no adjustments have been recorded.
200    pub fn is_empty(&self) -> bool {
201        self.adjustments.is_empty()
202    }
203
204    /// Total number of recorded adjustments.
205    pub fn len(&self) -> usize {
206        self.adjustments.len()
207    }
208
209    /// Iterator over all recorded adjustments.
210    pub fn iter(&self) -> std::slice::Iter<'_, Adjustment> {
211        self.adjustments.iter()
212    }
213}
214
215impl<'a> IntoIterator for &'a WriteReport {
216    type Item = &'a Adjustment;
217    type IntoIter = std::slice::Iter<'a, Adjustment>;
218
219    fn into_iter(self) -> Self::IntoIter {
220        self.adjustments.iter()
221    }
222}
223
224impl std::fmt::Display for WriteReport {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        if self.adjustments.is_empty() {
227            return write!(f, "no adjustments");
228        }
229        let mut first = true;
230        for a in &self.adjustments {
231            if !first {
232                writeln!(f)?;
233            }
234            first = false;
235            let sev = match a.severity {
236                Severity::Warning => "warning",
237                Severity::Error => "error",
238            };
239            write!(f, "{sev}: {}: {}", a.path, a.message)?;
240        }
241        Ok(())
242    }
243}
244
245/// The standard `strict`/`report` handling every format writer applies to
246/// its own accumulated [`WriteReport`], mirroring Python's `finish_write`.
247///
248/// If `report` is given, `rep`'s adjustments are copied into it. If `strict`
249/// and `rep` has any adjustments, returns [`WriteError`] carrying `rep`.
250/// Otherwise returns `text`.
251pub fn finish_write(
252    text: String,
253    rep: WriteReport,
254    strict: bool,
255    report: Option<&mut WriteReport>,
256) -> Result<String, WriteError> {
257    if let Some(out) = report {
258        out.adjustments.extend(rep.adjustments.iter().cloned());
259    }
260    if strict && !rep.is_empty() {
261        return Err(WriteError::with_report(rep.to_string(), rep));
262    }
263    Ok(text)
264}
265
266/// Build the [`WriteError`] for a value/shape a target format's syntax
267/// cannot represent at all -- spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24):
268/// unlike every other row in the codec-adjustments table, these have no
269/// single well-defined substitute to fall back to (either two distinct
270/// inputs can collide on the same substituted output, or the substitute
271/// erases the edge's existence entirely), so the write fails unconditionally
272/// -- regardless of `strict` -- rather than adjusting and reporting a
273/// warning/error in the [`WriteReport`]. Used by the three write-side
274/// unconditional-failure cases: an XML-illegal label/tag name, a null leaf
275/// written to a format with no null token (TOML), NaN/Infinity written to
276/// JSON, and an empty internal node written to XML.
277///
278/// Mirrors the "{path}: {message}" convention [`WriteError`]'s siblings
279/// (`DocumentError`, `SchemaError`) use for embedding path in text --
280/// The error carries the structured `path` and `code`
281/// (`write.unsupported-value`) as fields, and the message also embeds them
282/// for human readers.
283pub(crate) fn unsupported_value_error(path: &str, detail: impl std::fmt::Display) -> WriteError {
284    WriteError::with_diagnostic(
285        path,
286        "write.unsupported-value",
287        format!("{path}: write.unsupported-value: {detail}"),
288    )
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn new_report_is_empty_and_ok() {
297        let rep = WriteReport::new();
298        assert!(rep.is_empty());
299        assert_eq!(rep.len(), 0);
300        assert!(rep.is_ok());
301        assert_eq!(rep.to_string(), "no adjustments");
302    }
303
304    #[test]
305    fn add_records_warning_and_error_separately() {
306        let mut rep = WriteReport::new();
307        rep.add(
308            "$.a",
309            "temporal.stringified",
310            "written as a string",
311            Severity::Warning,
312        );
313        rep.add(
314            "$.b",
315            "float.special",
316            "NaN is not valid JSON",
317            Severity::Error,
318        );
319        assert_eq!(rep.len(), 2);
320        assert!(!rep.is_empty());
321        assert_eq!(rep.warnings().len(), 1);
322        assert_eq!(rep.errors().len(), 1);
323        assert!(
324            !rep.is_ok(),
325            "an error-severity entry makes the report falsy"
326        );
327    }
328
329    #[test]
330    fn warnings_only_report_is_still_ok() {
331        let mut rep = WriteReport::new();
332        rep.add(
333            "$.a",
334            "temporal.stringified",
335            "written as a string",
336            Severity::Warning,
337        );
338        assert!(rep.is_ok());
339    }
340
341    #[test]
342    fn display_lists_each_adjustment() {
343        let mut rep = WriteReport::new();
344        rep.add("$.a", "code.a", "message a", Severity::Warning);
345        rep.add("$.b", "code.b", "message b", Severity::Error);
346        assert_eq!(
347            rep.to_string(),
348            "warning: $.a: message a\nerror: $.b: message b"
349        );
350    }
351
352    #[test]
353    fn iter_and_into_iter_yield_adjustments_in_order() {
354        let mut rep = WriteReport::new();
355        rep.add("$.a", "code.a", "m", Severity::Warning);
356        rep.add("$.b", "code.b", "m", Severity::Warning);
357        let paths: Vec<&str> = rep.iter().map(|a| a.path.as_str()).collect();
358        assert_eq!(paths, vec!["$.a", "$.b"]);
359        let paths2: Vec<&str> = (&rep).into_iter().map(|a| a.path.as_str()).collect();
360        assert_eq!(paths2, vec!["$.a", "$.b"]);
361    }
362
363    #[test]
364    fn adjustments_accessor_matches_add_order() {
365        let mut rep = WriteReport::new();
366        rep.add("$.a", "code.a", "m", Severity::Warning);
367        assert_eq!(rep.adjustments().len(), 1);
368        assert_eq!(rep.adjustments()[0].code, "code.a");
369    }
370
371    #[test]
372    fn finish_write_lenient_returns_text_regardless_of_adjustments() {
373        let mut rep = WriteReport::new();
374        rep.add("$.a", "code.a", "m", Severity::Error);
375        let out = finish_write("text".to_string(), rep, false, None).unwrap();
376        assert_eq!(out, "text");
377    }
378
379    #[test]
380    fn finish_write_strict_raises_on_any_adjustment() {
381        let mut rep = WriteReport::new();
382        rep.add(
383            "$.a",
384            "float.special",
385            "NaN is not valid JSON",
386            Severity::Warning,
387        );
388        let err = finish_write("text".to_string(), rep, true, None).unwrap_err();
389        // message uses Display (path + human text), not the machine `code`.
390        assert!(!err.to_string().contains("float.special"));
391        assert!(err.to_string().contains("$.a"));
392        assert_eq!(err.report().unwrap().len(), 1);
393    }
394
395    #[test]
396    fn finish_write_strict_with_no_adjustments_still_returns_text() {
397        let rep = WriteReport::new();
398        let out = finish_write("text".to_string(), rep, true, None).unwrap();
399        assert_eq!(out, "text");
400    }
401
402    #[test]
403    fn finish_write_copies_into_caller_supplied_report() {
404        let mut rep = WriteReport::new();
405        rep.add("$.a", "code.a", "m", Severity::Warning);
406        let mut out_report = WriteReport::new();
407        let out = finish_write("text".to_string(), rep, false, Some(&mut out_report)).unwrap();
408        assert_eq!(out, "text");
409        assert_eq!(out_report.len(), 1);
410        assert_eq!(out_report.adjustments()[0].path, "$.a");
411    }
412
413    #[test]
414    fn finish_write_strict_still_populates_caller_report_before_erroring() {
415        let mut rep = WriteReport::new();
416        rep.add("$.a", "code.a", "m", Severity::Error);
417        let mut out_report = WriteReport::new();
418        let err = finish_write("text".to_string(), rep, true, Some(&mut out_report)).unwrap_err();
419        assert_eq!(out_report.len(), 1);
420        assert_eq!(err.report().unwrap().len(), 1);
421    }
422}
423
424#[cfg(test)]
425mod label_segment_tests {
426    use super::*;
427
428    fn seg(label: &str) -> String {
429        let mut s = String::new();
430        push_label_segment(&mut s, label);
431        s
432    }
433
434    #[test]
435    fn a_label_that_needs_no_quoting_is_bare() {
436        for l in ["name", "a b", "$", "é", "0", "1bad", "日本"] {
437            assert_eq!(seg(l), format!(".{l}"));
438        }
439    }
440
441    #[test]
442    fn a_label_that_needs_quoting_is_bracket_quoted_with_the_pinned_escapes() {
443        for (label, want) in [
444            ("", r#"[""]"#),
445            ("a.b", r#"["a.b"]"#),
446            ("item[0]", r#"["item[0]"]"#),
447            ("a]b", r#"["a]b"]"#),
448            ("q\"x", r#"["q\"x"]"#),
449            ("a\\b", r#"["a\\b"]"#),
450            ("l1\nl2", r#"["l1\nl2"]"#),
451            ("a\tb\rc", r#"["a\tb\rc"]"#),
452            ("a\u{1f}b", r#"["a\u001fb"]"#),
453            ("a\0b", r#"["a\u0000b"]"#),
454            ("a\u{7f}b", r#"["a\u007fb"]"#),
455            ("a\u{85}b", r#"["a\u0085b"]"#),
456            ("a\u{2028}b", "[\"a\\u2028b\"]"),
457            ("a\u{2029}b", "[\"a\\u2029b\"]"),
458            ("é.ü", r#"["é.ü"]"#),
459        ] {
460            assert_eq!(seg(label), want, "label {label:?}");
461        }
462    }
463
464    #[test]
465    fn the_occurrence_index_follows_either_form() {
466        assert_eq!(child_path("$", "a.b", Some(1)), r#"$["a.b"][1]"#);
467        assert_eq!(child_path("$.a", "b", None), "$.a.b");
468        assert_eq!(child_path("$", "item", Some(0)), "$.item[0]");
469        assert_eq!(child_path("$", "a", None), "$.a");
470        assert_eq!(
471            crate::document::join(&child_path("$", "x.y", None), "z"),
472            r#"$["x.y"].z"#
473        );
474    }
475}