Skip to main content

datalogic_rs/error/
mod.rs

1//! `Error` — the unified error type returned by every public engine
2//! operation. Submodules are structured by concern:
3//!
4//! - [`kind`] — `ErrorKind` enum + `CustomErrorSource` trait alias.
5//! - [`path`] — `ErrorPath`, the internal breadcrumb storage.
6//! - [`serde`] — `Display`, `std::error::Error`, `Serialize`, and `From`
7//!   impls for foreign error types.
8//!
9//! Re-exports below preserve the pre-split `crate::error::*` import paths so
10//! callers elsewhere in the crate are unaffected by the file split.
11
12mod kind;
13mod path;
14mod serde;
15
16pub use kind::{CustomErrorSource, ErrorKind};
17pub(crate) use path::ErrorPath;
18
19use datavalue::OwnedDataValue;
20use std::borrow::Cow;
21use std::fmt;
22use std::sync::Arc;
23
24/// Canonical "Invalid Arguments" error message — used wherever an
25/// operator rejects a malformed args list before evaluating.
26pub(crate) const INVALID_ARGS: &str = "Invalid Arguments";
27
28/// Canonical "NaN" string used as the `type` field of the thrown error
29/// object that arithmetic and comparison ops raise on non-numeric input.
30pub(crate) const NAN_ERROR: &str = "NaN";
31
32/// String-only custom error — used by [`Error::custom_message`] to wrap a
33/// bare message in a `dyn Error` shell.
34#[derive(Debug)]
35struct MessageError(String);
36
37impl fmt::Display for MessageError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str(&self.0)
40    }
41}
42
43impl std::error::Error for MessageError {}
44
45/// Error returned by every [`crate::Engine`] operation.
46///
47/// The `kind` field carries the failure category and any variant-specific
48/// payload. `operator` and `node_ids` are populated by the public
49/// `evaluate*` entry points: `operator` names the outermost operator that
50/// produced the error, and `node_ids` is a breadcrumb of compiled-node ids
51/// from the failure site toward the root (leaf-to-root). Use
52/// [`Error::resolve_path`] to translate the ids into structured
53/// [`crate::PathStep`]s callers can act on.
54///
55/// # Wire format
56///
57/// `Error` serialises as:
58/// `{"type": <kind tag>, "message": <Display>, ...kind-extras, "operator"?, "node_ids"?}`.
59/// `operator` is omitted when `None`; `node_ids` is omitted when empty. JS
60/// consumers can `JSON.parse(err)` and switch on `err.type`.
61///
62/// # Source chains
63///
64/// `std::error::Error::source` returns `Some` only for [`ErrorKind::Custom`]
65/// — the variant produced by [`Error::wrap`]. Every other variant carries
66/// a flat string or structured payload, not a typed cause. To attach a
67/// typed source error, wrap it via `Error::wrap` instead of constructing
68/// e.g. `Error::invalid_arguments("...")` directly.
69#[non_exhaustive]
70#[derive(Debug, Clone)]
71pub struct Error {
72    /// What went wrong. Pattern-matched by callers; stays public.
73    pub kind: ErrorKind,
74    /// Outermost operator that produced this error, when known.
75    /// Read via [`Self::operator`]. Stored as `Cow<'static, str>` so
76    /// built-in op names (the dominant case) are zero-allocation
77    /// `Cow::Borrowed` references; only dynamic custom-operator names
78    /// carry an owned `String` via `Cow::Owned`.
79    operator: Option<Cow<'static, str>>,
80    /// Breadcrumb of compiled-node ids from the failure site toward the
81    /// root (leaf-to-root). Empty when the error came from parse/compile
82    /// or wasn't routed through the public `evaluate*` path. Stored
83    /// inline (no `Box`) so attaching the breadcrumb at the boundary is
84    /// just a move — heap-allocating per error showed up as a +30%
85    /// regression on error-heavy suites (try/throw/datetime/string).
86    node_ids: ErrorPath,
87}
88
89impl Error {
90    /// Construct an [`Error`] with the given kind and no contextual metadata.
91    #[inline]
92    pub fn new(kind: ErrorKind) -> Self {
93        Self {
94            kind,
95            operator: None,
96            node_ids: ErrorPath::new(),
97        }
98    }
99
100    /// Outermost operator that produced this error, when known.
101    /// Returns `None` for parse/compile errors and for raw constructor sites
102    /// that didn't call [`Self::with_operator`].
103    #[inline]
104    pub fn operator(&self) -> Option<&str> {
105        self.operator.as_deref()
106    }
107
108    /// Breadcrumb of compiled-node ids from the failure site toward the root
109    /// (leaf-to-root). Returns an empty slice when the error came from
110    /// parse/compile or wasn't routed through the public `evaluate*` path.
111    /// Use [`Self::resolve_path`] to convert ids into named [`crate::PathStep`]s.
112    #[inline]
113    pub fn node_ids(&self) -> &[u32] {
114        self.node_ids.as_slice()
115    }
116
117    /// Get a stable string tag for the error kind. Stable across releases.
118    pub fn tag(&self) -> &'static str {
119        match self.kind {
120            ErrorKind::InvalidOperator(_) => "InvalidOperator",
121            ErrorKind::InvalidArguments(_) => "InvalidArguments",
122            ErrorKind::VariableNotFound(_) => "VariableNotFound",
123            ErrorKind::InvalidContextLevel(_) => "InvalidContextLevel",
124            ErrorKind::TypeError(_) => "TypeError",
125            ErrorKind::ArithmeticError(_) => "ArithmeticError",
126            ErrorKind::Custom(_) => "Custom",
127            ErrorKind::ParseError(_) => "ParseError",
128            ErrorKind::Thrown(_) => "Thrown",
129            ErrorKind::FormatError(_) => "FormatError",
130            ErrorKind::IndexOutOfBounds { .. } => "IndexOutOfBounds",
131            ErrorKind::ConfigurationError(_) => "ConfigurationError",
132        }
133    }
134
135    /// Attach the outermost operator name and return self.
136    ///
137    /// Accepts anything convertible to `Cow<'static, str>` — passing a
138    /// `&'static str` literal stays zero-allocation; a `String` becomes
139    /// `Cow::Owned` (one move, no copy).
140    #[must_use = "builder methods return the modified Error; bind or return it"]
141    pub fn with_operator(mut self, operator: impl Into<Cow<'static, str>>) -> Self {
142        self.operator = Some(operator.into());
143        self
144    }
145
146    /// Attach the breadcrumb path and return self.
147    ///
148    /// Takes a `Vec<u32>` of compiled-node ids (leaf-to-root). The internal
149    /// storage is currently a plain `Vec<u32>`; future versions may swap to
150    /// an inline-buffer / smallvec layout without an API change.
151    #[must_use = "builder methods return the modified Error; bind or return it"]
152    pub fn with_node_ids(mut self, ids: Vec<u32>) -> Self {
153        self.node_ids = ids.into();
154        self
155    }
156
157    /// Resolve the raw [`Self::node_ids`] breadcrumb into structured
158    /// [`crate::PathStep`]s (root-to-leaf). Walks the compiled tree once.
159    ///
160    /// Returns an empty vector when `self.node_ids` is empty. Ids absent
161    /// from the compiled tree (e.g. when the error came from compile-time,
162    /// before evaluation populated the breadcrumb) are skipped.
163    ///
164    /// **Why on demand**: an earlier design eagerly cached the resolved
165    /// steps on `Error` so callers could read them without holding the
166    /// `Logic`. That walk allocates a HashMap of every node + a `String`
167    /// JSON pointer per node, and paying it on every boundary error
168    /// inflated error-heavy benchmark suites by 17×. Resolving on demand
169    /// at the catch site puts the cost where the caller actually needs
170    /// the data — and most callers either inspect raw [`Self::node_ids`]
171    /// only, or already hold the compiled `Logic` at the catch site.
172    pub fn resolve_path(&self, compiled: &crate::Logic) -> Vec<crate::PathStep> {
173        compiled.resolve_node_ids(self.node_ids.as_slice())
174    }
175
176    // ---- 4.x convenience constructors ----
177    //
178    // The pre-merge enum used `Error::Variant(x)` directly. With the merged
179    // struct/enum split the right form is `ErrorKind::Variant(x).into()`.
180    // The shorthand below keeps the 33 internal call sites readable without
181    // pulling `ErrorKind` into every file's import list.
182
183    /// Shorthand for `ErrorKind::InvalidOperator(name).into()`.
184    #[inline]
185    pub fn invalid_operator(name: impl Into<Cow<'static, str>>) -> Self {
186        ErrorKind::InvalidOperator(name.into()).into()
187    }
188    /// Shorthand for `ErrorKind::InvalidArguments(msg).into()`.
189    #[inline]
190    pub fn invalid_arguments(msg: impl Into<Cow<'static, str>>) -> Self {
191        ErrorKind::InvalidArguments(msg.into()).into()
192    }
193    /// Shorthand for `ErrorKind::VariableNotFound(name).into()`.
194    #[inline]
195    pub fn variable_not_found(name: impl Into<Cow<'static, str>>) -> Self {
196        ErrorKind::VariableNotFound(name.into()).into()
197    }
198    /// Shorthand for `ErrorKind::InvalidContextLevel(level).into()`.
199    #[inline]
200    pub fn invalid_context_level(level: isize) -> Self {
201        ErrorKind::InvalidContextLevel(level).into()
202    }
203    /// Shorthand for `ErrorKind::TypeError(msg).into()`.
204    #[inline]
205    pub fn type_error(msg: impl Into<Cow<'static, str>>) -> Self {
206        ErrorKind::TypeError(msg.into()).into()
207    }
208    /// Shorthand for `ErrorKind::ArithmeticError(msg).into()`.
209    #[inline]
210    pub fn arithmetic_error(msg: impl Into<Cow<'static, str>>) -> Self {
211        ErrorKind::ArithmeticError(msg.into()).into()
212    }
213    /// Shorthand for a message-only [`ErrorKind::Custom`]. Equivalent to
214    /// [`Self::wrap`] with a string-shaped error inside. Reach for
215    /// [`Self::wrap`] directly when you have a typed `std::error::Error`
216    /// to preserve.
217    #[inline]
218    pub fn custom_message(msg: impl Into<String>) -> Self {
219        Self::wrap(MessageError(msg.into()))
220    }
221
222    /// Wrap any `std::error::Error + Send + Sync + 'static` into an
223    /// [`ErrorKind::Custom`], preserving the source chain so consumers can
224    /// walk it via [`std::error::Error::source`]:
225    ///
226    /// ```ignore
227    /// some_io_call().map_err(Error::wrap)?;
228    /// ```
229    ///
230    /// The original error stays inspectable: `error.source()` returns
231    /// `Some(&original)`. Standard chain-walking via
232    /// [`std::error::Error::source`] applies all the way down.
233    ///
234    /// Wrapping an existing [`Error`] is a no-op — the input is returned
235    /// unchanged rather than producing `Custom(Custom(...))`.
236    #[inline]
237    pub fn wrap<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
238        // No-op when E is already `Error`. We hold `err` inside an `Option`
239        // and downcast that — `TypeId::of::<Option<E>>() == TypeId::of::<Option<Error>>()`
240        // iff `E == Error`, so the downcast succeeds exactly when we'd
241        // otherwise double-wrap.
242        let mut slot: Option<E> = Some(err);
243        if let Some(slot_as_error) =
244            (&mut slot as &mut dyn std::any::Any).downcast_mut::<Option<Error>>()
245        {
246            return slot_as_error.take().expect("just stored `Some`");
247        }
248        let err = slot.take().expect("just stored `Some`");
249        ErrorKind::Custom(Arc::new(err)).into()
250    }
251    /// Shorthand for `ErrorKind::ParseError(msg).into()`.
252    #[inline]
253    pub fn parse_error(msg: impl Into<Cow<'static, str>>) -> Self {
254        ErrorKind::ParseError(msg.into()).into()
255    }
256    /// Shorthand for `ErrorKind::Thrown(value).into()`.
257    #[inline]
258    pub fn thrown(value: OwnedDataValue) -> Self {
259        ErrorKind::Thrown(value).into()
260    }
261
262    /// If this is an [`ErrorKind::Thrown`], return its payload. Convenience
263    /// accessor so consumers (loggers, structured-error walkers, the test
264    /// runner) don't have to pattern-match on the kind themselves.
265    #[inline]
266    pub fn thrown_value(&self) -> Option<&OwnedDataValue> {
267        if let ErrorKind::Thrown(v) = &self.kind {
268            Some(v)
269        } else {
270            None
271        }
272    }
273    /// Shorthand for `ErrorKind::FormatError(msg).into()`.
274    #[inline]
275    pub fn format_error(msg: impl Into<Cow<'static, str>>) -> Self {
276        ErrorKind::FormatError(msg.into()).into()
277    }
278    /// Shorthand for `ErrorKind::IndexOutOfBounds { index, length }.into()`.
279    #[inline]
280    pub fn index_out_of_bounds(index: isize, length: usize) -> Self {
281        ErrorKind::IndexOutOfBounds { index, length }.into()
282    }
283    /// Shorthand for `ErrorKind::ConfigurationError(msg).into()`.
284    #[inline]
285    pub fn configuration_error(msg: impl Into<Cow<'static, str>>) -> Self {
286        ErrorKind::ConfigurationError(msg.into()).into()
287    }
288
289    /// Canonical "Invalid Arguments" error. Used wherever an operator
290    /// rejects malformed args before evaluating.
291    #[inline]
292    pub(crate) fn invalid_args() -> Self {
293        Error::invalid_arguments(INVALID_ARGS)
294    }
295
296    /// Decorate an error from a public `evaluate*` boundary with the
297    /// breadcrumb path (raw ids only — see below) and the outermost
298    /// operator name. Marked `#[cold]` + `#[inline(never)]` so the
299    /// dispatch caller's `Err` arm shrinks to a single call instruction,
300    /// keeping the hot `Ok` arm's I-cache footprint tight.
301    ///
302    /// **Lazy path resolution.** The boundary attaches raw compiled-node
303    /// ids only — it does *not* call `Logic::resolve_node_ids` here. That
304    /// walk allocates a HashMap of every node + a `String` JSON pointer
305    /// per node and was measured to balloon try.json from 51 ns/op to
306    /// 898 ns/op (17×) and arithmetic/plus.json from 22 to 84 ns
307    /// (4×) on error-heavy suites where every iteration constructs an
308    /// Error. Consumers that need structured steps call
309    /// [`Self::resolve_path`] (takes a `&Logic`) on demand, which is
310    /// the same cost paid once at the catch site rather than at every
311    /// boundary crossing.
312    ///
313    /// `prefer_existing_op` controls whether to fall back to
314    /// `compiled.root_op_name` when no operator was already attached:
315    /// the `Engine::evaluate*` sites pass `true` (only attach if a
316    /// deeper site didn't name a more specific failing op);
317    /// `TracedSession` passes `false` to preserve its prior
318    /// unconditional-overwrite behavior.
319    #[cold]
320    #[inline(never)]
321    pub(crate) fn decorated(
322        mut self,
323        node_ids: Vec<u32>,
324        compiled: &crate::Logic,
325        prefer_existing_op: bool,
326    ) -> Self {
327        self = self.with_node_ids(node_ids);
328        if !prefer_existing_op || self.operator.is_none() {
329            if let Some(name) = compiled.root_op_name.clone() {
330                self.operator = Some(name);
331            }
332        }
333        self
334    }
335
336    /// Canonical NaN error — `{"type": "NaN"}` thrown via [`Error::thrown`].
337    /// Used by arithmetic and comparison ops on non-numeric input.
338    #[inline]
339    pub(crate) fn nan() -> Self {
340        Error::thrown(OwnedDataValue::object([("type", NAN_ERROR)]))
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn wrap_renders_via_display() {
350        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing key");
351        let err = Error::wrap(io_err);
352        assert_eq!(err.tag(), "Custom");
353        assert!(err.to_string().contains("missing key"));
354    }
355
356    #[test]
357    fn wrap_preserves_source_chain() {
358        use std::error::Error as _;
359        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing key");
360        let err = Error::wrap(io_err);
361        // `Error::source` returns the original typed error so consumers can
362        // walk the chain — the previous Display-only `wrap` lost this.
363        let src = err.source().expect("Custom should expose its source");
364        assert!(src.to_string().contains("missing key"));
365        // And the source itself can be downcast to the original type.
366        assert!(src.downcast_ref::<std::io::Error>().is_some());
367    }
368
369    #[test]
370    fn wrap_threads_through_question_mark() {
371        // Smoke test for the `?` ergonomic — `Error::wrap` slots into a
372        // `map_err` chain so foreign errors flow up unchanged.
373        fn inner() -> std::result::Result<(), Error> {
374            "not_an_int".parse::<i32>().map_err(Error::wrap)?;
375            Ok(())
376        }
377        let err = inner().expect_err("parse should fail");
378        assert!(matches!(err.kind, ErrorKind::Custom(_)));
379    }
380
381    #[test]
382    fn wrap_of_existing_error_is_noop() {
383        // `Error::wrap(some_error)` would otherwise produce `Custom(Custom(...))`
384        // — the no-op short-circuit returns the input unchanged.
385        let inner = Error::variable_not_found("x");
386        let wrapped = Error::wrap(inner.clone());
387        assert_eq!(wrapped.tag(), "VariableNotFound");
388        assert!(matches!(wrapped.kind, ErrorKind::VariableNotFound(ref name) if name == "x"));
389        // operator + node_ids metadata round-trip too.
390        let with_meta = inner.with_operator("var").with_node_ids(vec![1, 2, 3]);
391        let wrapped = Error::wrap(with_meta);
392        assert_eq!(wrapped.operator(), Some("var"));
393        assert_eq!(wrapped.node_ids(), &[1, 2, 3]);
394    }
395
396    #[test]
397    fn error_path_default_is_empty() {
398        let p = ErrorPath::new();
399        assert!(p.as_slice().is_empty());
400        assert_eq!(p.as_slice(), &[] as &[u32]);
401    }
402
403    #[test]
404    fn error_path_from_vec_round_trips() {
405        let p: ErrorPath = vec![10, 20, 30].into();
406        assert_eq!(p.as_slice(), &[10, 20, 30]);
407    }
408
409    #[test]
410    fn with_node_ids_stores_inline_no_box() {
411        // Engine boundary calls `with_node_ids` once per error; storage
412        // is inline `Vec<u32>` so this is just a move, not a heap alloc.
413        let err = Error::invalid_arguments("x").with_node_ids(vec![1, 2, 3]);
414        assert_eq!(err.node_ids(), &[1, 2, 3]);
415    }
416}