diff --git a/.gitignore b/.gitignore index b7454ee..d865234 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target .claude +*.profraw diff --git a/README.md b/README.md index efccc33..60cc60c 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ of redaction and metadata. - **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box. +- **Boxed error interop.** `AppError::from_boxed` adopts any + `Box` without re-boxing and `into_boxed_dyn_error` + converts back, matching `anyhow`'s round-trip API while keeping the source + chain intact and downcastable. - **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions with your domain errors without allocating or formatting on the happy path, while `app_error!` builds ad-hoc `AppError` values with `format!`-style diff --git a/README.template.md b/README.template.md index ab4005b..e3d768e 100644 --- a/README.template.md +++ b/README.template.md @@ -94,6 +94,10 @@ of redaction and metadata. - **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box. +- **Boxed error interop.** `AppError::from_boxed` adopts any + `Box` without re-boxing and `into_boxed_dyn_error` + converts back, matching `anyhow`'s round-trip API while keeping the source + chain intact and downcastable. - **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions with your domain errors without allocating or formatting on the happy path, while `app_error!` builds ad-hoc `AppError` values with `format!`-style diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index 6868c99..8db270e 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -20,7 +20,8 @@ //! enabling flexible message construction from string literals, owned strings, //! or pre-built `Cow` instances. -use alloc::borrow::Cow; +use alloc::{borrow::Cow, boxed::Box}; +use core::error::Error as CoreError; use super::core::AppError; use crate::AppErrorKind; @@ -323,4 +324,34 @@ impl AppError { pub fn cache(msg: impl Into>) -> Self { Self::with(AppErrorKind::Cache, msg) } + + /// Build a message-less error with the given kind from an already boxed + /// source error, without re-boxing the allocation. + /// + /// The source is stored as an owned attachment, so it stays recoverable by + /// value via [`downcast`](Self::downcast) and borrowable via + /// [`downcast_ref`](Self::downcast_ref). This mirrors + /// `anyhow::Error::from_boxed` (available since anyhow 1.0.95) and forms a + /// round-trip with [`into_boxed_dyn_error`](Self::into_boxed_dyn_error). + /// + /// ```rust + /// use core::error::Error; + /// + /// use masterror::{AppError, AppErrorKind}; + /// + /// let source: Box = "disk offline".into(); + /// let err = AppError::from_boxed(AppErrorKind::Internal, source); + /// assert!(err.message.is_none()); + /// assert_eq!( + /// err.source_ref().expect("source").to_string(), + /// "disk offline" + /// ); + /// ``` + #[must_use] + pub fn from_boxed( + kind: AppErrorKind, + source: Box + ) -> Self { + Self::bare(kind).with_boxed_source(source) + } } diff --git a/src/app_error/core/introspection.rs b/src/app_error/core/introspection.rs index f58bac0..bf8dd07 100644 --- a/src/app_error/core/introspection.rs +++ b/src/app_error/core/introspection.rs @@ -357,4 +357,40 @@ impl Error { { self.source.as_mut()?.as_dyn_mut()?.downcast_mut::() } + + /// Convert the error into a boxed trait object. + /// + /// The error is always boxed as-is, never unwrapped to its source, so the + /// returned trait object keeps the full `Display` output, the kind, the + /// metadata and the entire [`source()`](CoreError::source) chain of this + /// error. This mirrors `anyhow::Error::into_boxed_dyn_error` (available + /// since anyhow 1.0.98) and forms a round-trip with + /// [`from_boxed`](Self::from_boxed). + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::internal("db down"); + /// let display = err.to_string(); + /// let boxed = err.into_boxed_dyn_error(); + /// assert_eq!(boxed.to_string(), display); + /// ``` + /// + /// Round-trip through [`from_boxed`](Self::from_boxed) keeps the chain: + /// + /// ```rust + /// use core::error::Error; + /// + /// use masterror::{AppError, AppErrorKind}; + /// + /// let source: Box = "root cause".into(); + /// let err = AppError::from_boxed(AppErrorKind::Internal, source); + /// let restored = AppError::from_boxed(AppErrorKind::Internal, err.into_boxed_dyn_error()); + /// assert_eq!(restored.chain().count(), 3); + /// assert_eq!(restored.root_cause().to_string(), "root cause"); + /// ``` + #[must_use] + pub fn into_boxed_dyn_error(self) -> Box { + Box::new(self) + } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index cc706ad..78b6a83 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -880,6 +880,65 @@ fn downcast_mut_returns_none_for_shared_arc_attached_via_with_context() { assert!(app_err.downcast_ref::().is_some()); } +#[test] +fn from_boxed_preserves_kind_and_source_without_message() { + let source: Box = Box::new(IoError::other("disk offline")); + let err = AppError::from_boxed(AppErrorKind::Service, source); + assert!(matches!(err.kind, AppErrorKind::Service)); + assert!(err.message.is_none()); + assert_eq!( + err.source_ref().expect("source").to_string(), + "disk offline" + ); +} + +#[test] +fn from_boxed_source_downcasts_to_concrete_type() { + let source: Box = Box::new(IoError::other("disk offline")); + let err = AppError::from_boxed(AppErrorKind::Internal, source); + let io = err.downcast_ref::().expect("io source"); + assert_eq!(io.to_string(), "disk offline"); + let io = err.downcast::().expect("owned source"); + assert_eq!(io.to_string(), "disk offline"); +} + +#[test] +fn into_boxed_dyn_error_preserves_display_and_chain() { + let io_err = IoError::other("disk offline"); + let err = AppError::internal("db down").with_source(io_err); + let display = err.to_string(); + let boxed = err.into_boxed_dyn_error(); + assert_eq!(boxed.to_string(), display); + let source = boxed.source().expect("io source"); + assert_eq!(source.to_string(), "disk offline"); + assert!(source.source().is_none()); +} + +#[test] +fn boxed_round_trip_keeps_chain_and_downcasts() { + let io_err = IoError::other("disk offline"); + let err = AppError::internal("db down").with_source(io_err); + let restored = AppError::from_boxed(AppErrorKind::Internal, err.into_boxed_dyn_error()); + let chain: Vec<_> = restored.chain().collect(); + assert_eq!(chain.len(), 3); + assert_eq!(restored.root_cause().to_string(), "disk offline"); + let inner = restored + .downcast_ref::() + .expect("inner app error"); + assert_eq!(inner.message.as_deref(), Some("db down")); + let inner = restored.downcast::().expect("owned inner"); + assert!(matches!(inner.kind, AppErrorKind::Internal)); +} + +#[test] +fn boxed_error_converts_to_internal_app_error() { + let source: Box = Box::new(IoError::other("boom")); + let err: AppError = source.into(); + assert!(matches!(err.kind, AppErrorKind::Internal)); + assert!(err.message.is_none()); + assert_eq!(err.source_ref().expect("source").to_string(), "boom"); +} + #[test] fn local_display_bare_error_without_message() { let _guard = force_display_mode(DisplayMode::Local); diff --git a/src/convert.rs b/src/convert.rs index d780602..386866f 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -85,11 +85,12 @@ //! assert!(matches!(err.kind, AppErrorKind::BadRequest)); //! ``` -use alloc::string::String; +use alloc::{boxed::Box, string::String}; +use core::error::Error as CoreError; #[cfg(feature = "std")] use std::io::Error as IoError; -use crate::AppError; +use crate::{AppError, AppErrorKind}; #[cfg(feature = "axum")] #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] @@ -193,6 +194,29 @@ impl From for AppError { } } +/// Map an already boxed error to an internal application error. +/// +/// The box is stored as the owned source without re-boxing, so the concrete +/// error stays recoverable via [`AppError::downcast`] and +/// [`AppError::downcast_ref`]. Equivalent to +/// [`AppError::from_boxed`] with [`AppErrorKind::Internal`]. +/// +/// ```rust +/// use core::error::Error; +/// +/// use masterror::{AppError, AppErrorKind}; +/// +/// let source: Box = "boom".into(); +/// let err: AppError = source.into(); +/// assert!(matches!(err.kind, AppErrorKind::Internal)); +/// assert_eq!(err.source_ref().expect("source").to_string(), "boom"); +/// ``` +impl From> for AppError { + fn from(source: Box) -> Self { + AppError::from_boxed(AppErrorKind::Internal, source) + } +} + #[cfg(test)] mod tests { use crate::{AppError, AppErrorKind};