Skip to content
Merged

445 #458

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

target
.claude
*.profraw
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Error + Send + Sync>` 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
Expand Down
4 changes: 4 additions & 0 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Error + Send + Sync>` 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
Expand Down
33 changes: 32 additions & 1 deletion src/app_error/constructors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -323,4 +324,34 @@ impl AppError {
pub fn cache(msg: impl Into<Cow<'static, str>>) -> 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<dyn Error + Send + Sync> = "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<dyn CoreError + Send + Sync + 'static>
) -> Self {
Self::bare(kind).with_boxed_source(source)
}
}
36 changes: 36 additions & 0 deletions src/app_error/core/introspection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,4 +357,40 @@ impl Error {
{
self.source.as_mut()?.as_dyn_mut()?.downcast_mut::<E>()
}

/// 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<dyn Error + Send + Sync> = "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<dyn CoreError + Send + Sync + 'static> {
Box::new(self)
}
}
59 changes: 59 additions & 0 deletions src/app_error/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,65 @@ fn downcast_mut_returns_none_for_shared_arc_attached_via_with_context() {
assert!(app_err.downcast_ref::<IoError>().is_some());
}

#[test]
fn from_boxed_preserves_kind_and_source_without_message() {
let source: Box<dyn StdError + Send + Sync> = 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<dyn StdError + Send + Sync> = Box::new(IoError::other("disk offline"));
let err = AppError::from_boxed(AppErrorKind::Internal, source);
let io = err.downcast_ref::<IoError>().expect("io source");
assert_eq!(io.to_string(), "disk offline");
let io = err.downcast::<IoError>().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::<AppError>()
.expect("inner app error");
assert_eq!(inner.message.as_deref(), Some("db down"));
let inner = restored.downcast::<AppError>().expect("owned inner");
assert!(matches!(inner.kind, AppErrorKind::Internal));
}

#[test]
fn boxed_error_converts_to_internal_app_error() {
let source: Box<dyn StdError + Send + Sync> = 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);
Expand Down
28 changes: 26 additions & 2 deletions src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")))]
Expand Down Expand Up @@ -193,6 +194,29 @@ impl From<String> 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<dyn Error + Send + Sync> = "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<Box<dyn CoreError + Send + Sync + 'static>> for AppError {
fn from(source: Box<dyn CoreError + Send + Sync + 'static>) -> Self {
AppError::from_boxed(AppErrorKind::Internal, source)
}
}

#[cfg(test)]
mod tests {
use crate::{AppError, AppErrorKind};
Expand Down
Loading