diff --git a/README.md b/README.md index 60cc60c..3f91f6f 100644 --- a/README.md +++ b/README.md @@ -501,12 +501,15 @@ generated [`masterror::Error`]. `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` -and `AppCode`. The derive mirrors `thiserror`'s syntax. Note that the -generated `From` conversions produce an `AppError` carrying only the mapped -kind and code (plus the `Display` output as public message when the `message` -flag is set) — the original domain error is dropped, so its sources and -telemetry providers are not forwarded. Request telemetry from the domain -error before converting. +and `AppCode`. The derive mirrors `thiserror`'s syntax. The generated `From` +conversions produce an `AppError` carrying the mapped kind and code (plus the +`Display` output as public message when the `message` flag is set) and attach +the original domain error as the source: it stays downcastable via +`downcast_ref`, shows up in `chain()`/`root_cause()`, and its `#[provide]` +data is forwarded through the `AppError` on toolchains with +`error_generic_member_access`. Source attachment requires the domain error to +be `Send + Sync + 'static`; add the `no_source` flag to `#[app_error(...)]` +to opt out and drop the domain error during conversion instead. `request_ref`/`request_value` and the `std::error::Request` machinery require a nightly toolchain (`error_generic_member_access`); the crate detects diff --git a/README.template.md b/README.template.md index e3d768e..da48587 100644 --- a/README.template.md +++ b/README.template.md @@ -496,12 +496,15 @@ generated [`masterror::Error`]. `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` -and `AppCode`. The derive mirrors `thiserror`'s syntax. Note that the -generated `From` conversions produce an `AppError` carrying only the mapped -kind and code (plus the `Display` output as public message when the `message` -flag is set) — the original domain error is dropped, so its sources and -telemetry providers are not forwarded. Request telemetry from the domain -error before converting. +and `AppCode`. The derive mirrors `thiserror`'s syntax. The generated `From` +conversions produce an `AppError` carrying the mapped kind and code (plus the +`Display` output as public message when the `message` flag is set) and attach +the original domain error as the source: it stays downcastable via +`downcast_ref`, shows up in `chain()`/`root_cause()`, and its `#[provide]` +data is forwarded through the `AppError` on toolchains with +`error_generic_member_access`. Source attachment requires the domain error to +be `Send + Sync + 'static`; add the `no_source` flag to `#[app_error(...)]` +to opt out and drop the domain error during conversion instead. `request_ref`/`request_value` and the `std::error::Request` machinery require a nightly toolchain (`error_generic_member_access`); the crate detects diff --git a/masterror-derive/src/app_error_impl.rs b/masterror-derive/src/app_error_impl.rs index c9a944c..92bfbbf 100644 --- a/masterror-derive/src/app_error_impl.rs +++ b/masterror-derive/src/app_error_impl.rs @@ -4,7 +4,7 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::Error; +use syn::{Error, parse_quote}; use crate::input::{AppErrorSpec, ErrorData, ErrorInput, Fields, StructData, VariantData}; @@ -30,7 +30,7 @@ fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result Result<(), Error> { Ok(()) } +/// Ensures every variant agrees on the `no_source` flag. +/// +/// Source attachment is a property of the whole enum conversion, so mixing +/// `no_source` and source-attaching variants is rejected. +fn ensure_consistent_no_source(variants: &[VariantData]) -> Result { + let mut specs = variants + .iter() + .filter_map(|variant| variant.app_error.as_ref()); + let no_source = specs.next().is_some_and(|spec| spec.no_source); + for spec in specs { + if spec.no_source != no_source { + return Err(Error::new( + spec.attribute_span, + "`no_source` must be specified on every #[app_error(...)] variant or on none" + )); + } + } + Ok(no_source) +} + +/// Clones the input generics and adds the bounds required by +/// `AppError::with_source` for the derived type itself. +fn source_bound_generics(input: &ErrorInput) -> syn::Generics { + let ident = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + let mut generics = input.generics.clone(); + generics.make_where_clause().predicates.push(parse_quote! { + #ident #ty_generics: core::error::Error + core::marker::Send + core::marker::Sync + 'static + }); + generics +} + +/// Wraps a conversion body into a `From for AppError` implementation. +fn app_error_from_impl( + ident: &syn::Ident, + generics: &syn::Generics, + body: TokenStream +) -> TokenStream { + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppError #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #body + } + } + } +} + fn struct_app_error_impl(input: &ErrorInput, spec: &AppErrorSpec) -> TokenStream { let ident = &input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let kind = &spec.kind; + if spec.no_source { + let body = if spec.expose_message { + quote! { + masterror::AppError::with(#kind, std::string::ToString::to_string(&value)) + } + } else { + quote! { + { + let _ = value; + masterror::AppError::bare(#kind) + } + } + }; + return app_error_from_impl(ident, &input.generics, body); + } + let generics = source_bound_generics(input); let body = if spec.expose_message { quote! { masterror::AppError::with(#kind, std::string::ToString::to_string(&value)) + .with_source(value) } } else { quote! { - { - let _ = value; - masterror::AppError::bare(#kind) - } + masterror::AppError::bare(#kind).with_source(value) } }; - quote! { - impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppError #where_clause { - fn from(value: #ident #ty_generics) -> Self { - #body - } - } - } + app_error_from_impl(ident, &generics, body) } fn struct_app_code_impl(input: &ErrorInput, spec: &AppErrorSpec) -> TokenStream { @@ -116,9 +171,12 @@ fn struct_app_code_impl(input: &ErrorInput, spec: &AppErrorSpec) -> TokenStream } } -fn enum_app_error_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { +fn enum_app_error_impl( + input: &ErrorInput, + variants: &[VariantData] +) -> Result { let ident = &input.ident; - let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let no_source = ensure_consistent_no_source(variants)?; let mut arms = Vec::new(); for variant in variants { let spec = variant.app_error.as_ref().expect("presence checked"); @@ -126,27 +184,29 @@ fn enum_app_error_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStr let pattern = variant_app_error_pattern(ident, variant); let body = if spec.expose_message { quote! { - masterror::AppError::with(#kind, std::string::ToString::to_string(&err)) + masterror::AppError::with(#kind, std::string::ToString::to_string(&value)) } } else { quote! { - { - let _ = err; - masterror::AppError::bare(#kind) - } + masterror::AppError::bare(#kind) } }; arms.push(quote! { #pattern => #body }); } - quote! { - impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::AppError #where_clause { - fn from(value: #ident #ty_generics) -> Self { - match value { - #(#arms),* - } - } + let base = quote! { + match &value { + #(#arms),* } + }; + if no_source { + return Ok(app_error_from_impl(ident, &input.generics, base)); } + let generics = source_bound_generics(input); + let body = quote! { + let base: masterror::AppError = #base; + base.with_source(value) + }; + Ok(app_error_from_impl(ident, &generics, body)) } fn enum_app_code_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { @@ -173,9 +233,9 @@ fn enum_app_code_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStre fn variant_app_error_pattern(enum_ident: &syn::Ident, variant: &VariantData) -> TokenStream { let ident = &variant.ident; match &variant.fields { - Fields::Unit => quote! { err @ #enum_ident::#ident }, - Fields::Named(_) => quote! { err @ #enum_ident::#ident { .. } }, - Fields::Unnamed(_) => quote! { err @ #enum_ident::#ident(..) } + Fields::Unit => quote! { #enum_ident::#ident }, + Fields::Named(_) => quote! { #enum_ident::#ident { .. } }, + Fields::Unnamed(_) => quote! { #enum_ident::#ident(..) } } } @@ -187,3 +247,97 @@ fn variant_app_code_pattern(enum_ident: &syn::Ident, variant: &VariantData) -> T Fields::Unnamed(_) => quote! { #enum_ident::#ident(..) } } } + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::*; + use crate::input::parse_input; + + fn parse(input: syn::DeriveInput) -> ErrorInput { + parse_input(input).expect("parse input") + } + + #[test] + fn struct_conversion_attaches_source() { + let parsed = parse(parse_quote! { + #[error("boom")] + #[app_error(kind = AppErrorKind::Internal)] + struct Boom; + }); + let impls = expand(&parsed).expect("expand"); + assert_eq!(impls.len(), 1); + let output = impls[0].to_string(); + assert!(output.contains("with_source")); + assert!(output.contains("Send")); + assert!(!output.contains("let _ = value")); + } + + #[test] + fn struct_no_source_drops_value() { + let parsed = parse(parse_quote! { + #[error("boom")] + #[app_error(kind = AppErrorKind::Internal, no_source)] + struct Boom; + }); + let impls = expand(&parsed).expect("expand"); + assert_eq!(impls.len(), 1); + let output = impls[0].to_string(); + assert!(!output.contains("with_source")); + assert!(!output.contains("Send")); + } + + #[test] + fn enum_conversion_attaches_source_once() { + let parsed = parse(parse_quote! { + enum Failure { + #[error("a")] + #[app_error(kind = AppErrorKind::Internal, message)] + A, + #[error("b")] + #[app_error(kind = AppErrorKind::Service)] + B + } + }); + let impls = expand(&parsed).expect("expand"); + assert_eq!(impls.len(), 1); + let output = impls[0].to_string(); + assert_eq!(output.matches("with_source").count(), 1); + } + + #[test] + fn enum_all_no_source_drops_value() { + let parsed = parse(parse_quote! { + enum Failure { + #[error("a")] + #[app_error(kind = AppErrorKind::Internal, no_source)] + A, + #[error("b")] + #[app_error(kind = AppErrorKind::Service, no_source)] + B + } + }); + let impls = expand(&parsed).expect("expand"); + assert_eq!(impls.len(), 1); + let output = impls[0].to_string(); + assert!(!output.contains("with_source")); + assert!(!output.contains("Send")); + } + + #[test] + fn enum_mixed_no_source_is_rejected() { + let parsed = parse(parse_quote! { + enum Failure { + #[error("a")] + #[app_error(kind = AppErrorKind::Internal, no_source)] + A, + #[error("b")] + #[app_error(kind = AppErrorKind::Service)] + B + } + }); + let err = expand(&parsed).expect_err("mixed no_source must fail"); + assert!(err.to_string().contains("no_source")); + } +} diff --git a/masterror-derive/src/input/parse_attr.rs b/masterror-derive/src/input/parse_attr.rs index 6000ae1..31a934e 100644 --- a/masterror-derive/src/input/parse_attr.rs +++ b/masterror-derive/src/input/parse_attr.rs @@ -190,6 +190,7 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { let mut kind = None; let mut code = None; let mut expose_message = false; + let mut no_source = false; while !input.is_empty() { let ident: Ident = input.parse()?; let name = ident.to_string(); @@ -222,6 +223,18 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { expose_message = true; } } + "no_source" => { + if no_source { + return Err(Error::new(ident.span(), "duplicate no_source flag")); + } + if input.peek(Token![=]) { + input.parse::()?; + let value: LitBool = input.parse()?; + no_source = value.value; + } else { + no_source = true; + } + } other => { return Err(Error::new( ident.span(), @@ -251,6 +264,7 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { kind, code, expose_message, + no_source, attribute_span: attr.span() }) }) @@ -810,6 +824,45 @@ mod tests { assert!(!result.unwrap().expose_message); } + #[test] + fn parse_app_error_attribute_with_no_source_flag() { + let attr: Attribute = parse_quote! { #[app_error(kind = K, no_source)] }; + let result = parse_app_error_attribute(&attr); + assert!(result.is_ok()); + assert!(result.unwrap().no_source); + } + + #[test] + fn parse_app_error_attribute_with_no_source_true() { + let attr: Attribute = parse_quote! { #[app_error(kind = K, no_source = true)] }; + let result = parse_app_error_attribute(&attr); + assert!(result.is_ok()); + assert!(result.unwrap().no_source); + } + + #[test] + fn parse_app_error_attribute_with_no_source_false() { + let attr: Attribute = parse_quote! { #[app_error(kind = K, no_source = false)] }; + let result = parse_app_error_attribute(&attr); + assert!(result.is_ok()); + assert!(!result.unwrap().no_source); + } + + #[test] + fn parse_app_error_attribute_default_no_source() { + let attr: Attribute = parse_quote! { #[app_error(kind = K)] }; + let result = parse_app_error_attribute(&attr); + assert!(result.is_ok()); + assert!(!result.unwrap().no_source); + } + + #[test] + fn parse_app_error_attribute_duplicate_no_source() { + let attr: Attribute = parse_quote! { #[app_error(kind = K, no_source, no_source)] }; + let result = parse_app_error_attribute(&attr); + assert!(result.is_err()); + } + #[test] fn parse_app_error_attribute_duplicate_kind() { let attr: Attribute = parse_quote! { #[app_error(kind = A, kind = B)] }; diff --git a/masterror-derive/src/input/types.rs b/masterror-derive/src/input/types.rs index cd4c64a..637e476 100644 --- a/masterror-derive/src/input/types.rs +++ b/masterror-derive/src/input/types.rs @@ -61,12 +61,14 @@ pub struct VariantData { /// AppError attribute specification. /// -/// Configures error kind, code, and message exposure for app-level errors. +/// Configures error kind, code, message exposure and source attachment for +/// app-level errors. #[derive(Clone, Debug)] pub struct AppErrorSpec { pub kind: ExprPath, pub code: Option, pub expose_message: bool, + pub no_source: bool, pub attribute_span: Span } diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs index c9927fe..815bb2e 100644 --- a/masterror-derive/src/lib.rs +++ b/masterror-derive/src/lib.rs @@ -6,6 +6,13 @@ //! //! This crate is not intended to be used directly. Re-exported as //! `masterror::Error`. +//! +//! The `From for AppError` conversion generated by `#[app_error(...)]` +//! attaches the domain error as the source of the produced `AppError`, +//! keeping it downcastable and part of the error chain. Attaching requires +//! `T: Send + Sync + 'static`; use the `no_source` flag in +//! `#[app_error(...)]` to opt out and drop the domain error during +//! conversion instead. mod app_error_impl; mod display; diff --git a/src/app_error/core/error.rs b/src/app_error/core/error.rs index 871f563..2a7861f 100644 --- a/src/app_error/core/error.rs +++ b/src/app_error/core/error.rs @@ -112,6 +112,17 @@ impl CoreError for Error { .as_ref() .map(|source| source.as_dyn() as &(dyn CoreError + 'static)) } + + #[cfg(masterror_has_error_generic_member_access)] + fn provide<'a>(&'a self, request: &mut core::error::Request<'a>) { + #[cfg(feature = "backtrace")] + if let Some(backtrace) = self.backtrace.as_deref() { + request.provide_ref::(backtrace); + } + if let Some(source) = self.source.as_ref() { + crate::provide::ThiserrorProvide::thiserror_provide(source.as_dyn(), request); + } + } } /// Conventional result alias for application code. diff --git a/src/lib.rs b/src/lib.rs index 12f934c..caebf88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,8 +110,15 @@ //! } //! .into(); //! assert!(matches!(app.kind, AppErrorKind::BadRequest)); +//! assert!(app.downcast_ref::().is_some()); //! ``` //! +//! The generated `From` conversion attaches the domain error as the source of +//! the resulting [`AppError`], so it stays downcastable and visible in +//! `chain()`/`root_cause()`. This requires the domain error to be +//! `Send + Sync + 'static`; add the `no_source` flag to `#[app_error(...)]` +//! to opt out and drop the domain error during conversion instead. +//! //! Use `#[provide]` to forward typed telemetry that downstream consumers can //! extract from [`AppError`] via `std::error::Request`. //! @@ -400,6 +407,12 @@ pub use code::{AppCode, ParseAppCodeError}; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on this crate. /// +/// The `From for AppError` conversion generated by `#[app_error(...)]` +/// attaches the domain error as the source of the produced [`AppError`], +/// keeping it downcastable and part of the error chain. Attaching requires +/// `T: Send + Sync + 'static`; use the `no_source` flag to opt out and drop +/// the domain error during conversion instead. +/// /// # Examples /// /// ``` @@ -417,6 +430,7 @@ pub use kind::AppErrorKind; /// } /// .into(); /// assert!(matches!(app.kind, AppErrorKind::BadRequest)); +/// assert!(app.downcast_ref::().is_some()); /// /// let code: AppCode = MissingFlag { /// name: "other" diff --git a/tests/app_error_attr_source.rs b/tests/app_error_attr_source.rs new file mode 100644 index 0000000..81eaf68 --- /dev/null +++ b/tests/app_error_attr_source.rs @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +use std::io; + +use masterror::{AppCode, AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("bare failure")] +#[app_error(kind = AppErrorKind::Service)] +struct BareDomainError; + +#[derive(Debug, Error)] +#[error("missing flag: {name}")] +#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)] +struct MessageDomainError { + name: &'static str +} + +#[derive(Debug, Error)] +#[error("database connection failed")] +#[app_error(kind = AppErrorKind::Internal)] +struct ChainedDomainError { + #[source] + source: io::Error +} + +#[derive(Debug, Error)] +enum DomainEnumError { + #[error("missing resource {id}")] + #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)] + Missing { id: u64 }, + #[error("backend unavailable")] + #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] + Backend +} + +#[test] +fn struct_without_message_attaches_source() { + let app: AppError = BareDomainError.into(); + assert!(app.message.is_none()); + assert!(app.source_ref().is_some()); + assert!(app.is::()); + assert!(app.downcast_ref::().is_some()); +} + +#[test] +fn struct_with_message_keeps_message_and_source() { + let domain = MessageDomainError { + name: "feature" + }; + let rendered = domain.to_string(); + let app: AppError = domain.into(); + assert_eq!(app.message.as_deref(), Some(rendered.as_str())); + let source = app + .downcast_ref::() + .expect("domain source"); + assert_eq!(source.name, "feature"); +} + +#[test] +fn enum_variant_with_message_attaches_source() { + let app: AppError = DomainEnumError::Missing { + id: 7 + } + .into(); + assert!(matches!(app.kind, AppErrorKind::NotFound)); + assert_eq!(app.message.as_deref(), Some("missing resource 7")); + assert!(matches!( + app.downcast_ref::(), + Some(DomainEnumError::Missing { + id: 7 + }) + )); +} + +#[test] +fn enum_variant_without_message_attaches_source() { + let app: AppError = DomainEnumError::Backend.into(); + assert!(matches!(app.kind, AppErrorKind::Service)); + assert!(app.message.is_none()); + assert!(matches!( + app.downcast_ref::(), + Some(DomainEnumError::Backend) + )); +} + +#[test] +fn source_chain_reaches_innermost_error() { + let app: AppError = ChainedDomainError { + source: io::Error::other("disk offline") + } + .into(); + assert_eq!(app.chain().count(), 3); + assert_eq!(app.root_cause().to_string(), "disk offline"); +} + +#[test] +fn app_code_conversion_is_unchanged() { + let code: AppCode = MessageDomainError { + name: "other" + } + .into(); + assert_eq!(code, AppCode::BadRequest); + let code: AppCode = DomainEnumError::Backend.into(); + assert_eq!(code, AppCode::Service); +} + +#[test] +fn problem_json_does_not_serialize_source() { + use masterror::ProblemJson; + + let app: AppError = ChainedDomainError { + source: io::Error::other("disk offline") + } + .into(); + let problem = ProblemJson::from_app_error(app); + let payload = serde_json::to_string(&problem).expect("problem json"); + assert!(!payload.contains("disk offline")); + assert!(!payload.contains("database connection failed")); + assert!(!payload.contains("source")); +} diff --git a/tests/error_derive.rs b/tests/error_derive.rs index 8778dba..2a89815 100644 --- a/tests/error_derive.rs +++ b/tests/error_derive.rs @@ -202,6 +202,24 @@ enum EnumTelemetryError { Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot) } +#[cfg_attr(not(masterror_has_error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +#[error("converted telemetry {snapshot:?}")] +#[app_error(kind = masterror::AppErrorKind::Service)] +struct ConvertedTelemetryError { + #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)] + snapshot: TelemetrySnapshot +} + +#[cfg_attr(not(masterror_has_error_generic_member_access), allow(dead_code))] +#[derive(Debug, Error)] +#[error("converted backtrace")] +#[app_error(kind = masterror::AppErrorKind::Internal)] +struct ConvertedBacktraceError { + #[backtrace] + trace: std::backtrace::Backtrace +} + #[derive(Debug, Error)] #[error("{source:?}")] struct DelegatedBacktraceFromSource { @@ -542,6 +560,37 @@ fn enum_variants_provide_custom_telemetry() { assert_eq!(provided_owned, named_snapshot); } +#[cfg(masterror_has_error_generic_member_access)] +#[test] +fn app_error_conversion_forwards_providers() { + let snapshot = TelemetrySnapshot { + name: "conversion", + value: 3 + }; + let err = ConvertedTelemetryError { + snapshot: snapshot.clone() + }; + let app: masterror::AppError = err.into(); + let provided_ref = request_ref::(&app).expect("forwarded reference"); + assert_eq!(provided_ref, &snapshot); + let provided_value = request_value::(&app).expect("forwarded value"); + assert_eq!(provided_value, snapshot); +} + +#[cfg(masterror_has_error_generic_member_access)] +#[test] +fn app_error_conversion_forwards_source_backtrace() { + let err = ConvertedBacktraceError { + trace: std::backtrace::Backtrace::force_capture() + }; + let app: masterror::AppError = err.into(); + let source = app + .downcast_ref::() + .expect("attached source"); + let provided = request_ref::(&app).expect("forwarded backtrace"); + assert!(ptr::eq(&source.trace, provided)); +} + #[test] fn named_struct_display_and_source() { let err = NamedError { diff --git a/tests/ui/app_error/pass/enum.rs b/tests/ui/app_error/pass/enum.rs index 8268219..05c2b82 100644 --- a/tests/ui/app_error/pass/enum.rs +++ b/tests/ui/app_error/pass/enum.rs @@ -23,10 +23,12 @@ fn main() { let app_missing: AppError = missing.into(); assert!(matches!(app_missing.kind, AppErrorKind::NotFound)); assert_eq!(app_missing.message.as_deref(), Some("missing resource 7")); + assert!(app_missing.source_ref().is_some()); let backend = ApiError::Backend; let app_backend: AppError = backend.into(); assert!(matches!(app_backend.kind, AppErrorKind::Service)); assert!(app_backend.message.is_none()); + assert!(app_backend.source_ref().is_some()); let code: AppCode = ApiError::Backend.into(); assert_eq!(code, AppCode::Service); } diff --git a/tests/ui/app_error/pass/no_source.rs b/tests/ui/app_error/pass/no_source.rs new file mode 100644 index 0000000..b7b9e0e --- /dev/null +++ b/tests/ui/app_error/pass/no_source.rs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +use std::rc::Rc; + +use masterror::{AppError, AppErrorKind, Error}; + +#[derive(Debug, Error)] +#[error("non-send payload {payload}")] +#[app_error(kind = AppErrorKind::Internal, no_source)] +struct NonSendError { + payload: Rc, +} + +#[derive(Debug, Error)] +enum NonSendEnumError { + #[error("non-send variant {0}")] + #[app_error(kind = AppErrorKind::Service, no_source, message)] + Payload(Rc), +} + +fn main() { + let err = NonSendError { + payload: Rc::new("data".to_owned()), + }; + let app: AppError = err.into(); + assert!(matches!(app.kind, AppErrorKind::Internal)); + assert!(app.source_ref().is_none()); + + let app: AppError = NonSendEnumError::Payload(Rc::new(7)).into(); + assert!(matches!(app.kind, AppErrorKind::Service)); + assert_eq!(app.message.as_deref(), Some("non-send variant 7")); + assert!(app.source_ref().is_none()); +} diff --git a/tests/ui/app_error/pass/struct.rs b/tests/ui/app_error/pass/struct.rs index 4cb77bf..58c7042 100644 --- a/tests/ui/app_error/pass/struct.rs +++ b/tests/ui/app_error/pass/struct.rs @@ -16,6 +16,8 @@ fn main() { let app: AppError = err.into(); assert!(matches!(app.kind, AppErrorKind::BadRequest)); assert_eq!(app.message.as_deref(), Some("missing flag: feature")); + assert!(app.source_ref().is_some()); + assert!(app.downcast_ref::().is_some()); let code: AppCode = MissingFlag { name: "other" }.into(); assert_eq!(code, AppCode::BadRequest); } diff --git a/wiki/Derive-Macros-en.md b/wiki/Derive-Macros-en.md index 73dd201..3bf8cfd 100644 --- a/wiki/Derive-Macros-en.md +++ b/wiki/Derive-Macros-en.md @@ -122,7 +122,7 @@ enum EnumError { ## `#[app_error(...)]` — conversions into AppError -Records how the derived error translates into `AppError`/`AppCode`. Options: `kind` (required), `code` (optional), `message` (flag). +Records how the derived error translates into `AppError`/`AppCode`. Options: `kind` (required), `code` (optional), `message` (flag), `no_source` (flag). ```rust use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -144,6 +144,9 @@ assert_eq!(code, AppCode::BadRequest); - `kind = ...` selects the `AppErrorKind`; generates `From for AppError`. - `code = ...` additionally generates `From for AppCode`. - `message` forwards the `Display` output as the public message; omit it to keep the message internal. +- `no_source` skips source attachment and drops the domain error during conversion (for types that are not `Send + Sync + 'static`). + +The generated `From for AppError` attaches the original domain error as the source: it stays downcastable via `downcast_ref`, appears in `chain()`/`root_cause()`, and its `#[provide]` data is forwarded through the `AppError` on toolchains with `error_generic_member_access`. Source attachment requires `T: Send + Sync + 'static`. Enums choose a mapping per variant, and the derive still emits a single `From for AppError`. @@ -169,7 +172,7 @@ struct StructuredTelemetryError { } ``` -Consumers extract the snapshot with `std::error::request_ref::(&err)` on the domain error. +Consumers extract the snapshot with `std::error::request_ref::(&err)` on the domain error, or on the converted `AppError` — the conversion forwards providers through the attached source. ## `#[derive(Masterror)]` — end-to-end domain errors diff --git "a/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" "b/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" index ee9f478..26aac59 100644 --- "a/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" +++ "b/wiki/Derive-\320\274\320\260\320\272\321\200\320\276\321\201\321\213.md" @@ -122,7 +122,7 @@ enum EnumError { ## `#[app_error(...)]` — конверсии в AppError -Описывает, как производная ошибка транслируется в `AppError`/`AppCode`. Опции: `kind` (обязательная), `code` (опциональная), `message` (флаг). +Описывает, как производная ошибка транслируется в `AppError`/`AppCode`. Опции: `kind` (обязательная), `code` (опциональная), `message` (флаг), `no_source` (флаг). ```rust use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -144,6 +144,9 @@ assert_eq!(code, AppCode::BadRequest); - `kind = ...` выбирает `AppErrorKind`; генерирует `From for AppError`. - `code = ...` дополнительно генерирует `From for AppCode`. - `message` пробрасывает вывод `Display` как публичное сообщение; опустите его, чтобы сообщение осталось внутренним. +- `no_source` отключает привязку источника и отбрасывает доменную ошибку при конверсии (для типов, не являющихся `Send + Sync + 'static`). + +Сгенерированный `From for AppError` прикрепляет исходную доменную ошибку как source: она остаётся доступной через `downcast_ref`, видна в `chain()`/`root_cause()`, а данные `#[provide]` пробрасываются через `AppError` на тулчейнах с `error_generic_member_access`. Для привязки источника требуется `T: Send + Sync + 'static`. Enum выбирают отображение для каждого варианта, при этом derive всё равно генерирует единственную реализацию `From for AppError`. @@ -169,7 +172,7 @@ struct StructuredTelemetryError { } ``` -Потребители извлекают снимок вызовом `std::error::request_ref::(&err)` на доменной ошибке. +Потребители извлекают снимок вызовом `std::error::request_ref::(&err)` на доменной ошибке или на сконвертированном `AppError` — конверсия пробрасывает провайдеры через прикреплённый source. ## `#[derive(Masterror)]` — сквозные доменные ошибки diff --git "a/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" "b/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" index 070ef55..5b5f37a 100644 --- "a/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" +++ "b/wiki/Derive-\353\247\244\355\201\254\353\241\234.md" @@ -122,7 +122,7 @@ enum EnumError { ## `#[app_error(...)]` — AppError로의 변환 -파생된 오류가 `AppError`/`AppCode`로 어떻게 변환되는지 기록합니다. 옵션: `kind`(필수), `code`(선택), `message`(플래그). +파생된 오류가 `AppError`/`AppCode`로 어떻게 변환되는지 기록합니다. 옵션: `kind`(필수), `code`(선택), `message`(플래그), `no_source`(플래그). ```rust use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -144,6 +144,9 @@ assert_eq!(code, AppCode::BadRequest); - `kind = ...`는 `AppErrorKind`를 선택하며 `From for AppError`를 생성합니다. - `code = ...`는 추가로 `From for AppCode`를 생성합니다. - `message`는 `Display` 출력을 공개 메시지로 전달합니다. 메시지를 내부용으로 유지하려면 생략하세요. +- `no_source`는 소스 첨부를 생략하고 변환 시 도메인 오류를 폐기합니다(`Send + Sync + 'static`이 아닌 타입용). + +생성된 `From for AppError`는 원본 도메인 오류를 소스로 첨부합니다: `downcast_ref`로 다운캐스트할 수 있고 `chain()`/`root_cause()`에 나타나며, `error_generic_member_access`를 지원하는 툴체인에서는 `#[provide]` 데이터가 `AppError`를 통해 전달됩니다. 소스 첨부에는 `T: Send + Sync + 'static`이 필요합니다. 열거형은 변형별로 매핑을 선택하며, 파생은 여전히 단일 `From for AppError`를 발행합니다. @@ -169,7 +172,7 @@ struct StructuredTelemetryError { } ``` -소비자는 도메인 오류에 대해 `std::error::request_ref::(&err)`로 스냅샷을 추출합니다. +소비자는 도메인 오류 또는 변환된 `AppError`에 대해 `std::error::request_ref::(&err)`로 스냅샷을 추출합니다 — 변환은 첨부된 소스를 통해 프로바이더를 전달합니다. ## `#[derive(Masterror)]` — 엔드투엔드 도메인 오류