Skip to content
Merged

452 #461

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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
216 changes: 185 additions & 31 deletions masterror-derive/src/app_error_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -30,7 +30,7 @@
let mut impls = Vec::new();
if variants.iter().any(|variant| variant.app_error.is_some()) {
ensure_all_have_app_error(variants)?;
impls.push(enum_app_error_impl(input, variants));
impls.push(enum_app_error_impl(input, variants)?);
}
if variants.iter().any(|variant| {
variant
Expand Down Expand Up @@ -77,29 +77,84 @@
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<bool, Error> {
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<T> 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! {

Check warning on line 133 in masterror-derive/src/app_error_impl.rs

View check run for this annotation

Codecov / codecov/patch

masterror-derive/src/app_error_impl.rs#L133

Added line #L133 was not covered by tests
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 {
Expand All @@ -116,37 +171,42 @@
}
}

fn enum_app_error_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream {
fn enum_app_error_impl(
input: &ErrorInput,
variants: &[VariantData]
) -> Result<TokenStream, Error> {
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");
let kind = &spec.kind;
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 {
Expand All @@ -173,9 +233,9 @@
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(..) }
}
}

Expand All @@ -187,3 +247,97 @@
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"));
}
}
Loading
Loading