Skip to main content

frozen_core/
error.rs

1//! Utilities for error propagation used across `[frozen_core]`
2//!
3//! ## Components
4//!
5//! - [`FrozenError`] which is a structured error w/ 24-bit identifier
6//! - [`FrozenResult`] which is a result alias using [`FrozenError`]
7//!
8//! ## ID
9//!
10//! Each [`FrozenError`] uses a 24-bit identifier encoded in the following format,
11//!
12//! `| module:8 | domain:8 | reason:8 |`
13//!
14//! This id packs important context which aids in the debugging process
15//!
16//! ## Example
17//!
18//! ```
19//! use frozen_core::error::{FrozenError, FrozenResult, ErrCode};
20//!
21//! fn read_file() -> FrozenResult<()> {
22//!     Err(FrozenError::new(0x10, 0x20, ErrCode::new(0x30, "io"), "read failed"))
23//! }
24//!
25//! let err = read_file().unwrap_err();
26//!
27//! assert_eq!(err.module, 0x10);
28//! assert_eq!(err.domain, 0x20);
29//! assert_eq!(err.reason, 0x30);
30//!
31//! assert!(err.context.contains("[io]"));
32//! ```
33
34/// Custom result type w/ [`FrozenError`] as error type
35pub type FrozenResult<T> = Result<T, FrozenError>;
36
37/// Utility for error propagation used across [`frozen_core`](crate)
38#[derive(Clone)]
39pub struct FrozenError {
40    /// 8-bit unique identifier to identify the _module_ of [`FrozenError`] object
41    pub module: u8,
42
43    /// 8-bit unique identifier to identify the _domain_ of [`FrozenError`] object
44    pub domain: u8,
45
46    /// 8-bit unique identifier to identify the _reason_ of [`FrozenError`] object
47    pub reason: u8,
48
49    /// Error context for the [`FrozenError`]
50    pub context: Box<str>,
51
52    /// Secondary errors that occurred during best-effort cleanup or rollback
53    pub suppressed: Option<Box<Vec<FrozenError>>>,
54}
55
56impl FrozenError {
57    /// Construct a new [`FrozenError`]
58    ///
59    /// ## Example
60    ///
61    /// ```
62    /// use frozen_core::error::{FrozenError, ErrCode};
63    ///
64    /// let err = FrozenError::new(0x10, 0x20, ErrCode::new(0x30, "io"), "failed to read file");
65    ///
66    /// assert_eq!(err.module, 0x10);
67    /// assert_eq!(err.domain, 0x20);
68    /// assert_eq!(err.reason, 0x30);
69    ///
70    /// assert!(err.context.contains("[io]"));
71    /// assert!(err.context.contains("failed to read file"));
72    /// ```
73    #[inline(always)]
74    pub fn new(module: u8, domain: u8, code: ErrCode, errmsg: &str) -> Self {
75        Self {
76            module,
77            domain,
78            reason: code.reason,
79            context: format!("[{}] {}", code.detail, errmsg).into_boxed_str(),
80            suppressed: None,
81        }
82    }
83
84    /// Construct a new [`FrozenError`] from raw error object implementing [`std::fmt::Display`]
85    ///
86    /// ## Example
87    ///
88    /// ```
89    /// use frozen_core::error::{FrozenError, ErrCode};
90    ///
91    /// let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
92    /// let err = FrozenError::new_raw(0x14, 0x24, ErrCode::new(0x34, "io"), io_err);
93    ///
94    /// assert_eq!(err.module, 0x14);
95    /// assert_eq!(err.domain, 0x24);
96    /// assert_eq!(err.reason, 0x34);
97    ///
98    /// assert!(err.context.contains("[io]"));
99    /// assert!(err.context.contains("file missing"));
100    /// ```
101    #[inline(always)]
102    pub fn new_raw<E: std::fmt::Display>(module: u8, domain: u8, code: ErrCode, err: E) -> Self {
103        Self {
104            domain,
105            module,
106            reason: code.reason,
107            context: format!("[{}] {}", code.detail, err).into_boxed_str(),
108            suppressed: None,
109        }
110    }
111
112    /// Attach a secondary error that occurred during best-effort cleanup or rollback
113    pub fn add_suppressed(&mut self, err: FrozenError) {
114        let list = self.suppressed.get_or_insert_with(|| Box::new(Vec::new()));
115        list.push(err);
116    }
117
118    /// Builder-style helper to attach a suppressed error
119    pub fn with_suppressed(mut self, err: FrozenError) -> Self {
120        self.add_suppressed(err);
121        self
122    }
123
124    /// Returns a slice of suppressed errors attached to this error
125    #[inline]
126    pub fn suppressed(&self) -> &[FrozenError] {
127        match &self.suppressed {
128            Some(list) => list.as_slice(),
129            None => &[],
130        }
131    }
132
133    /// Sentinel placeholder for an unbound or default module identifier
134    pub const UNBOUND_MODULE: u8 = 0x00;
135
136    /// Construct a [`FrozenError`] where `module_id` is not yet known
137    ///
138    /// ## Example
139    ///
140    /// ```
141    /// use frozen_core::error::{FrozenError, ErrCode};
142    ///
143    /// let err = FrozenError::unbound(0x08, ErrCode::new(0x10, "inv"), "invalid path");
144    /// assert_eq!(err.module, FrozenError::UNBOUND_MODULE);
145    /// assert_eq!(err.domain, 0x08);
146    /// assert_eq!(err.reason, 0x10);
147    /// ```
148    #[inline(always)]
149    pub fn unbound(domain: u8, code: ErrCode, errmsg: &str) -> Self {
150        Self::new(Self::UNBOUND_MODULE, domain, code, errmsg)
151    }
152
153    /// Construct a [`FrozenError`] from a raw displayable error where `module_id` is not yet known
154    ///
155    /// ## Example
156    ///
157    /// ```
158    /// use frozen_core::error::{FrozenError, ErrCode};
159    ///
160    /// let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
161    /// let err = FrozenError::unbound_raw(0x08, ErrCode::new(0x10, "inv"), io_err);
162    /// assert_eq!(err.module, FrozenError::UNBOUND_MODULE);
163    /// assert_eq!(err.domain, 0x08);
164    /// assert_eq!(err.reason, 0x10);
165    /// ```
166    #[inline(always)]
167    pub fn unbound_raw<E: std::fmt::Display>(domain: u8, code: ErrCode, err: E) -> Self {
168        Self::new_raw(Self::UNBOUND_MODULE, domain, code, err)
169    }
170
171    /// Recursively stamps or overrides the 8-bit `module_id` onto this error and all attached
172    /// suppressed errors
173    ///
174    /// ## Example
175    ///
176    /// ```
177    /// use frozen_core::error::{FrozenError, ErrCode};
178    ///
179    /// let err = FrozenError::unbound(0x08, ErrCode::new(0x10, "inv"), "path error").with_module(0x05);
180    /// assert_eq!(err.module, 0x05);
181    /// assert_eq!(err.domain, 0x08);
182    /// assert_eq!(err.reason, 0x10);
183    /// ```
184    #[must_use]
185    pub fn with_module(mut self, module: u8) -> Self {
186        self.module = module;
187        if let Some(suppressed) = &mut self.suppressed {
188            for sub in suppressed.iter_mut() {
189                *sub = sub.clone().with_module(module);
190            }
191        }
192
193        self
194    }
195}
196
197impl FrozenError {
198    fn fmt_tree(
199        &self,
200        f: &mut std::fmt::Formatter<'_>,
201        prefix: &str,
202        is_tail: bool,
203        is_root: bool,
204    ) -> std::fmt::Result {
205        if is_root {
206            writeln!(
207                f,
208                "[{:#04x}:{:#04x}:{:#04x}] {}",
209                self.module, self.domain, self.reason, self.context
210            )?;
211        } else {
212            let branch = if is_tail { "└── " } else { "├── " };
213            writeln!(
214                f,
215                "{}{}[{:#04x}:{:#04x}:{:#04x}] {}",
216                prefix, branch, self.module, self.domain, self.reason, self.context
217            )?;
218        }
219
220        if let Some(suppressed) = &self.suppressed {
221            let next_prefix = if is_root {
222                String::new()
223            } else if is_tail {
224                format!("{prefix}    ")
225            } else {
226                format!("{prefix}│   ")
227            };
228
229            let count = suppressed.len();
230            for (idx, sub_err) in suppressed.iter().enumerate() {
231                let is_last = idx + 1 == count;
232                sub_err.fmt_tree(f, &next_prefix, is_last, false)?;
233            }
234        }
235
236        Ok(())
237    }
238}
239
240impl std::fmt::Debug for FrozenError {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match &self.suppressed {
243            None => write!(
244                f,
245                "FrozenError {{ module: {:#04x}, domain: {:#04x}, reason: {:#04x}, context: {:?} }}",
246                self.module, self.domain, self.reason, self.context
247            ),
248            Some(suppressed) => write!(
249                f,
250                "FrozenError {{ module: {:#04x}, domain: {:#04x}, reason: {:#04x}, context: {:?}, suppressed: {:?} }}",
251                self.module, self.domain, self.reason, self.context, suppressed
252            ),
253        }
254    }
255}
256
257impl std::fmt::Display for FrozenError {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        if self.suppressed.is_none() {
260            write!(
261                f,
262                "[{:#04x}:{:#04x}:{:#04x}] {}",
263                self.module, self.domain, self.reason, self.context
264            )
265        } else {
266            let mut formatted = String::new();
267
268            struct TreeHelper<'a>(&'a FrozenError);
269
270            impl std::fmt::Display for TreeHelper<'_> {
271                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272                    self.0.fmt_tree(f, "", true, true)
273                }
274            }
275
276            use std::fmt::Write as _;
277            let _ = write!(formatted, "{}", TreeHelper(self));
278
279            if formatted.ends_with('\n') {
280                formatted.pop();
281            }
282
283            write!(f, "{formatted}")
284        }
285    }
286}
287
288impl std::error::Error for FrozenError {}
289
290impl PartialEq for FrozenError {
291    fn eq(&self, other: &Self) -> bool {
292        (self.module == other.module)
293            && (self.domain == other.domain)
294            && (self.reason == other.reason)
295    }
296}
297
298/// Static error descriptor used to construct [`FrozenError`]
299///
300/// ## Example
301///
302/// ```
303/// use frozen_core::error::ErrCode;
304///
305/// const LOCK_ERR: ErrCode = frozen_core::error::ErrCode::new(0xFF, "lock error");
306///
307/// assert_eq!(LOCK_ERR.reason, 0xFF);
308/// assert_eq!(LOCK_ERR.detail, "lock error");
309/// ```
310#[derive(Debug, Clone)]
311pub struct ErrCode {
312    /// 8-bit reason code encoded into [`FrozenError`]
313    pub reason: u8,
314
315    /// Short subsystem label included in the formatted error context
316    pub detail: &'static str,
317}
318
319impl ErrCode {
320    /// Create a new [`ErrCode`]
321    ///
322    /// *NOTE:* This function is `const`, allowing error codes to be defined as compile-time
323    /// constants
324    ///
325    /// ## Example
326    ///
327    /// ```
328    /// use frozen_core::error::ErrCode;
329    ///
330    /// const LOCK_ERR: ErrCode = frozen_core::error::ErrCode::new(0xFF, "lock error");
331    ///
332    /// assert_eq!(LOCK_ERR.reason, 0xFF);
333    /// assert_eq!(LOCK_ERR.detail, "lock error");
334    /// ```
335    #[inline]
336    pub const fn new(reason: u8, detail: &'static str) -> Self {
337        Self { reason, detail }
338    }
339}
340
341/// Extension trait for [`FrozenResult`] to bind a `module_id` seamlessly
342///
343/// ## Example
344///
345/// ```
346/// use frozen_core::error::{BindModule, FrozenError, FrozenResult, ErrCode};
347///
348/// fn backend_call() -> FrozenResult<()> {
349///     Err(FrozenError::unbound(0x08, ErrCode::new(0x10, "io"), "failed"))
350/// }
351///
352/// let res = backend_call().with_module(0x02);
353/// assert_eq!(res.unwrap_err().module, 0x02);
354/// ```
355pub trait BindModule<T> {
356    /// Attaches `module_id` to the inner [`FrozenError`] if `self` is `Err`
357    fn with_module(self, module_id: u8) -> FrozenResult<T>;
358}
359
360impl<T> BindModule<T> for FrozenResult<T> {
361    #[inline(always)]
362    fn with_module(self, module_id: u8) -> FrozenResult<T> {
363        self.map_err(|e| e.with_module(module_id))
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn ok_context_exact_format() {
373        let err = FrozenError::new(1, 2, ErrCode::new(3, "io"), "failure");
374        assert_eq!(err.context, "[io] failure".into());
375    }
376
377    #[test]
378    fn ok_empty_message() {
379        let err = FrozenError::new(1, 2, ErrCode::new(3, "io"), "");
380        assert_eq!(err.context, "[io] ".into());
381    }
382
383    #[test]
384    fn ok_empty_detail() {
385        let err = FrozenError::new(1, 2, ErrCode::new(3, ""), "failure");
386        assert_eq!(err.context, "[] failure".into());
387    }
388
389    #[test]
390    fn ok_new_and_new_raw_same_id() {
391        let e1 = FrozenError::new(1, 2, ErrCode::new(3, "io"), "fail");
392
393        let io_err = std::io::Error::other("fail");
394        let e2 = FrozenError::new_raw(1, 2, ErrCode::new(3, "io"), io_err);
395
396        assert_eq!(e1, e2);
397    }
398
399    #[test]
400    fn ok_context_formatting() {
401        let err = FrozenError::new(1, 1, ErrCode::new(1, "io"), "disk failure");
402        assert_eq!(err.context, "[io] disk failure".into());
403    }
404
405    #[test]
406    fn ok_new_raw_uses_display() {
407        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
408        let err = FrozenError::new_raw(1, 2, ErrCode::new(3, "io"), io_err);
409
410        assert!(err.context.contains("[io]"));
411        assert!(err.context.contains("access denied"));
412    }
413
414    #[test]
415    fn ok_compare_same_id_different_context() {
416        let e1 = FrozenError::new(1, 2, ErrCode::new(3, "io"), "a");
417        let e2 = FrozenError::new(1, 2, ErrCode::new(3, "io"), "b");
418
419        assert_eq!(e1, e2);
420    }
421
422    #[test]
423    fn ok_compare_different_id() {
424        let e1 = FrozenError::new(1, 2, ErrCode::new(3, "io"), "a");
425        let e2 = FrozenError::new(1, 2, ErrCode::new(4, "io"), "a");
426
427        assert_ne!(e1, e2);
428    }
429
430    #[test]
431    fn ok_display_format() {
432        let err = FrozenError::new(0x01, 0x11, ErrCode::new(0x1C, "lock"), "file locked");
433        assert_eq!(format!("{err}"), "[0x01:0x11:0x1c] [lock] file locked");
434    }
435
436    #[test]
437    fn ok_debug_format_includes_context() {
438        let err = FrozenError::new(0x01, 0x11, ErrCode::new(0x1C, "lock"), "file locked");
439        let dbg = format!("{err:?}");
440        assert!(dbg.contains("0x01"));
441        assert!(dbg.contains("0x11"));
442        assert!(dbg.contains("0x1c"));
443        assert!(dbg.contains("[lock] file locked"));
444    }
445
446    #[test]
447    fn ok_is_std_error() {
448        // NOTE:
449        //
450        // `FrozenError` must satisfy `std::error::Error` so it can be used with `?` into `Box<dyn Error>` and ecosystem
451        // tooling
452        fn assert_is_error<E: std::error::Error>(_: &E) {}
453        let err = FrozenError::new(1, 2, ErrCode::new(3, "io"), "fail");
454        assert_is_error(&err);
455    }
456
457    #[test]
458    fn ok_suppressed_tree_display() {
459        let mut root = FrozenError::new(0x01, 0x10, ErrCode::new(0x20, "posix"), "f_advise failed");
460        let sub1 = FrozenError::new(0x01, 0x10, ErrCode::new(0x30, "posix"), "close failed");
461        let mut sub2 = FrozenError::new(0x01, 0x10, ErrCode::new(0x40, "posix"), "flush failed");
462        let sub2_nested = FrozenError::new(0x01, 0x10, ErrCode::new(0x50, "posix"), "sync failed");
463
464        sub2.add_suppressed(sub2_nested);
465        root.add_suppressed(sub1);
466        root.add_suppressed(sub2);
467
468        assert_eq!(root.suppressed().len(), 2);
469
470        let displayed = format!("{root}");
471        let expected = "\
472[0x01:0x10:0x20] [posix] f_advise failed
473├── [0x01:0x10:0x30] [posix] close failed
474└── [0x01:0x10:0x40] [posix] flush failed
475    └── [0x01:0x10:0x50] [posix] sync failed";
476
477        assert_eq!(displayed, expected);
478    }
479
480    #[test]
481    fn ok_with_suppressed_builder() {
482        let err = FrozenError::new(1, 2, ErrCode::new(3, "io"), "parent")
483            .with_suppressed(FrozenError::new(1, 2, ErrCode::new(4, "io"), "cleanup"));
484
485        assert_eq!(err.suppressed().len(), 1);
486        assert_eq!(err.suppressed()[0].reason, 4);
487    }
488
489    #[test]
490    fn ok_unbound_and_with_module() {
491        let mut err = FrozenError::unbound(0x08, ErrCode::new(0x10, "inv"), "unbound err");
492        assert_eq!(err.module, FrozenError::UNBOUND_MODULE);
493        assert_eq!(err.domain, 0x08);
494        assert_eq!(err.reason, 0x10);
495
496        let suppressed = FrozenError::unbound(0x08, ErrCode::new(0x20, "close"), "suppressed err");
497        err.add_suppressed(suppressed);
498
499        let bound = err.with_module(0x42);
500        assert_eq!(bound.module, 0x42);
501        assert_eq!(bound.domain, 0x08);
502        assert_eq!(bound.reason, 0x10);
503        assert_eq!(bound.suppressed()[0].module, 0x42);
504    }
505
506    #[test]
507    fn ok_bind_module_trait() {
508        let res: FrozenResult<()> =
509            Err(FrozenError::unbound(0x08, ErrCode::new(0x10, "inv"), "fail"));
510        let bound_res = res.with_module(0x07);
511        assert_eq!(bound_res.unwrap_err().module, 0x07);
512
513        let ok_res: FrozenResult<i32> = Ok(0x0A);
514        assert_eq!(ok_res.with_module(0x07).unwrap(), 0x0A);
515    }
516}