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 to a same-label
26/// array's entries: the first occurrence gets the bare path
27/// (`"{path}.{label}"`), later ones are indexed (`"{path}.{label}[{i}]"`).
28/// Lives next to [`Adjustment`], whose `path` field this format feeds.
29///
30/// Used by scanners that build the full path eagerly (`toml.rs::strip_nulls`,
31/// `xml.rs::scan_xml_into`, whose own recursion doesn't fit the shared
32/// `formats::visit_grouped` walker -- see that function's doc comment). The
33/// grouped-`Value` walkers (`json`/`yaml`) instead reuse a single path buffer
34/// via [`push_child_path`], never allocating a `String` per edge.
35pub(crate) fn child_path(path: &str, label: &str, index: usize) -> String {
36    let mut s = String::with_capacity(path.len() + label.len() + 8);
37    s.push_str(path);
38    push_child_path(&mut s, label, index);
39    s
40}
41
42/// Same rule as [`child_path`], but writes into a caller-owned buffer instead
43/// of allocating a new `String`. Callers that walk a whole tree can push a
44/// segment, recurse, then `buf.truncate` back -- one buffer, reused for
45/// every edge, instead of one allocation per edge.
46pub(crate) fn push_child_path(buf: &mut String, label: &str, index: usize) {
47    use std::fmt::Write;
48    buf.push('.');
49    buf.push_str(label);
50    if index != 0 {
51        write!(buf, "[{index}]").expect("writing to a String never fails");
52    }
53}
54
55/// How surprising/lossy a single [`Adjustment`] is. `strict` mode raises on
56/// either severity; only [`WriteReport::is_ok`] (Python's `__bool__`)
57/// distinguishes them.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Severity {
60    /// Recoverable adjustment (e.g. date written as string).
61    Warning,
62    /// Lossy or corrupting adjustment (e.g. NaN written as null).
63    Error,
64}
65
66/// One thing a writer changed to make the data fit the target format.
67/// Mirrors Python's `Adjustment` `NamedTuple` field-for-field.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Adjustment {
70    /// Same path style as validation, e.g. `"$.order.total"`.
71    pub path: String,
72    /// Stable, machine-checkable code, e.g. `"null.omitted"`.
73    pub code: String,
74    /// Human-readable sentence.
75    pub message: String,
76    /// The severity level of this adjustment.
77    pub severity: Severity,
78}
79
80/// Everything a writer adjusted. Mirrors Python's `WriteReport`: truthy
81/// (see [`WriteReport::is_ok`]) when there are no error-severity entries --
82/// warnings alone are fine.
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct WriteReport {
85    adjustments: Vec<Adjustment>,
86}
87
88impl WriteReport {
89    /// Create an empty `WriteReport`.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Record one adjustment.
95    pub fn add(
96        &mut self,
97        path: impl Into<String>,
98        code: impl Into<String>,
99        message: impl Into<String>,
100        severity: Severity,
101    ) {
102        self.adjustments.push(Adjustment {
103            path: path.into(),
104            code: code.into(),
105            message: message.into(),
106            severity,
107        });
108    }
109
110    /// All recorded adjustments, in the order they were added.
111    pub fn adjustments(&self) -> &[Adjustment] {
112        &self.adjustments
113    }
114
115    /// Only the `Warning`-severity adjustments.
116    pub fn warnings(&self) -> Vec<&Adjustment> {
117        self.adjustments
118            .iter()
119            .filter(|a| a.severity == Severity::Warning)
120            .collect()
121    }
122
123    /// Only the `Error`-severity adjustments.
124    pub fn errors(&self) -> Vec<&Adjustment> {
125        self.adjustments
126            .iter()
127            .filter(|a| a.severity == Severity::Error)
128            .collect()
129    }
130
131    /// Python's `__bool__`: `true` (safe) iff there are no error-severity
132    /// entries -- warnings alone don't flip this.
133    pub fn is_ok(&self) -> bool {
134        self.errors().is_empty()
135    }
136
137    /// Returns `true` iff no adjustments have been recorded.
138    pub fn is_empty(&self) -> bool {
139        self.adjustments.is_empty()
140    }
141
142    /// Total number of recorded adjustments.
143    pub fn len(&self) -> usize {
144        self.adjustments.len()
145    }
146
147    /// Iterator over all recorded adjustments.
148    pub fn iter(&self) -> std::slice::Iter<'_, Adjustment> {
149        self.adjustments.iter()
150    }
151}
152
153impl<'a> IntoIterator for &'a WriteReport {
154    type Item = &'a Adjustment;
155    type IntoIter = std::slice::Iter<'a, Adjustment>;
156
157    fn into_iter(self) -> Self::IntoIter {
158        self.adjustments.iter()
159    }
160}
161
162impl std::fmt::Display for WriteReport {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        if self.adjustments.is_empty() {
165            return write!(f, "no adjustments");
166        }
167        let mut first = true;
168        for a in &self.adjustments {
169            if !first {
170                writeln!(f)?;
171            }
172            first = false;
173            let sev = match a.severity {
174                Severity::Warning => "warning",
175                Severity::Error => "error",
176            };
177            write!(f, "{sev}: {}: {}", a.path, a.message)?;
178        }
179        Ok(())
180    }
181}
182
183/// The standard `strict`/`report` handling every format writer applies to
184/// its own accumulated [`WriteReport`], mirroring Python's `finish_write`.
185///
186/// If `report` is given, `rep`'s adjustments are copied into it. If `strict`
187/// and `rep` has any adjustments, returns [`WriteError`] carrying `rep`.
188/// Otherwise returns `text`.
189pub fn finish_write(
190    text: String,
191    rep: WriteReport,
192    strict: bool,
193    report: Option<&mut WriteReport>,
194) -> Result<String, WriteError> {
195    if let Some(out) = report {
196        out.adjustments.extend(rep.adjustments.iter().cloned());
197    }
198    if strict && !rep.is_empty() {
199        return Err(WriteError::with_report(rep.to_string(), rep));
200    }
201    Ok(text)
202}
203
204/// Build the [`WriteError`] for a value/shape a target format's syntax
205/// cannot represent at all -- spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24):
206/// unlike every other row in the codec-adjustments table, these have no
207/// single well-defined substitute to fall back to (either two distinct
208/// inputs can collide on the same substituted output, or the substitute
209/// erases the edge's existence entirely), so the write fails unconditionally
210/// -- regardless of `strict` -- rather than adjusting and reporting a
211/// warning/error in the [`WriteReport`]. Used by the three write-side
212/// unconditional-failure cases: an XML-illegal label/tag name, a null leaf
213/// written to a format with no null token (TOML), NaN/Infinity written to
214/// JSON, and an empty internal node written to XML.
215///
216/// Mirrors the "{path}: {message}" convention [`WriteError`]'s siblings
217/// (`DocumentError`, `SchemaError`) use for embedding path in text --
218/// `WriteError` itself has no structured `path`/`code` fields (see its doc
219/// comment), so the stable code `write.unsupported-value` is embedded in
220/// the message alongside the path, exactly like every other `WriteError`
221/// site in this crate.
222pub(crate) fn unsupported_value_error(path: &str, detail: impl std::fmt::Display) -> WriteError {
223    WriteError::new(format!("{path}: write.unsupported-value: {detail}"))
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn new_report_is_empty_and_ok() {
232        let rep = WriteReport::new();
233        assert!(rep.is_empty());
234        assert_eq!(rep.len(), 0);
235        assert!(rep.is_ok());
236        assert_eq!(rep.to_string(), "no adjustments");
237    }
238
239    #[test]
240    fn add_records_warning_and_error_separately() {
241        let mut rep = WriteReport::new();
242        rep.add(
243            "$.a",
244            "temporal.stringified",
245            "written as a string",
246            Severity::Warning,
247        );
248        rep.add(
249            "$.b",
250            "float.special",
251            "NaN is not valid JSON",
252            Severity::Error,
253        );
254        assert_eq!(rep.len(), 2);
255        assert!(!rep.is_empty());
256        assert_eq!(rep.warnings().len(), 1);
257        assert_eq!(rep.errors().len(), 1);
258        assert!(
259            !rep.is_ok(),
260            "an error-severity entry makes the report falsy"
261        );
262    }
263
264    #[test]
265    fn warnings_only_report_is_still_ok() {
266        let mut rep = WriteReport::new();
267        rep.add(
268            "$.a",
269            "temporal.stringified",
270            "written as a string",
271            Severity::Warning,
272        );
273        assert!(rep.is_ok());
274    }
275
276    #[test]
277    fn display_lists_each_adjustment() {
278        let mut rep = WriteReport::new();
279        rep.add("$.a", "code.a", "message a", Severity::Warning);
280        rep.add("$.b", "code.b", "message b", Severity::Error);
281        assert_eq!(
282            rep.to_string(),
283            "warning: $.a: message a\nerror: $.b: message b"
284        );
285    }
286
287    #[test]
288    fn iter_and_into_iter_yield_adjustments_in_order() {
289        let mut rep = WriteReport::new();
290        rep.add("$.a", "code.a", "m", Severity::Warning);
291        rep.add("$.b", "code.b", "m", Severity::Warning);
292        let paths: Vec<&str> = rep.iter().map(|a| a.path.as_str()).collect();
293        assert_eq!(paths, vec!["$.a", "$.b"]);
294        let paths2: Vec<&str> = (&rep).into_iter().map(|a| a.path.as_str()).collect();
295        assert_eq!(paths2, vec!["$.a", "$.b"]);
296    }
297
298    #[test]
299    fn adjustments_accessor_matches_add_order() {
300        let mut rep = WriteReport::new();
301        rep.add("$.a", "code.a", "m", Severity::Warning);
302        assert_eq!(rep.adjustments().len(), 1);
303        assert_eq!(rep.adjustments()[0].code, "code.a");
304    }
305
306    #[test]
307    fn finish_write_lenient_returns_text_regardless_of_adjustments() {
308        let mut rep = WriteReport::new();
309        rep.add("$.a", "code.a", "m", Severity::Error);
310        let out = finish_write("text".to_string(), rep, false, None).unwrap();
311        assert_eq!(out, "text");
312    }
313
314    #[test]
315    fn finish_write_strict_raises_on_any_adjustment() {
316        let mut rep = WriteReport::new();
317        rep.add(
318            "$.a",
319            "float.special",
320            "NaN is not valid JSON",
321            Severity::Warning,
322        );
323        let err = finish_write("text".to_string(), rep, true, None).unwrap_err();
324        // message uses Display (path + human text), not the machine `code`.
325        assert!(!err.to_string().contains("float.special"));
326        assert!(err.to_string().contains("$.a"));
327        assert_eq!(err.report().unwrap().len(), 1);
328    }
329
330    #[test]
331    fn finish_write_strict_with_no_adjustments_still_returns_text() {
332        let rep = WriteReport::new();
333        let out = finish_write("text".to_string(), rep, true, None).unwrap();
334        assert_eq!(out, "text");
335    }
336
337    #[test]
338    fn finish_write_copies_into_caller_supplied_report() {
339        let mut rep = WriteReport::new();
340        rep.add("$.a", "code.a", "m", Severity::Warning);
341        let mut out_report = WriteReport::new();
342        let out = finish_write("text".to_string(), rep, false, Some(&mut out_report)).unwrap();
343        assert_eq!(out, "text");
344        assert_eq!(out_report.len(), 1);
345        assert_eq!(out_report.adjustments()[0].path, "$.a");
346    }
347
348    #[test]
349    fn finish_write_strict_still_populates_caller_report_before_erroring() {
350        let mut rep = WriteReport::new();
351        rep.add("$.a", "code.a", "m", Severity::Error);
352        let mut out_report = WriteReport::new();
353        let err = finish_write("text".to_string(), rep, true, Some(&mut out_report)).unwrap_err();
354        assert_eq!(out_report.len(), 1);
355        assert_eq!(err.report().unwrap().len(), 1);
356    }
357}