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}