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 code;
13mod kind;
14mod path;
15mod serde;
16
17pub use code::{ErrorCode, UnknownErrorCode};
18pub use kind::{CustomErrorSource, ErrorKind};
19pub(crate) use path::ErrorPath;
20// Only `try`'s catch arm renders a kind outside this module.
21#[cfg(feature = "error-handling")]
22pub(crate) use serde::KindDisplay;
23
24use datavalue::OwnedDataValue;
25use std::borrow::Cow;
26use std::fmt;
27use std::sync::Arc;
28
29/// Canonical "Invalid Arguments" error message — used wherever an
30/// operator rejects a malformed args list before evaluating.
31pub(crate) const INVALID_ARGS: &str = "Invalid Arguments";
32
33/// Canonical "NaN" string used as the `type` field of the thrown error
34/// object that arithmetic and comparison ops raise on non-numeric input.
35pub(crate) const NAN_ERROR: &str = "NaN";
36
37/// String-only custom error — used by [`Error::custom_message`] to wrap a
38/// bare message in a `dyn Error` shell.
39#[derive(Debug)]
40struct MessageError(String);
41
42impl fmt::Display for MessageError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl std::error::Error for MessageError {}
49
50/// Error returned by every [`crate::Engine`] operation.
51///
52/// The `kind` field carries the failure category and any variant-specific
53/// payload. `operator` and `node_ids` are populated by the public
54/// `evaluate*` entry points: `operator` names the innermost operator that
55/// failed, and `node_ids` is a breadcrumb of compiled-node ids
56/// from the failure site toward the root (leaf-to-root). Use
57/// [`Error::resolve_path`] to translate the ids into structured
58/// [`crate::PathStep`]s callers can act on.
59///
60/// # Wire format
61///
62/// `Error` serialises as:
63/// `{"type": <kind tag>, "message": <Display>, ...kind-extras, "operator"?, "node_ids"?}`.
64/// `operator` is omitted when `None`; `node_ids` is omitted when empty. JS
65/// consumers can `JSON.parse(err)` and switch on `err.type`.
66///
67/// # Source chains
68///
69/// `std::error::Error::source` returns `Some` only for [`ErrorKind::Custom`]
70/// — the variant produced by [`Error::wrap`]. Every other variant carries
71/// a flat string or structured payload, not a typed cause. To attach a
72/// typed source error, wrap it via `Error::wrap` instead of constructing
73/// e.g. `Error::invalid_arguments("...")` directly.
74#[non_exhaustive]
75#[derive(Debug, Clone)]
76pub struct Error {
77    /// What went wrong. Pattern-matched by callers; stays public.
78    pub kind: ErrorKind,
79    /// The operator that failed, when known. Stored as
80    /// `Cow<'static, str>` so built-in op names (the dominant case) are
81    /// zero-allocation `Cow::Borrowed` references; only dynamic
82    /// custom-operator names carry an owned `String` via `Cow::Owned`.
83    /// Read via [`Self::operator`].
84    ///
85    /// Kept inline rather than boxed: a boxed-metadata variant (Error at
86    /// 40 bytes instead of 80) was measured and rejected — the box cost
87    /// ~14 ns per boundary-escaping error, regressing error-dense
88    /// workloads 15-45%, while the thinner `Result` slot produced no
89    /// measurable win on error-free suites.
90    operator: Option<Cow<'static, str>>,
91    /// Breadcrumb of compiled-node ids from the failure site toward the
92    /// root (leaf-to-root). Empty when the error came from parse/compile
93    /// or wasn't routed through the public `evaluate*` path. Read via
94    /// [`Self::node_ids`].
95    node_ids: ErrorPath,
96}
97
98impl Error {
99    /// Construct an [`Error`] with the given kind and no contextual metadata.
100    #[inline]
101    pub fn new(kind: ErrorKind) -> Self {
102        Self {
103            kind,
104            operator: None,
105            node_ids: ErrorPath::default(),
106        }
107    }
108
109    /// The operator that failed, when known: the innermost operator on
110    /// [`Self::node_ids`], so a failing custom operator nested inside
111    /// built-ins is named itself. The same on every evaluation path,
112    /// traced or not. When no node on the path has an operator name, the
113    /// rule's root operator stands in.
114    ///
115    /// Returns `None` for parse/compile errors and for raw constructor sites
116    /// that didn't call [`Self::with_operator`].
117    #[inline]
118    pub fn operator(&self) -> Option<&str> {
119        self.operator.as_deref()
120    }
121
122    /// Breadcrumb of compiled-node ids from the failure site toward the root
123    /// (leaf-to-root). Returns an empty slice when the error came from
124    /// parse/compile or wasn't routed through the public `evaluate*` path.
125    /// Use [`Self::resolve_path`] to convert ids into named [`crate::PathStep`]s.
126    #[inline]
127    pub fn node_ids(&self) -> &[u32] {
128        self.node_ids.as_slice()
129    }
130
131    /// Get a stable string tag for the error kind. Stable across releases.
132    /// The name of [`Self::code`].
133    pub fn tag(&self) -> &'static str {
134        self.code().as_str()
135    }
136
137    /// What kind of error this is, without its payload. See [`ErrorCode`].
138    pub fn code(&self) -> ErrorCode {
139        self.kind.code()
140    }
141
142    /// Attach the name of the operator that failed and return self. An
143    /// error that leaves an evaluation already named keeps that name.
144    ///
145    /// Accepts anything convertible to `Cow<'static, str>` — passing a
146    /// `&'static str` literal stays zero-allocation; a `String` becomes
147    /// `Cow::Owned` (one move, no copy).
148    #[must_use = "builder methods return the modified Error; bind or return it"]
149    pub fn with_operator(mut self, operator: impl Into<Cow<'static, str>>) -> Self {
150        self.operator = Some(operator.into());
151        self
152    }
153
154    /// Attach the breadcrumb path and return self.
155    ///
156    /// Takes a `Vec<u32>` of compiled-node ids (leaf-to-root). The internal
157    /// storage is private; future versions may swap its layout without an
158    /// API change.
159    #[must_use = "builder methods return the modified Error; bind or return it"]
160    pub fn with_node_ids(mut self, ids: Vec<u32>) -> Self {
161        self.node_ids = ids.into();
162        self
163    }
164
165    /// Resolve the raw [`Self::node_ids`] breadcrumb into structured
166    /// [`crate::PathStep`]s (root-to-leaf). Walks the compiled tree once.
167    ///
168    /// Returns an empty vector when `self.node_ids` is empty. Ids absent
169    /// from the compiled tree (e.g. when the error came from compile-time,
170    /// before evaluation populated the breadcrumb) are skipped.
171    ///
172    /// **Why on demand**: an earlier design eagerly cached the resolved
173    /// steps on `Error` so callers could read them without holding the
174    /// `Logic`. That walk allocates a HashMap of every node + a `String`
175    /// JSON pointer per node, and paying it on every boundary error
176    /// inflated error-heavy benchmark suites by 17×. Resolving on demand
177    /// at the catch site puts the cost where the caller actually needs
178    /// the data — and most callers either inspect raw [`Self::node_ids`]
179    /// only, or already hold the compiled `Logic` at the catch site.
180    pub fn resolve_path(&self, compiled: &crate::Logic) -> Vec<crate::PathStep> {
181        compiled.resolve_node_ids(self.node_ids())
182    }
183
184    // ---- 4.x convenience constructors ----
185    //
186    // The pre-merge enum used `Error::Variant(x)` directly. With the merged
187    // struct/enum split the right form is `ErrorKind::Variant(x).into()`.
188    // The shorthand below keeps the 33 internal call sites readable without
189    // pulling `ErrorKind` into every file's import list.
190
191    /// Shorthand for `ErrorKind::InvalidOperator(name).into()`.
192    #[inline]
193    pub fn invalid_operator(name: impl Into<Cow<'static, str>>) -> Self {
194        ErrorKind::InvalidOperator(name.into()).into()
195    }
196    /// Shorthand for `ErrorKind::InvalidArguments(msg).into()`.
197    #[inline]
198    pub fn invalid_arguments(msg: impl Into<Cow<'static, str>>) -> Self {
199        ErrorKind::InvalidArguments(msg.into()).into()
200    }
201    /// Shorthand for `ErrorKind::VariableNotFound(name).into()`.
202    #[inline]
203    pub fn variable_not_found(name: impl Into<Cow<'static, str>>) -> Self {
204        ErrorKind::VariableNotFound(name.into()).into()
205    }
206    /// Shorthand for `ErrorKind::InvalidContextLevel(level).into()`.
207    #[inline]
208    pub fn invalid_context_level(level: isize) -> Self {
209        ErrorKind::InvalidContextLevel(level).into()
210    }
211    /// Shorthand for `ErrorKind::TypeError(msg).into()`.
212    #[inline]
213    pub fn type_error(msg: impl Into<Cow<'static, str>>) -> Self {
214        ErrorKind::TypeError(msg.into()).into()
215    }
216    /// Shorthand for `ErrorKind::ArithmeticError(msg).into()`.
217    #[inline]
218    pub fn arithmetic_error(msg: impl Into<Cow<'static, str>>) -> Self {
219        ErrorKind::ArithmeticError(msg.into()).into()
220    }
221    /// Shorthand for a message-only [`ErrorKind::Custom`]. Equivalent to
222    /// [`Self::wrap`] with a string-shaped error inside. Reach for
223    /// [`Self::wrap`] directly when you have a typed `std::error::Error`
224    /// to preserve.
225    #[inline]
226    pub fn custom_message(msg: impl Into<String>) -> Self {
227        Self::wrap(MessageError(msg.into()))
228    }
229
230    /// Wrap any `std::error::Error + Send + Sync + 'static` into an
231    /// [`ErrorKind::Custom`], preserving the source chain so consumers can
232    /// walk it via [`std::error::Error::source`]:
233    ///
234    /// ```ignore
235    /// some_io_call().map_err(Error::wrap)?;
236    /// ```
237    ///
238    /// The original error stays inspectable: `error.source()` returns
239    /// `Some(&original)`. Standard chain-walking via
240    /// [`std::error::Error::source`] applies all the way down.
241    ///
242    /// Wrapping an existing [`Error`] is a no-op — the input is returned
243    /// unchanged rather than producing `Custom(Custom(...))`.
244    #[inline]
245    pub fn wrap<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
246        // No-op when E is already `Error`. We hold `err` inside an `Option`
247        // and downcast that — `TypeId::of::<Option<E>>() == TypeId::of::<Option<Error>>()`
248        // iff `E == Error`, so the downcast succeeds exactly when we'd
249        // otherwise double-wrap.
250        let mut slot: Option<E> = Some(err);
251        if let Some(slot_as_error) =
252            (&mut slot as &mut dyn std::any::Any).downcast_mut::<Option<Error>>()
253        {
254            return slot_as_error.take().expect("just stored `Some`");
255        }
256        let err = slot.take().expect("just stored `Some`");
257        ErrorKind::Custom(Arc::new(err)).into()
258    }
259    /// Shorthand for `ErrorKind::ParseError(msg).into()`.
260    #[inline]
261    pub fn parse_error(msg: impl Into<Cow<'static, str>>) -> Self {
262        ErrorKind::ParseError(msg.into()).into()
263    }
264    /// Shorthand for `ErrorKind::Thrown(value).into()`.
265    #[inline]
266    pub fn thrown(value: OwnedDataValue) -> Self {
267        ErrorKind::Thrown(value).into()
268    }
269
270    /// If this is an [`ErrorKind::Thrown`], return its payload. Convenience
271    /// accessor so consumers (loggers, structured-error walkers, the test
272    /// runner) don't have to pattern-match on the kind themselves.
273    #[inline]
274    pub fn thrown_value(&self) -> Option<&OwnedDataValue> {
275        if let ErrorKind::Thrown(v) = &self.kind {
276            Some(v)
277        } else {
278            None
279        }
280    }
281    /// Shorthand for `ErrorKind::FormatError(msg).into()`.
282    #[inline]
283    pub fn format_error(msg: impl Into<Cow<'static, str>>) -> Self {
284        ErrorKind::FormatError(msg.into()).into()
285    }
286    /// Shorthand for `ErrorKind::IndexOutOfBounds { index, length }.into()`.
287    #[inline]
288    pub fn index_out_of_bounds(index: isize, length: usize) -> Self {
289        ErrorKind::IndexOutOfBounds { index, length }.into()
290    }
291    /// Shorthand for `ErrorKind::ConfigurationError(msg).into()`.
292    #[inline]
293    pub fn configuration_error(msg: impl Into<Cow<'static, str>>) -> Self {
294        ErrorKind::ConfigurationError(msg.into()).into()
295    }
296
297    /// The evaluation's operation budget was exhausted.
298    ///
299    /// `#[cold]`: this is raised once per aborted evaluation and never on
300    /// a path that completes, so it stays out of the charge site's
301    /// inlined body.
302    #[cfg(feature = "budget")]
303    #[cold]
304    #[inline(never)]
305    pub(crate) fn budget_exceeded(budget: u64, spent: u64) -> Self {
306        ErrorKind::BudgetExceeded { budget, spent }.into()
307    }
308
309    /// Canonical "Invalid Arguments" error. Used wherever an operator
310    /// rejects malformed args before evaluating.
311    #[inline]
312    pub(crate) fn invalid_args() -> Self {
313        Error::invalid_arguments(INVALID_ARGS)
314    }
315
316    /// Decorate an error from a public `evaluate*` boundary with the
317    /// breadcrumb path (raw ids only — see below) and, when nothing
318    /// deeper named one, the root operator name. Marked `#[cold]` + `#[inline(never)]` so the
319    /// dispatch caller's `Err` arm shrinks to a single call instruction,
320    /// keeping the hot `Ok` arm's I-cache footprint tight.
321    ///
322    /// **Lazy path resolution.** The boundary attaches raw compiled-node
323    /// ids only — it does *not* call `Logic::resolve_node_ids` here. That
324    /// walk allocates a HashMap of every node + a `String` JSON pointer
325    /// per node and was measured to balloon try.json from 51 ns/op to
326    /// 898 ns/op (17×) and arithmetic/plus.json from 22 to 84 ns
327    /// (4×) on error-heavy suites where every iteration constructs an
328    /// Error. Consumers that need structured steps call
329    /// [`Self::resolve_path`] (takes a `&Logic`) on demand, which is
330    /// the same cost paid once at the catch site rather than at every
331    /// boundary crossing.
332    ///
333    /// The operator is the innermost one on the breadcrumb, named on the
334    /// way up by [`Self::name_operator_from`]. Only when no node on the
335    /// path has an operator name (or the error was raised outside any
336    /// node) does the root operator stand in. Every evaluation entry
337    /// point, traced or not, applies the same rule.
338    #[cold]
339    #[inline(never)]
340    pub(crate) fn decorated(mut self, node_ids: Vec<u32>, compiled: &crate::Logic) -> Self {
341        self.node_ids = node_ids.into();
342        if self.operator.is_none() {
343            self.operator = compiled.root_op_name.clone();
344        }
345        self
346    }
347
348    /// Name this error after `node`'s operator, unless a deeper node (or
349    /// the site that raised it) already named it.
350    #[inline]
351    pub(crate) fn name_operator_from(&mut self, node: &crate::CompiledNode) {
352        if self.operator.is_none() {
353            self.operator = node.operator_name();
354        }
355    }
356
357    /// Canonical NaN error — `{"type": "NaN"}` thrown via [`Error::thrown`].
358    /// Used by arithmetic and comparison ops on non-numeric input.
359    #[inline]
360    pub(crate) fn nan() -> Self {
361        Error::thrown(OwnedDataValue::object([("type", NAN_ERROR)]))
362    }
363
364    /// Placeholder `Thrown` error for the deferred fast lane.
365    ///
366    /// **Invariant:** only construct this while
367    /// `ContextStack::in_catch_scope()` is `true` (inside a protected —
368    /// non-final — arm of a multi-arg `try`), with the real payload parked
369    /// in the context's thrown slot via `ContextStack::set_thrown_slot`.
370    /// Under that invariant the error is guaranteed to be consumed by the
371    /// nearest enclosing `try`'s arm loop, so the `Null` payload is never
372    /// observable: `try`'s catch arm reads the slot, and the error never
373    /// reaches a public boundary or user code. Escaping throws must go
374    /// through [`Error::thrown`] with the fully built owned payload.
375    #[cfg(feature = "error-handling")]
376    #[inline]
377    pub(crate) fn deferred_thrown() -> Self {
378        ErrorKind::Thrown(OwnedDataValue::Null).into()
379    }
380
381    /// NaN error with the deferred fast lane: inside a `try` protected arm
382    /// (and when no tracer is attached — traced steps render the payload),
383    /// park the static arena-form `{"type": "NaN"}` object in the context's
384    /// thrown slot and skip the three-allocation owned build entirely.
385    /// Anywhere else this is exactly [`Error::nan`].
386    #[cfg(feature = "error-handling")]
387    #[inline]
388    pub(crate) fn nan_at(ctx: &mut crate::arena::ContextStack<'_>) -> Self {
389        if ctx.in_catch_scope() && !ctx.is_tracing() {
390            ctx.set_thrown_slot(&NAN_THROWN);
391            return Self::deferred_thrown();
392        }
393        Self::nan()
394    }
395
396    /// Fallback when `try`/`throw` are compiled out: no catch scope can
397    /// exist, so this is always the eager [`Error::nan`].
398    #[cfg(not(feature = "error-handling"))]
399    #[inline]
400    pub(crate) fn nan_at(_ctx: &mut crate::arena::ContextStack<'_>) -> Self {
401        Self::nan()
402    }
403}
404
405/// Arena-form of the canonical NaN error object, `{"type": "NaN"}` — the
406/// payload [`Error::nan_at`] parks in the context's thrown slot instead of
407/// heap-building the owned form. `'static` and arena-free, so it can be
408/// borrowed at any arena lifetime (same soundness argument as
409/// `crate::arena::singletons`).
410#[cfg(feature = "error-handling")]
411static NAN_THROWN: crate::arena::DataValue<'static> =
412    crate::arena::DataValue::Object(&[("type", crate::arena::DataValue::String(NAN_ERROR))]);
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn wrap_renders_via_display() {
420        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing key");
421        let err = Error::wrap(io_err);
422        assert_eq!(err.tag(), "Custom");
423        assert!(err.to_string().contains("missing key"));
424    }
425
426    #[test]
427    fn wrap_preserves_source_chain() {
428        use std::error::Error as _;
429        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing key");
430        let err = Error::wrap(io_err);
431        // `Error::source` returns the original typed error so consumers can
432        // walk the chain — the previous Display-only `wrap` lost this.
433        let src = err.source().expect("Custom should expose its source");
434        assert!(src.to_string().contains("missing key"));
435        // And the source itself can be downcast to the original type.
436        assert!(src.downcast_ref::<std::io::Error>().is_some());
437    }
438
439    #[test]
440    fn wrap_threads_through_question_mark() {
441        // Smoke test for the `?` ergonomic — `Error::wrap` slots into a
442        // `map_err` chain so foreign errors flow up unchanged.
443        fn inner() -> std::result::Result<(), Error> {
444            "not_an_int".parse::<i32>().map_err(Error::wrap)?;
445            Ok(())
446        }
447        let err = inner().expect_err("parse should fail");
448        assert!(matches!(err.kind, ErrorKind::Custom(_)));
449    }
450
451    #[test]
452    fn wrap_of_existing_error_is_noop() {
453        // `Error::wrap(some_error)` would otherwise produce `Custom(Custom(...))`
454        // — the no-op short-circuit returns the input unchanged.
455        let inner = Error::variable_not_found("x");
456        let wrapped = Error::wrap(inner.clone());
457        assert_eq!(wrapped.tag(), "VariableNotFound");
458        assert!(matches!(wrapped.kind, ErrorKind::VariableNotFound(ref name) if name == "x"));
459        // operator + node_ids metadata round-trip too.
460        let with_meta = inner.with_operator("var").with_node_ids(vec![1, 2, 3]);
461        let wrapped = Error::wrap(with_meta);
462        assert_eq!(wrapped.operator(), Some("var"));
463        assert_eq!(wrapped.node_ids(), &[1, 2, 3]);
464    }
465
466    #[test]
467    fn error_path_default_is_empty() {
468        let p = ErrorPath::default();
469        assert!(p.as_slice().is_empty());
470        assert_eq!(p.as_slice(), &[] as &[u32]);
471    }
472
473    #[test]
474    fn error_path_from_vec_round_trips() {
475        let p: ErrorPath = vec![10, 20, 30].into();
476        assert_eq!(p.as_slice(), &[10, 20, 30]);
477    }
478
479    #[test]
480    fn with_node_ids_round_trips() {
481        // Engine boundary calls `with_node_ids` once per escaping error;
482        // the ids land in the breadcrumb and read back unchanged.
483        let err = Error::invalid_arguments("x").with_node_ids(vec![1, 2, 3]);
484        assert_eq!(err.node_ids(), &[1, 2, 3]);
485        // Metadata-free errors read back as empty without allocating.
486        assert!(Error::invalid_arguments("x").node_ids().is_empty());
487    }
488}