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