Skip to main content

ogeom_core/
error.rs

1//! Errors as values.
2//!
3//! See `docs/DATA_MODEL.md` ยง12. The variants cover the failure vocabulary a
4//! kernel needs, chosen to line up with the categories applications already
5//! handle, but they are returned, not thrown, and no hardware signal is ever
6//! converted into one of them.
7//!
8//! The rule this encodes: an algorithm that did not converge says so. It does
9//! not return a null shape and set a flag for the caller to forget to check.
10
11use core::fmt;
12
13/// The result of any fallible kernel operation.
14pub type OgeomResult<T> = Result<T, OgeomError>;
15
16/// A kernel failure.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18#[non_exhaustive]
19pub enum OgeomError {
20    /// Arguments cannot produce the requested entity: three collinear points
21    /// for a circle, a zero-length direction, a self-intersecting wire.
22    #[error("construction failed: {0}")]
23    Construction(Cause),
24
25    /// An argument lies outside the domain the operation is defined on.
26    #[error("domain error: {0}")]
27    Domain(Cause),
28
29    /// A parameter or index lies outside its valid range.
30    #[error("out of range: {0}")]
31    Range(Cause),
32
33    /// Collections or geometries that had to agree in size or dimension did not.
34    #[error("dimension mismatch: {0}")]
35    Dimension(Cause),
36
37    /// An operation was handed a null or empty shape where one was required.
38    #[error("null object: {0}")]
39    NullObject(Cause),
40
41    /// A key did not resolve: most often a stale arena key, or a shape used
42    /// with an arena that does not own it.
43    #[error("dangling reference: {0}")]
44    Dangling(Cause),
45
46    /// A numerical method failed: no convergence, a singular system, a step
47    /// that could not be taken.
48    #[error("numeric failure: {0}")]
49    Numeric(Cause),
50
51    /// The algorithm ran but could not produce a result. Distinct from
52    /// [`OgeomError::Construction`]: the inputs were legitimate and the failure is
53    /// the algorithm's.
54    #[error("not done: {0}")]
55    NotDone(Cause),
56
57    /// The result exists but violates an invariant from `docs/DATA_MODEL.md`:
58    /// tolerance containment, orientation consistency, edge representation
59    /// agreement. Never returned silently. Producing invalid topology is worse
60    /// than failing.
61    #[error("invariant violated: {0}")]
62    Invariant(Cause),
63
64    /// Cancelled through a progress sink.
65    #[error("cancelled")]
66    Cancelled,
67
68    /// Reached a path that exists but has not been implemented yet.
69    #[error("not implemented: {0}")]
70    Unimplemented(Cause),
71}
72
73impl OgeomError {
74    /// Whether retrying with a looser tolerance could plausibly succeed.
75    ///
76    /// Used by the fuzzy-tolerance escape hatch: numerical failures are worth
77    /// retrying, malformed input is not.
78    #[must_use]
79    pub const fn is_tolerance_sensitive(&self) -> bool {
80        matches!(
81            self,
82            Self::Numeric(_) | Self::NotDone(_) | Self::Invariant(_)
83        )
84    }
85}
86
87/// A short, cheap explanation attached to an [`OgeomError`].
88///
89/// Static in the common case so that returning an error costs no allocation on
90/// paths that are hit often: failed intersections inside a boolean, for
91/// instance, are routine control flow rather than exceptional.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum Cause {
94    /// A compile-time message.
95    Static(&'static str),
96    /// A message built at runtime.
97    Owned(String),
98}
99
100impl fmt::Display for Cause {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::Static(s) => f.write_str(s),
104            Self::Owned(s) => f.write_str(s),
105        }
106    }
107}
108
109impl From<&'static str> for Cause {
110    fn from(s: &'static str) -> Self {
111        Self::Static(s)
112    }
113}
114
115impl From<String> for Cause {
116    fn from(s: String) -> Self {
117        Self::Owned(s)
118    }
119}
120
121impl From<fmt::Arguments<'_>> for Cause {
122    fn from(a: fmt::Arguments<'_>) -> Self {
123        a.as_str()
124            .map_or_else(|| Self::Owned(a.to_string()), Self::Static)
125    }
126}
127
128/// Build an [`OgeomError`] with a formatted cause.
129///
130/// ```
131/// # use ogeom_core::{ogeom_err, OgeomError};
132/// let e = ogeom_err!(Construction, "radius {} is not positive", -1.0);
133/// assert!(matches!(e, OgeomError::Construction(_)));
134/// ```
135#[macro_export]
136macro_rules! ogeom_err {
137    ($variant:ident, $msg:literal) => {
138        // Through `format_args!` rather than straight to `Cause::Static`, so a
139        // literal with inline captures (`"index {i}"`) interpolates instead
140        // of being taken verbatim. `Arguments::as_str` returns `Some` for a
141        // literal with nothing to interpolate, so the allocation-free path is
142        // preserved exactly where it applies.
143        $crate::OgeomError::$variant($crate::Cause::from(format_args!($msg)))
144    };
145    ($variant:ident, $fmt:literal, $($arg:tt)*) => {
146        $crate::OgeomError::$variant($crate::Cause::from(format_args!($fmt, $($arg)*)))
147    };
148}
149
150/// Return early with an [`OgeomError`] built by [`ogeom_err!`].
151#[macro_export]
152macro_rules! ogeom_bail {
153    ($variant:ident, $($arg:tt)*) => {
154        return Err($crate::ogeom_err!($variant, $($arg)*))
155    };
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn static_cause_does_not_allocate() {
165        let e = ogeom_err!(Domain, "parameter outside curve range");
166        assert!(matches!(e, OgeomError::Domain(Cause::Static(_))));
167    }
168
169    #[test]
170    fn inline_captures_interpolate_in_the_single_argument_form() {
171        // The trap this guards: a literal containing `{name}` taken verbatim
172        // ships the message with braces in it and silently drops the value.
173        let index = 7;
174        let e = ogeom_err!(Range, "index {index} is out of bounds");
175        assert_eq!(e.to_string(), "out of range: index 7 is out of bounds");
176        assert!(matches!(e, OgeomError::Range(Cause::Owned(_))));
177    }
178
179    #[test]
180    fn formatted_cause_renders() {
181        let e = ogeom_err!(Range, "index {} of {}", 7, 3);
182        assert_eq!(e.to_string(), "out of range: index 7 of 3");
183    }
184
185    #[test]
186    fn tolerance_sensitivity_splits_input_errors_from_algorithm_errors() {
187        assert!(ogeom_err!(Numeric, "no convergence").is_tolerance_sensitive());
188        assert!(ogeom_err!(NotDone, "could not close shell").is_tolerance_sensitive());
189        // Retrying a degenerate construction with a looser tolerance is pointless.
190        assert!(!ogeom_err!(Construction, "collinear points").is_tolerance_sensitive());
191        assert!(!OgeomError::Cancelled.is_tolerance_sensitive());
192    }
193
194    #[test]
195    fn bail_returns_early() {
196        fn f(ok: bool) -> OgeomResult<u8> {
197            if !ok {
198                ogeom_bail!(NullObject, "no shape");
199            }
200            Ok(1)
201        }
202        assert_eq!(f(true).unwrap(), 1);
203        assert!(f(false).is_err());
204    }
205}