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    /// Human-readable error description.
25    pub message: String,
26}
27
28impl DocumentError {
29    /// Construct a new `DocumentError` at the given path.
30    pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
31        Self {
32            path: path.into(),
33            message: message.into(),
34        }
35    }
36}
37
38/// A Schema definition is invalid (bad cardinality, duplicate field label,
39/// unknown scalar/ref name) -- raised by [`crate::schema`], [`crate::osd`],
40/// [`crate::infer`], and [`crate::ops::extract`].
41///
42/// Breaking change in issue #122: `SchemaError` now carries machine-readable
43/// `path` and `code` fields alongside human-readable `message`, matching the
44/// spec's schema well-formedness and algebra error taxonomy (spec §8.3.3 & §8.3.6).
45#[derive(Debug, Error, Clone, PartialEq, Eq)]
46#[error("{message}")]
47pub struct SchemaError {
48    /// The path (record/field context or "$") where the schema error occurred.
49    pub path: String,
50    /// Stable machine-readable error code (e.g. "schema.unknown-type", spec §8.3.3).
51    pub code: String,
52    /// Human-readable error description.
53    pub message: String,
54}
55
56impl SchemaError {
57    /// Construct a structured `SchemaError` with path, code, and message.
58    pub fn new(
59        path: impl Into<String>,
60        code: impl Into<String>,
61        message: impl Into<String>,
62    ) -> Self {
63        Self {
64            path: path.into(),
65            code: code.into(),
66            message: message.into(),
67        }
68    }
69}
70
71/// An OML source string could not be parsed -- raised by
72/// [`crate::oml::read_oml`], mirroring Python's `ParseError` in
73/// `~/dev/omnist/omnist/errors.py`. Carries the same "line N, col N: msg"
74/// convention the Python reference's scanner/parser produce.
75#[derive(Debug, Error, Clone, PartialEq, Eq)]
76#[error("line {line}, col {col}: {message}")]
77pub struct ParseError {
78    /// Line number where parsing failed (1-indexed).
79    pub line: usize,
80    /// Column number where parsing failed (1-indexed).
81    pub col: usize,
82    /// Human-readable parse failure description.
83    pub message: String,
84}
85
86impl ParseError {
87    /// Construct a new `ParseError` with position coordinates.
88    pub fn new(line: usize, col: usize, message: impl Into<String>) -> Self {
89        Self {
90            line,
91            col,
92            message: message.into(),
93        }
94    }
95}
96
97/// An unknown format name was looked up in the format registry -- raised by
98/// [`crate::registry::get_format`] (and therefore
99/// [`crate::document::Doc::from_format`]/`to_format`/`check_format`),
100/// mirroring Python's `OmnistError(f"unknown format {name!r}; registered:
101/// ...")` raised directly (not as a distinct exception subclass) in
102/// `~/dev/omnist/omnist/registry.py::get_format`. Given its own leaf type
103/// here (rather than reusing `DocumentError`/`SchemaError`) because "no such
104/// registered format" isn't a Document-shape or Schema-definition problem --
105/// it's specifically a registry lookup miss, issue #31.
106#[derive(Debug, Error, Clone, PartialEq, Eq)]
107#[error("{0}")]
108pub struct FormatError(pub String);
109
110impl FormatError {
111    /// Construct a new `FormatError` for an unknown format name.
112    pub fn new(message: impl Into<String>) -> Self {
113        Self(message.into())
114    }
115}
116
117/// An in-memory Document could not be written -- raised by
118/// [`crate::oml::write_oml`] (depth guard only; OML is otherwise lossless
119/// for every Document -- see that module's doc comment) and, from issue
120/// #16 onward, by `strict=true` format writers via
121/// [`crate::report::finish_write`], mirroring Python's
122/// `WriteError(str(rep), report=rep)`. The optional [`crate::report::WriteReport`]
123/// carries the adjustments that triggered a strict-mode raise; `None` for
124/// every other `WriteError` site (e.g. the depth guard, which has no
125/// report to attach).
126#[derive(Debug, Error, Clone, PartialEq, Eq)]
127#[error("{message}")]
128pub struct WriteError {
129    /// Human-readable write failure description.
130    pub message: String,
131    /// Optional accumulated `WriteReport` when written in strict mode.
132    pub report: Option<crate::report::WriteReport>,
133}
134
135impl WriteError {
136    /// Construct a new `WriteError` with no report.
137    pub fn new(message: impl Into<String>) -> Self {
138        Self {
139            message: message.into(),
140            report: None,
141        }
142    }
143
144    /// Construct a `WriteError` carrying the [`crate::report::WriteReport`]
145    /// that caused a strict-mode write to raise.
146    pub fn with_report(message: impl Into<String>, report: crate::report::WriteReport) -> Self {
147        Self {
148            message: message.into(),
149            report: Some(report),
150        }
151    }
152
153    /// The report attached to this error, if any (only strict-mode format
154    /// writers attach one).
155    pub fn report(&self) -> Option<&crate::report::WriteReport> {
156        self.report.as_ref()
157    }
158}
159
160impl From<DocumentError> for WriteError {
161    fn from(e: DocumentError) -> Self {
162        WriteError::new(e.message)
163    }
164}
165
166/// A freshly-read node could not be made to conform to a `Schema` --
167/// raised by [`crate::materialize::materialize`] (issue #14), mirroring
168/// Python's `ParseError(str(res), errors=res.errors)` raised by
169/// `~/dev/omnist/omnist/deserialize.py`. Wraps a
170/// [`crate::schema::ValidationResult`] directly rather than duplicating its
171/// `(path, message, code)` collection machinery -- `materialize` already
172/// walks the tree using the exact same shape-check rules `Schema::validate`
173/// does, so its error report reuses the same collector type.
174#[derive(Debug, Error, Clone, PartialEq, Eq)]
175#[error("{0}")]
176pub struct MaterializeError(pub crate::schema::ValidationResult);
177
178impl MaterializeError {
179    /// Construct a new `MaterializeError` wrapping a `ValidationResult`.
180    pub fn new(result: crate::schema::ValidationResult) -> Self {
181        Self(result)
182    }
183
184    /// Access the inner `ValidationResult`.
185    pub fn result(&self) -> &crate::schema::ValidationResult {
186        &self.0
187    }
188
189    /// Slice of all validation errors that caused materialization to fail.
190    pub fn errors(&self) -> &[crate::schema::ValidationError] {
191        self.0.errors()
192    }
193}
194
195/// Crate-wide top-level error, mirroring Python's `OmnistError` base class.
196#[derive(Debug, Error, Clone, PartialEq, Eq)]
197pub enum OmnistError {
198    /// An invalid Document operation or structure.
199    #[error(transparent)]
200    Document(#[from] DocumentError),
201    /// An invalid Schema definition.
202    #[error(transparent)]
203    Schema(#[from] SchemaError),
204    /// Document failed to conform to target Schema during materialization.
205    #[error(transparent)]
206    Materialize(#[from] MaterializeError),
207    /// Source text syntax error with line/column coordinates.
208    #[error(transparent)]
209    Parse(#[from] ParseError),
210    /// Document could not be written to destination format.
211    #[error(transparent)]
212    Write(#[from] WriteError),
213    /// Format name not recognized in registry.
214    #[error(transparent)]
215    Format(#[from] FormatError),
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn document_error_display_includes_path_and_message() {
224        let e = DocumentError::new("$.foo", "not a Document value");
225        assert_eq!(e.to_string(), "$.foo: not a Document value");
226    }
227
228    #[test]
229    fn omnist_error_wraps_document_error_transparently() {
230        let doc_err = DocumentError::new("$.foo", "boom");
231        let wrapped: OmnistError = doc_err.clone().into();
232        assert_eq!(wrapped.to_string(), doc_err.to_string());
233        assert!(matches!(wrapped, OmnistError::Document(ref inner) if *inner == doc_err));
234    }
235
236    #[test]
237    fn document_error_clone_and_eq() {
238        let a = DocumentError::new("$", "x");
239        let b = a.clone();
240        assert_eq!(a, b);
241    }
242
243    #[test]
244    fn schema_error_display_and_eq() {
245        let e = SchemaError::new("R.a", "schema.unknown-type", "unknown type 'Missing'");
246        assert_eq!(e.path, "R.a");
247        assert_eq!(e.code, "schema.unknown-type");
248        assert_eq!(e.message, "unknown type 'Missing'");
249        assert_eq!(e.to_string(), "unknown type 'Missing'");
250        assert_eq!(e.clone(), e);
251    }
252
253    #[test]
254    fn omnist_error_wraps_schema_error_transparently() {
255        let schema_err = SchemaError::new("$", "schema.syntax", "boom");
256        let wrapped: OmnistError = schema_err.clone().into();
257        assert_eq!(wrapped.to_string(), schema_err.to_string());
258        assert!(matches!(wrapped, OmnistError::Schema(ref inner) if *inner == schema_err));
259    }
260
261    #[test]
262    fn parse_error_display_includes_line_col_and_message() {
263        let e = ParseError::new(3, 7, "stray character '@'");
264        assert_eq!(e.to_string(), "line 3, col 7: stray character '@'");
265    }
266
267    #[test]
268    fn omnist_error_wraps_parse_error_transparently() {
269        let e = ParseError::new(1, 1, "boom");
270        let wrapped: OmnistError = e.clone().into();
271        assert_eq!(wrapped.to_string(), e.to_string());
272        assert!(matches!(wrapped, OmnistError::Parse(ref inner) if *inner == e));
273    }
274
275    #[test]
276    fn write_error_display_and_from_document_error() {
277        let e = WriteError::new("nesting exceeds the maximum depth (200)");
278        assert_eq!(e.to_string(), "nesting exceeds the maximum depth (200)");
279        let doc_err = DocumentError::new("$", "nesting exceeds the maximum depth (200)");
280        let from_doc: WriteError = doc_err.into();
281        assert_eq!(from_doc, e);
282    }
283
284    #[test]
285    fn omnist_error_wraps_write_error_transparently() {
286        let e = WriteError::new("boom");
287        let wrapped: OmnistError = e.clone().into();
288        assert_eq!(wrapped.to_string(), e.to_string());
289        assert!(matches!(wrapped, OmnistError::Write(ref inner) if *inner == e));
290    }
291
292    #[test]
293    fn materialize_error_new_result_and_errors_accessors() {
294        let fields = vec![crate::schema::Field::required("x", crate::schema::STRING).unwrap()];
295        let rec = crate::schema::Record::new(fields).unwrap();
296        let mut env: indexmap::IndexMap<String, crate::schema::Record> = indexmap::IndexMap::new();
297        env.insert("Root".to_string(), rec);
298        let schema = crate::schema::Schema::new(crate::schema::Ref::new("Root"), env).unwrap();
299        // An empty node under a schema requiring field "x" -- one
300        // cardinality error, giving a non-empty `ValidationResult` to test
301        // the accessors against.
302        let node = crate::document::RawNode::Edges(vec![]);
303        let res = crate::materialize::materialize(&node, Some(&schema))
304            .unwrap_err()
305            .0;
306        assert!(!res.ok());
307
308        let e = MaterializeError::new(res.clone());
309        assert_eq!(e.result(), &res);
310        assert_eq!(e.errors(), res.errors());
311        assert_eq!(e.to_string(), res.to_string());
312    }
313
314    #[test]
315    fn omnist_error_wraps_materialize_error_transparently() {
316        let res = crate::schema::ValidationResult::new();
317        let e = MaterializeError::new(res);
318        let wrapped: OmnistError = e.clone().into();
319        assert_eq!(wrapped.to_string(), e.to_string());
320        assert!(matches!(wrapped, OmnistError::Materialize(ref inner) if *inner == e));
321    }
322}