Skip to main content

omnist/
error.rs

1//! Error hierarchy, `thiserror`-based (per issue #1 §5).
2//!
3//! `OmnistError` is the crate-wide top-level error; each module contributes
4//! its own leaf error type as a variant (mirroring Python's
5//! `OmnistError`/`SchemaError`/`ParseError`/`WriteError`/`DocumentError`
6//! hierarchy in `~/dev/omnist/omnist/errors.py`). This issue (#4) adds only
7//! `DocumentError`; the other leaf types land with their own modules.
8
9use thiserror::Error;
10
11/// A Document operation is invalid, or a plain value is not a legal Document.
12///
13/// Raised by [`crate::document`] when a construction or mutation would
14/// produce something outside the Document model (a bare top-level array, an
15/// array of arrays, nesting past the max depth) or when an operation doesn't
16/// fit the node it's called on (e.g. reading `.value()` on an internal
17/// node). The message carries the offending path, matching the Python
18/// reference's `DocumentError` convention of embedding `path` in the text.
19#[derive(Debug, Error, Clone, PartialEq, Eq)]
20#[error("{path}: {message}")]
21pub struct DocumentError {
22    /// The path inside the document where the error occurred.
23    pub path: String,
24    /// The spec's stable machine-readable code (omnist-spec §8.3.2 and
25    /// §8.3.8, e.g. `document.unlabeled-element`,
26    /// `format.dtd-forbidden`), when the failure is one the taxonomy names.
27    /// `None` for API-misuse errors (reading `.value()` on an internal
28    /// node, `get_one` on a repeated label, ...) that no conformance
29    /// diagnostic describes.
30    pub code: Option<String>,
31    /// Human-readable error description.
32    pub message: String,
33}
34
35impl DocumentError {
36    /// Construct a new `DocumentError` at the given path, with no
37    /// taxonomy code (an API-misuse error).
38    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
39        Self {
40            path: path.into(),
41            code: None,
42            message: message.into(),
43        }
44    }
45
46    /// Construct a `DocumentError` carrying a spec taxonomy `code`.
47    pub fn with_code(
48        path: impl Into<String>,
49        code: impl Into<String>,
50        message: impl Into<String>,
51    ) -> Self {
52        Self {
53            path: path.into(),
54            code: Some(code.into()),
55            message: message.into(),
56        }
57    }
58}
59
60/// A Schema definition is invalid (bad cardinality, duplicate field label,
61/// unknown scalar/ref name) -- raised by [`crate::schema`], [`crate::osd`],
62/// [`crate::infer`], and [`crate::ops::extract`].
63///
64/// Breaking change in issue #122: `SchemaError` now carries machine-readable
65/// `path` and `code` fields alongside human-readable `message`, matching the
66/// spec's schema well-formedness and algebra error taxonomy (spec §8.3.3 & §8.3.6).
67#[derive(Debug, Error, Clone, PartialEq, Eq)]
68#[error("{message}")]
69pub struct SchemaError {
70    /// The path (record/field context or "$") where the schema error occurred.
71    pub path: String,
72    /// Stable machine-readable error code (e.g. "schema.unknown-type", spec §8.3.3).
73    pub code: String,
74    /// Human-readable error description.
75    pub message: String,
76}
77
78impl SchemaError {
79    /// Construct a structured `SchemaError` with path, code, and message.
80    pub fn new(
81        path: impl Into<String>,
82        code: impl Into<String>,
83        message: impl Into<String>,
84    ) -> Self {
85        Self {
86            path: path.into(),
87            code: code.into(),
88            message: message.into(),
89        }
90    }
91}
92
93/// An OML source string could not be parsed -- raised by
94/// [`crate::oml::read_oml`], mirroring Python's `ParseError` in
95/// `~/dev/omnist/omnist/errors.py`. Carries the same "line N, col N: msg"
96/// convention the Python reference's scanner/parser produce.
97#[derive(Debug, Error, Clone, PartialEq, Eq)]
98#[error("line {line}, col {col}: {message}")]
99pub struct ParseError {
100    /// Line number where parsing failed (1-indexed).
101    pub line: usize,
102    /// Column number where parsing failed (1-indexed).
103    pub col: usize,
104    /// The spec's stable machine-readable code (omnist-spec §8.3.1, e.g.
105    /// `parse.unexpected-token`, `parse.codec-syntax`).
106    pub code: String,
107    /// Human-readable parse failure description.
108    pub message: String,
109}
110
111impl ParseError {
112    /// Construct a new `ParseError` with position coordinates and the
113    /// spec's `parse.*` code.
114    pub fn new(
115        line: usize,
116        col: usize,
117        code: impl Into<String>,
118        message: impl Into<String>,
119    ) -> Self {
120        Self {
121            line,
122            col,
123            code: code.into(),
124            message: message.into(),
125        }
126    }
127
128    /// Construct a `parse.codec-syntax` error (omnist-spec §8.3.1): input a
129    /// JSON/YAML/TOML/XML codec could not accept, whether malformed in its
130    /// own format or refused by a byte-level precondition the spec imposes
131    /// ahead of the codec (E-24).
132    pub fn codec_syntax(line: usize, col: usize, message: impl Into<String>) -> Self {
133        Self::new(line, col, "parse.codec-syntax", message)
134    }
135
136    /// The `text position` path (`line:col`, omnist-spec §8.4) of this
137    /// error.
138    pub fn position(&self) -> String {
139        format!("{}:{}", self.line, self.col)
140    }
141}
142
143/// An unknown format name was looked up in the format registry -- raised by
144/// [`crate::registry::get_format`] (and therefore
145/// [`crate::document::Doc::from_format`]/`to_format`/`check_format`),
146/// mirroring Python's `OmnistError(f"unknown format {name!r}; registered:
147/// ...")` raised directly (not as a distinct exception subclass) in
148/// `~/dev/omnist/omnist/registry.py::get_format`. Given its own leaf type
149/// here (rather than reusing `DocumentError`/`SchemaError`) because "no such
150/// registered format" isn't a Document-shape or Schema-definition problem --
151/// it's specifically a registry lookup miss, issue #31.
152#[derive(Debug, Error, Clone, PartialEq, Eq)]
153#[error("{0}")]
154pub struct FormatError(pub String);
155
156impl FormatError {
157    /// Construct a new `FormatError` for an unknown format name.
158    pub fn new(message: impl Into<String>) -> Self {
159        Self(message.into())
160    }
161}
162
163/// An in-memory Document could not be written -- raised by
164/// [`crate::oml::write_oml`] (depth guard only; OML is otherwise lossless
165/// for every Document -- see that module's doc comment) and, from issue
166/// #16 onward, by `strict=true` format writers via
167/// [`crate::report::finish_write`], mirroring Python's
168/// `WriteError(str(rep), report=rep)`. The optional [`crate::report::WriteReport`]
169/// carries the adjustments that triggered a strict-mode raise; `None` for
170/// every other `WriteError` site (e.g. the depth guard, which has no
171/// report to attach).
172#[derive(Debug, Error, Clone, PartialEq, Eq)]
173#[error("{message}")]
174pub struct WriteError {
175    /// Human-readable write failure description.
176    pub message: String,
177    /// Optional accumulated `WriteReport` when written in strict mode.
178    pub report: Option<crate::report::WriteReport>,
179    /// The Document path of the value that could not be written, when the
180    /// failure is one the spec's taxonomy names (omnist-spec §8.3.8 and
181    /// §8.3.9); `None` otherwise.
182    pub path: Option<String>,
183    /// The spec's stable code for the failure (`write.unsupported-value`,
184    /// `format.multiple-roots`, ...); `None` when the taxonomy has no code
185    /// for it.
186    pub code: Option<String>,
187}
188
189impl WriteError {
190    /// Construct a new `WriteError` with no report.
191    pub fn new(message: impl Into<String>) -> Self {
192        Self {
193            message: message.into(),
194            report: None,
195            path: None,
196            code: None,
197        }
198    }
199
200    /// Construct a `WriteError` that carries the structured `(path, code)`
201    /// diagnostic the spec's taxonomy assigns to it.
202    pub fn with_diagnostic(
203        path: impl Into<String>,
204        code: impl Into<String>,
205        message: impl Into<String>,
206    ) -> Self {
207        Self {
208            message: message.into(),
209            report: None,
210            path: Some(path.into()),
211            code: Some(code.into()),
212        }
213    }
214
215    /// Construct a `WriteError` carrying the [`crate::report::WriteReport`]
216    /// that caused a strict-mode write to raise.
217    pub fn with_report(message: impl Into<String>, report: crate::report::WriteReport) -> Self {
218        Self {
219            message: message.into(),
220            report: Some(report),
221            path: None,
222            code: None,
223        }
224    }
225
226    /// The report attached to this error, if any (only strict-mode format
227    /// writers attach one).
228    pub fn report(&self) -> Option<&crate::report::WriteReport> {
229        self.report.as_ref()
230    }
231}
232
233impl From<DocumentError> for WriteError {
234    fn from(e: DocumentError) -> Self {
235        WriteError::new(e.message)
236    }
237}
238
239/// A freshly-read node could not be made to conform to a `Schema` --
240/// raised by [`crate::materialize::materialize`] (issue #14), mirroring
241/// Python's `ParseError(str(res), errors=res.errors)` raised by
242/// `~/dev/omnist/omnist/deserialize.py`. Wraps a
243/// [`crate::schema::ValidationResult`] directly rather than duplicating its
244/// `(path, message, code)` collection machinery -- `materialize` already
245/// walks the tree using the exact same shape-check rules `Schema::validate`
246/// does, so its error report reuses the same collector type.
247#[derive(Debug, Error, Clone, PartialEq, Eq)]
248#[error("{0}")]
249pub struct MaterializeError(pub crate::schema::ValidationResult);
250
251impl MaterializeError {
252    /// Construct a new `MaterializeError` wrapping a `ValidationResult`.
253    pub fn new(result: crate::schema::ValidationResult) -> Self {
254        Self(result)
255    }
256
257    /// Access the inner `ValidationResult`.
258    pub fn result(&self) -> &crate::schema::ValidationResult {
259        &self.0
260    }
261
262    /// Slice of all validation errors that caused materialization to fail.
263    pub fn errors(&self) -> &[crate::schema::ValidationError] {
264        self.0.errors()
265    }
266}
267
268/// Crate-wide top-level error, mirroring Python's `OmnistError` base class.
269#[derive(Debug, Error, Clone, PartialEq, Eq)]
270pub enum OmnistError {
271    /// An invalid Document operation or structure.
272    #[error(transparent)]
273    Document(#[from] DocumentError),
274    /// An invalid Schema definition.
275    #[error(transparent)]
276    Schema(#[from] SchemaError),
277    /// Document failed to conform to target Schema during materialization.
278    #[error(transparent)]
279    Materialize(#[from] MaterializeError),
280    /// Source text syntax error with line/column coordinates.
281    #[error(transparent)]
282    Parse(#[from] ParseError),
283    /// Document could not be written to destination format.
284    #[error(transparent)]
285    Write(#[from] WriteError),
286    /// Format name not recognized in registry.
287    #[error(transparent)]
288    Format(#[from] FormatError),
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn document_error_display_includes_path_and_message() {
297        let e = DocumentError::new("$.foo", "not a Document value");
298        assert_eq!(e.to_string(), "$.foo: not a Document value");
299    }
300
301    #[test]
302    fn omnist_error_wraps_document_error_transparently() {
303        let doc_err = DocumentError::new("$.foo", "boom");
304        let wrapped: OmnistError = doc_err.clone().into();
305        assert_eq!(wrapped.to_string(), doc_err.to_string());
306        assert!(matches!(wrapped, OmnistError::Document(ref inner) if *inner == doc_err));
307    }
308
309    #[test]
310    fn document_error_clone_and_eq() {
311        let a = DocumentError::new("$", "x");
312        let b = a.clone();
313        assert_eq!(a, b);
314    }
315
316    #[test]
317    fn schema_error_display_and_eq() {
318        let e = SchemaError::new("R.a", "schema.unknown-type", "unknown type 'Missing'");
319        assert_eq!(e.path, "R.a");
320        assert_eq!(e.code, "schema.unknown-type");
321        assert_eq!(e.message, "unknown type 'Missing'");
322        assert_eq!(e.to_string(), "unknown type 'Missing'");
323        assert_eq!(e.clone(), e);
324    }
325
326    #[test]
327    fn omnist_error_wraps_schema_error_transparently() {
328        let schema_err = SchemaError::new("$", "schema.syntax", "boom");
329        let wrapped: OmnistError = schema_err.clone().into();
330        assert_eq!(wrapped.to_string(), schema_err.to_string());
331        assert!(matches!(wrapped, OmnistError::Schema(ref inner) if *inner == schema_err));
332    }
333
334    #[test]
335    fn parse_error_display_includes_line_col_and_message() {
336        let e = ParseError::new(3, 7, "parse.unexpected-token", "stray character '@'");
337        assert_eq!(e.to_string(), "line 3, col 7: stray character '@'");
338    }
339
340    #[test]
341    fn parse_error_carries_a_code_and_a_text_position_path() {
342        let e = ParseError::new(3, 7, "parse.trailing-content", "x");
343        assert_eq!(e.code, "parse.trailing-content");
344        assert_eq!(e.position(), "3:7");
345        let c = ParseError::codec_syntax(1, 1, "bad");
346        assert_eq!(c.code, "parse.codec-syntax");
347        assert_eq!(c.position(), "1:1");
348    }
349
350    #[test]
351    fn document_error_code_is_none_unless_the_taxonomy_names_it() {
352        assert_eq!(DocumentError::new("$", "misuse").code, None);
353        let e = DocumentError::with_code("$.a", "document.unlabeled-element", "x");
354        assert_eq!(e.code.as_deref(), Some("document.unlabeled-element"));
355        assert_eq!(e.path, "$.a");
356        assert_eq!(e.to_string(), "$.a: x");
357    }
358
359    #[test]
360    fn write_error_diagnostic_fields_are_set_only_by_with_diagnostic() {
361        let plain = WriteError::new("boom");
362        assert_eq!((plain.path, plain.code), (None, None));
363        let d = WriteError::with_diagnostic("$.n", "write.unsupported-value", "nope");
364        assert_eq!(d.path.as_deref(), Some("$.n"));
365        assert_eq!(d.code.as_deref(), Some("write.unsupported-value"));
366        let r = WriteError::with_report("strict", crate::report::WriteReport::new());
367        assert_eq!((r.path, r.code), (None, None));
368    }
369
370    #[test]
371    fn omnist_error_wraps_parse_error_transparently() {
372        let e = ParseError::new(1, 1, "parse.unexpected-token", "boom");
373        let wrapped: OmnistError = e.clone().into();
374        assert_eq!(wrapped.to_string(), e.to_string());
375        assert!(matches!(wrapped, OmnistError::Parse(ref inner) if *inner == e));
376    }
377
378    #[test]
379    fn write_error_display_and_from_document_error() {
380        let e = WriteError::new("nesting exceeds the maximum depth (200)");
381        assert_eq!(e.to_string(), "nesting exceeds the maximum depth (200)");
382        let doc_err = DocumentError::new("$", "nesting exceeds the maximum depth (200)");
383        let from_doc: WriteError = doc_err.into();
384        assert_eq!(from_doc, e);
385    }
386
387    #[test]
388    fn omnist_error_wraps_write_error_transparently() {
389        let e = WriteError::new("boom");
390        let wrapped: OmnistError = e.clone().into();
391        assert_eq!(wrapped.to_string(), e.to_string());
392        assert!(matches!(wrapped, OmnistError::Write(ref inner) if *inner == e));
393    }
394
395    #[test]
396    fn materialize_error_new_result_and_errors_accessors() {
397        let fields = vec![crate::schema::Field::required("x", crate::schema::STRING).unwrap()];
398        let rec = crate::schema::Record::new(fields).unwrap();
399        let mut env: indexmap::IndexMap<String, crate::schema::Record> = indexmap::IndexMap::new();
400        env.insert("Root".to_string(), rec);
401        let schema = crate::schema::Schema::new(crate::schema::Ref::new("Root"), env).unwrap();
402        // An empty node under a schema requiring field "x" -- one
403        // cardinality error, giving a non-empty `ValidationResult` to test
404        // the accessors against.
405        let node = crate::document::RawNode::Edges(vec![]);
406        let res = crate::materialize::materialize(&node, Some(&schema))
407            .unwrap_err()
408            .0;
409        assert!(!res.ok());
410
411        let e = MaterializeError::new(res.clone());
412        assert_eq!(e.result(), &res);
413        assert_eq!(e.errors(), res.errors());
414        assert_eq!(e.to_string(), res.to_string());
415    }
416
417    #[test]
418    fn omnist_error_wraps_materialize_error_transparently() {
419        let res = crate::schema::ValidationResult::new();
420        let e = MaterializeError::new(res);
421        let wrapped: OmnistError = e.clone().into();
422        assert_eq!(wrapped.to_string(), e.to_string());
423        assert!(matches!(wrapped, OmnistError::Materialize(ref inner) if *inner == e));
424    }
425}