From 6d2f7c0a4ffb47b30fbc837ec93fe5e2cb1cda37 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Oct 2025 09:26:36 +0700 Subject: [PATCH 1/6] feat: add simple .context() method for anyhow parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ResultExt::context(msg) as ergonomic alternative to .ctx(). This matches anyhow's API while maintaining masterror's features. Usage: read_file().context("Failed to read config")? Equivalent to ctx but simpler for common cases where you just need a message without custom kind/metadata. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 1 - src/result_ext.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b14aa63..ff07fea 100644 --- a/README.md +++ b/README.md @@ -459,4 +459,3 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); MSRV: **1.90** ยท License: **MIT OR Apache-2.0** ยท No `unsafe` - diff --git a/src/result_ext.rs b/src/result_ext.rs index 88bfb88..c441797 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -36,6 +36,33 @@ pub trait ResultExt { fn ctx(self, build: impl FnOnce() -> Context) -> Result where E: CoreError + Send + Sync + 'static; + + /// Wrap the error with a simple context message. + /// + /// This is a convenience method equivalent to anyhow's `.context()`. + /// For more control, use [`ctx`](ResultExt::ctx). + /// + /// # Examples + /// + /// ```rust + /// use std::io::{Error as IoError, ErrorKind}; + /// + /// use masterror::ResultExt; + /// + /// fn read_config() -> Result { + /// Err(IoError::from(ErrorKind::NotFound)) + /// } + /// + /// let err = read_config() + /// .context("Failed to read config file") + /// .unwrap_err(); + /// + /// assert!(err.source_ref().is_some()); + /// ``` + #[allow(clippy::result_large_err)] + fn context(self, msg: impl Into>) -> Result + where + E: CoreError + Send + Sync + 'static; } impl ResultExt for Result { @@ -45,6 +72,13 @@ impl ResultExt for Result { { self.map_err(|err| build().into_error(err)) } + + fn context(self, msg: impl Into>) -> Result + where + E: CoreError + Send + Sync + 'static + { + self.map_err(|err| Error::internal(msg).with_context(err)) + } } #[cfg(test)] @@ -219,4 +253,14 @@ mod tests { assert!(err.backtrace().is_some()); }); } + + #[test] + fn context_wraps_with_simple_message() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result.context("operation failed").expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::Internal); + assert!(err.source_ref().is_some()); + assert!(err.source_ref().unwrap().is::()); + } } From fbe01438ba35821d5fb70dd4870c9286343eb25b Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Oct 2025 09:27:42 +0700 Subject: [PATCH 2/6] feat: add redaction example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows GDPR-compliant field redaction with Hash/Redact policies. Demonstrates per-field privacy controls for sensitive data. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/redaction.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/redaction.rs diff --git a/examples/redaction.rs b/examples/redaction.rs new file mode 100644 index 0000000..0d609d2 --- /dev/null +++ b/examples/redaction.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Redaction example showing GDPR-compliant field masking. + +use masterror::{AppError, FieldRedaction, field}; + +fn main() { + let err = AppError::bad_request("Invalid credentials") + .with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash)) + .with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact)) + .with_field(field::str("session_id", "abc123")); + + println!("=== Redacted Metadata ===\n"); + for (key, value, redaction) in err.metadata().iter_with_redaction() { + println!("{key}: {value:?} [{redaction:?}]"); + } +} From 4e609339c0106c554db23ae28de4cf357e4e1bcc Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Oct 2025 09:37:28 +0700 Subject: [PATCH 3/6] docs: update WHY_MIGRATE.md with new anyhow parity features Added documentation for: - .context() method (anyhow-style ergonomics) - .downcast_ref() for error introspection - Examples directory with comprehensive demos - Zero-friction migration from anyhow --- WHY_MIGRATE.md | 57 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/WHY_MIGRATE.md b/WHY_MIGRATE.md index da32f34..282c74f 100644 --- a/WHY_MIGRATE.md +++ b/WHY_MIGRATE.md @@ -91,6 +91,10 @@ use masterror::prelude::*; fn process() -> AppResult<()> { ensure!(condition, AppError::bad_request("invalid input")); + // Simple context (anyhow-style) + database_call().context("db operation failed")?; + + // Or structured context with metadata database_call() .ctx(|| Context::new(AppErrorKind::Database) .with(field::str("table", "users")) @@ -100,7 +104,7 @@ fn process() -> AppResult<()> { fail!(AppError::internal("unrecoverable")); } -// โœ… Same ergonomics as anyhow +// โœ… Same ergonomics as anyhow (.context(), .chain(), .downcast_ref()) // โœ… Plus: typed errors // โœ… Plus: structured metadata // โœ… Plus: automatic tracing @@ -194,6 +198,32 @@ let err = AppError::database("query failed") // โœ… Zero boilerplate ``` +#### 5. Error Introspection (anyhow Parity) + +```rust +// anyhow: type-safe error inspection +if let Some(io_err) = err.downcast_ref::() { + match io_err.kind() { + io::ErrorKind::NotFound => /* handle */, + _ => /* other */ + } +} + +// masterror: same API, works with AppError +use masterror::ResultExt; + +match database_op().context("db failed") { + Err(err) => { + if let Some(io_err) = err.downcast_ref::() { + // โœ… Type-safe downcasting + // โœ… Inspect wrapped error sources + // โœ… Full anyhow API compatibility + } + } + Ok(val) => val +} +``` + ## Migration Guide ### From thiserror @@ -243,18 +273,29 @@ bail!("invalid input"); fail!(AppError::bad_request("invalid input")); ``` -**Step 3:** Enhance context +**Step 3:** Keep using .context() (it just works!) ```rust -// Before +// Before (anyhow) .context("db error")? -// After +// After (masterror) - identical API +.context("db error")? + +// Or use structured context for better observability .ctx(|| Context::new(AppErrorKind::Database) .with(field::str("table", "users")) )? ``` -**Result:** Type-safe, structured, observable errors. +**Step 4:** Error introspection works the same +```rust +// anyhow API still works +if let Some(io_err) = err.downcast_ref::() { + // handle specific error type +} +``` + +**Result:** Type-safe, structured, observable errors with zero API friction. ## Real-World Impact @@ -346,7 +387,11 @@ Binary size: 944KB (vs thiserror 32KB, anyhow 566KB) - ๐Ÿ“š [Full Documentation](https://docs.rs/masterror) - ๐Ÿ“Š [Benchmarks](BENCHMARKS.md) -- ๐Ÿ”ง [Examples](examples/) +- ๐Ÿ”ง **[Examples](examples/)** - See working code for: + - [Basic Usage](examples/basic_usage.rs) - Core error handling patterns + - [thiserror Compatibility](examples/derive_error.rs) - Drop-in replacement + - [Structured Metadata](examples/structured_metadata.rs) - Typed fields vs strings + - [Redaction](examples/redaction.rs) - GDPR-compliant privacy controls - ๐Ÿ’ฌ [GitHub Issues](https://github.com/RAprogramm/masterror/issues) --- From 7b641f4839c73c8c77e3cea525f950823be52187 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:32:03 +0700 Subject: [PATCH 4/6] Preserve AppError context in ResultExt --- src/result_ext.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/result_ext.rs b/src/result_ext.rs index c441797..a24770c 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -77,7 +77,39 @@ impl ResultExt for Result { where E: CoreError + Send + Sync + 'static { - self.map_err(|err| Error::internal(msg).with_context(err)) + let msg = msg.into(); + + self.map_err(|err| { + let source: Box = Box::new(err); + + match source.downcast::() { + Ok(app_err) => { + let mut app_err = *app_err; + let mut enriched = Error::new_raw(app_err.kind, Some(msg.clone())); + + enriched.code = app_err.code; + enriched.metadata = app_err.metadata.clone(); + enriched.edit_policy = app_err.edit_policy; + enriched.retry = app_err.retry; + enriched.www_authenticate = app_err.www_authenticate.clone(); + #[cfg(feature = "serde_json")] + { + enriched.details = app_err.details.clone(); + } + #[cfg(not(feature = "serde_json"))] + { + enriched.details = app_err.details.clone(); + } + #[cfg(feature = "backtrace")] + if let Some(backtrace) = app_err.backtrace().cloned() { + enriched = enriched.with_backtrace(backtrace); + } + + enriched.with_context(app_err) + } + Err(source) => Error::internal(msg.clone()).with_context(source) + } + }) } } @@ -97,7 +129,7 @@ mod tests { use crate::app_error::{reset_backtrace_preference, set_backtrace_preference_override}; use crate::{ AppCode, AppErrorKind, - app_error::{Context, FieldValue, MessageEditPolicy}, + app_error::{Context, Error, FieldValue, MessageEditPolicy}, field }; @@ -263,4 +295,32 @@ mod tests { assert!(err.source_ref().is_some()); assert!(err.source_ref().unwrap().is::()); } + + #[test] + fn context_preserves_app_error_classification() { + let base = Error::bad_request("missing flag") + .with_field(field::str("flag", "beta")) + .with_code(AppCode::Cache) + .redactable(); + + let err = Result::<(), Error>::Err(base) + .context("parsing configuration failed") + .expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::BadRequest); + assert_eq!(err.code, AppCode::Cache); + assert_eq!(err.message.as_deref(), Some("parsing configuration failed")); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); + assert_eq!( + err.metadata().get("flag"), + Some(&FieldValue::Str(Cow::Borrowed("beta"))) + ); + + let source = err + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("app error source"); + assert_eq!(source.kind, AppErrorKind::BadRequest); + assert_eq!(source.message.as_deref(), Some("missing flag")); + } } From 65b12a50407a83d83a9241a64cb15e015094eae0 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:56:20 +0700 Subject: [PATCH 5/6] Fix ResultExt context conversion --- src/result_ext.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/result_ext.rs b/src/result_ext.rs index a24770c..4b46b9a 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +use alloc::sync::Arc; use core::error::Error as CoreError; use crate::app_error::{Context, Error}; @@ -84,10 +85,10 @@ impl ResultExt for Result { match source.downcast::() { Ok(app_err) => { - let mut app_err = *app_err; + let app_err = *app_err; let mut enriched = Error::new_raw(app_err.kind, Some(msg.clone())); - enriched.code = app_err.code; + enriched.code = app_err.code.clone(); enriched.metadata = app_err.metadata.clone(); enriched.edit_policy = app_err.edit_policy; enriched.retry = app_err.retry; @@ -107,7 +108,7 @@ impl ResultExt for Result { enriched.with_context(app_err) } - Err(source) => Error::internal(msg.clone()).with_context(source) + Err(source) => Error::internal(msg.clone()).with_source_arc(Arc::from(source)) } }) } From ba28413b7f35b32e55f8c62c5624871cbb29ac25 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sun, 5 Oct 2025 11:30:27 +0700 Subject: [PATCH 6/6] Preserve backtraces when wrapping AppError contexts --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/app_error/core.rs | 41 ++++++++++++++++++++++++++++++++++------- src/result_ext.rs | 7 +++++-- 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf60a98..1dd5fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.17] - 2025-11-02 + +### Fixed +- Preserve captured backtraces when wrapping `AppError` instances through + `ResultExt::context` by sharing the snapshot instead of attempting to clone + `std::backtrace::Backtrace`. + ## [0.24.16] - 2025-11-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a41ebf0..bfff7a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1804,7 +1804,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.16" +version = "0.24.17" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 5860848..1004cda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "masterror" -version = "0.24.16" +version = "0.24.17" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index ff07fea..520cb40 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.16", default-features = false } +masterror = { version = "0.24.17", default-features = false } # or with features: -# masterror = { version = "0.24.16", features = [ +# masterror = { version = "0.24.17", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 26e1168..7ccf280 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -91,9 +91,9 @@ pub struct ErrorInner { pub details: Option, pub source: Option>, #[cfg(feature = "backtrace")] - pub backtrace: Option, + pub backtrace: Option>, #[cfg(feature = "backtrace")] - pub captured_backtrace: OnceLock>, + pub captured_backtrace: OnceLock>>, telemetry_dirty: AtomicBool, #[cfg(feature = "tracing")] tracing_dirty: AtomicBool @@ -110,9 +110,9 @@ const BACKTRACE_STATE_DISABLED: u8 = 2; static BACKTRACE_STATE: AtomicU8 = AtomicU8::new(BACKTRACE_STATE_UNSET); #[cfg(feature = "backtrace")] -fn capture_backtrace_snapshot() -> Option { +fn capture_backtrace_snapshot() -> Option> { if should_capture_backtrace() { - Some(Backtrace::capture()) + Some(Arc::new(Backtrace::capture())) } else { None } @@ -321,13 +321,13 @@ impl Error { #[cfg(feature = "backtrace")] fn capture_backtrace(&self) -> Option<&CapturedBacktrace> { - if let Some(backtrace) = self.backtrace.as_ref() { + if let Some(backtrace) = self.backtrace.as_deref() { return Some(backtrace); } self.captured_backtrace .get_or_init(capture_backtrace_snapshot) - .as_ref() + .as_deref() } #[cfg(not(feature = "backtrace"))] @@ -336,7 +336,7 @@ impl Error { } #[cfg(feature = "backtrace")] - fn set_backtrace_slot(&mut self, backtrace: CapturedBacktrace) { + fn set_backtrace_slot(&mut self, backtrace: Arc) { self.backtrace = Some(backtrace); self.captured_backtrace = OnceLock::new(); } @@ -574,6 +574,21 @@ impl Error { /// Attach a captured backtrace. #[must_use] pub fn with_backtrace(mut self, backtrace: CapturedBacktrace) -> Self { + #[cfg(feature = "backtrace")] + { + self.set_backtrace_slot(Arc::new(backtrace)); + } + + #[cfg(not(feature = "backtrace"))] + { + self.set_backtrace_slot(backtrace); + } + self.mark_dirty(); + self + } + + #[cfg(feature = "backtrace")] + pub(crate) fn with_shared_backtrace(mut self, backtrace: Arc) -> Self { self.set_backtrace_slot(backtrace); self.mark_dirty(); self @@ -678,6 +693,18 @@ impl Error { self.capture_backtrace() } + #[cfg(feature = "backtrace")] + pub(crate) fn backtrace_shared(&self) -> Option> { + if let Some(backtrace) = self.backtrace.as_ref() { + return Some(Arc::clone(backtrace)); + } + + self.captured_backtrace + .get_or_init(capture_backtrace_snapshot) + .as_ref() + .map(Arc::clone) + } + /// Borrow the source if present. #[must_use] pub fn source_ref(&self) -> Option<&(dyn CoreError + Send + Sync + 'static)> { diff --git a/src/result_ext.rs b/src/result_ext.rs index 4b46b9a..790a095 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -102,8 +102,11 @@ impl ResultExt for Result { enriched.details = app_err.details.clone(); } #[cfg(feature = "backtrace")] - if let Some(backtrace) = app_err.backtrace().cloned() { - enriched = enriched.with_backtrace(backtrace); + let shared_backtrace = app_err.backtrace_shared(); + + #[cfg(feature = "backtrace")] + if let Some(backtrace) = shared_backtrace { + enriched = enriched.with_shared_backtrace(backtrace); } enriched.with_context(app_err)