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}