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