Skip to main content

lox_core/
error.rs

1// SPDX-FileCopyrightText: 2026 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5//! Type-erased error handling for callback boundaries.
6//!
7//! User-provided callbacks (objective functions, detection functions, Python
8//! callables) can fail with error types that `lox-core` cannot name. [`LoxError`]
9//! erases those types so they can travel through solvers and detectors, while
10//! [`find_source`] recovers them at the boundary via downcasting.
11
12use alloc::boxed::Box;
13use thiserror::Error;
14
15/// A boxed, type-erased error.
16pub type BoxedError = Box<dyn core::error::Error + Send + Sync + 'static>;
17
18/// A type-erased error carrying an arbitrary failure across an abstraction
19/// boundary.
20///
21/// Unlike a transparent wrapper, `LoxError` exposes the erased error as its
22/// [`source`](core::error::Error::source), so the wrapped error remains
23/// reachable when walking an error chain with [`find_source`].
24#[derive(Debug, Error)]
25#[error("{0}")]
26pub struct LoxError(#[source] BoxedError);
27
28impl LoxError {
29    /// Wraps an arbitrary error.
30    pub fn new(error: impl Into<BoxedError>) -> Self {
31        LoxError(error.into())
32    }
33
34    /// Returns the wrapped error.
35    pub fn into_inner(self) -> BoxedError {
36        self.0
37    }
38
39    /// Searches the wrapped error and its source chain for an error of type `E`.
40    pub fn downcast_ref<E: core::error::Error + 'static>(&self) -> Option<&E> {
41        find_source(self.0.as_ref())
42    }
43}
44
45impl From<&str> for LoxError {
46    fn from(s: &str) -> Self {
47        LoxError(s.into())
48    }
49}
50
51impl From<BoxedError> for LoxError {
52    fn from(e: BoxedError) -> Self {
53        LoxError(e)
54    }
55}
56
57impl From<core::convert::Infallible> for LoxError {
58    fn from(x: core::convert::Infallible) -> Self {
59        match x {}
60    }
61}
62
63/// Searches `err` and its source chain for an error of type `E`.
64///
65/// Transparent wrappers (thiserror's `#[error(transparent)]`) forward `source`
66/// to the wrapped error's source and thus hide themselves from the chain;
67/// [`LoxError`] deliberately appears as a real link so that errors it erases
68/// stay reachable.
69pub fn find_source<'a, E: core::error::Error + 'static>(
70    err: &'a (dyn core::error::Error + 'static),
71) -> Option<&'a E> {
72    let mut current: Option<&(dyn core::error::Error + 'static)> = Some(err);
73    while let Some(e) = current {
74        if let Some(found) = e.downcast_ref::<E>() {
75            return Some(found);
76        }
77        current = e.source();
78    }
79    None
80}
81
82#[cfg(test)]
83mod tests {
84    use alloc::string::ToString;
85
86    use super::*;
87
88    #[derive(Debug, Error)]
89    #[error("leaf failure")]
90    struct LeafError;
91
92    #[derive(Debug, Error)]
93    #[error(transparent)]
94    struct Transparent(#[from] LoxError);
95
96    #[test]
97    fn test_lox_error_display_is_inner() {
98        let err = LoxError::new(LeafError);
99        assert_eq!(err.to_string(), "leaf failure");
100
101        let from_str: LoxError = "boom".into();
102        assert_eq!(from_str.to_string(), "boom");
103
104        let boxed: BoxedError = "kaboom".into();
105        let from_boxed: LoxError = boxed.into();
106        assert_eq!(from_boxed.to_string(), "kaboom");
107    }
108
109    #[test]
110    fn test_into_inner_downcasts_to_original() {
111        let err = LoxError::new(LeafError);
112        let inner = err.into_inner();
113        assert!(inner.downcast_ref::<LeafError>().is_some());
114    }
115
116    #[test]
117    fn test_find_source_through_transparent_wrappers() {
118        // A transparent wrapper hides itself from the chain, but the erased
119        // error stays reachable because `LoxError` is a real chain link.
120        let err = Transparent(LoxError::new(LeafError));
121        let found = find_source::<LeafError>(&err).expect("leaf must be reachable");
122        assert_eq!(found.to_string(), "leaf failure");
123        assert!(find_source::<core::fmt::Error>(&err).is_none());
124    }
125
126    #[test]
127    fn test_downcast_ref_finds_wrapped_error() {
128        let err = LoxError::new(LeafError);
129        assert!(err.downcast_ref::<LeafError>().is_some());
130        assert!(err.downcast_ref::<core::fmt::Error>().is_none());
131    }
132}