Skip to content
Open
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
48 changes: 37 additions & 11 deletions crates/aerro-macros/src/attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub enum FieldRole {
Plain,
Source,
From,
Forward,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -134,13 +135,16 @@ fn parse_variant_attr(attr: &Attribute) -> syn::Result<ParsedVariantAttr> {
.ok_or_else(|| meta.error("expected an identifier"))?
.to_string();
match key.as_str() {
"category" => {
let value: Ident = meta.value()?.parse()?;
out.category = Some(value);
}
"code" => {
let value: Ident = meta.value()?.parse()?;
out.code = Some(value);
let path: syn::Path = meta.value()?.parse()?;
let segs: Vec<_> = path.segments.iter().collect();
if segs.len() != 2 {
return Err(meta.error(
"`code` must be a two-segment path, e.g. `Business::NotFound` or `System::Internal`",
));
}
out.category = Some(segs[0].ident.clone());
out.code = Some(segs[1].ident.clone());
}
"exposure" => {
let value: Ident = meta.value()?.parse()?;
Expand Down Expand Up @@ -173,13 +177,16 @@ fn collect_field_cfg(field: &Field) -> syn::Result<FieldCfg> {
} else if attr.path().is_ident("from") {
role = FieldRole::From;
} else if attr.path().is_ident("aerro") {
// field-level `#[aerro(redact)]`
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("redact") {
redact = true;
Ok(())
} else if meta.path.is_ident("forward") {
role = FieldRole::Forward;
Ok(())
} else {
Err(meta.error("field-level aerro attribute only supports `redact`"))
Err(meta
.error("field-level aerro attribute only supports `redact` and `forward`"))
}
})?;
}
Expand Down Expand Up @@ -275,7 +282,7 @@ pub fn parse_variant(v: &Variant) -> syn::Result<VariantCfg> {
return Err(syn::Error::new_spanned(
v,
format!(
"variant `{}` is missing `#[aerro(category = Business, code = AlreadyExists)]`",
"variant `{}` is missing `#[aerro(code = Business::AlreadyExists)]`",
v.ident
),
));
Expand All @@ -291,15 +298,15 @@ pub fn parse_variant(v: &Variant) -> syn::Result<VariantCfg> {
let category_ident = parsed.category.ok_or_else(|| {
syn::Error::new_spanned(
v,
"missing `category = Business` (one of: Business, System, Validation, Transport)",
"missing `code = Business::NotFound` (two-segment path: Category::GrpcCode)",
)
})?;
let category = CategoryAttr::from_ident(&category_ident)?;

let code_ident = parsed.code.ok_or_else(|| {
syn::Error::new_spanned(
v,
"missing `code = AlreadyExists` (PascalCase tonic::Code variant name)",
"missing `code = Business::NotFound` (two-segment path: Category::GrpcCode)",
)
})?;

Expand All @@ -324,6 +331,25 @@ pub fn parse_variant(v: &Variant) -> syn::Result<VariantCfg> {
.map(collect_field_cfg)
.collect::<syn::Result<Vec<_>>>()?;

let forward_count = fields
.iter()
.filter(|f| matches!(f.role, FieldRole::Forward))
.count();
if forward_count > 0 {
if fields.len() != 1 {
return Err(syn::Error::new_spanned(
v,
"`#[aerro(forward)]` variants must contain exactly one field",
));
}
if parsed.from_catchall {
return Err(syn::Error::new_spanned(
v,
"`#[aerro(forward)]` cannot be combined with `from`",
));
}
}

if let Some(ref fmt) = parsed.error {
validate_error_fmt(fmt, is_tuple, &fields, v.span())?;
}
Expand Down
12 changes: 7 additions & 5 deletions crates/aerro-macros/src/codegen/aerro_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,14 @@ fn decode_payload_arm(v: &VariantCfg, type_id: &str) -> TokenStream {
.iter()
.filter(|f| matches!(f.role, FieldRole::Plain))
.collect();
let has_source_or_from = v
.fields
.iter()
.any(|f| matches!(f.role, FieldRole::Source | FieldRole::From));
let has_opaque_field = v.fields.iter().any(|f| {
matches!(
f.role,
FieldRole::Source | FieldRole::From | FieldRole::Forward
)
});

if has_source_or_from {
if has_opaque_field {
// Cannot reconstruct anyhow/eyre error from wire — fall back to RemoteError.
return quote! {
#type_id => {
Expand Down
72 changes: 60 additions & 12 deletions crates/aerro-macros/src/codegen/thiserror_glue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ pub fn emit_display_and_error(cfg: &EnumCfg) -> TokenStream {
let source_arms = cfg.variants.iter().map(source_arm);

let from_impls = cfg.variants.iter().filter_map(|v| from_impl(enum_ident, v));
let forward_impls = cfg
.variants
.iter()
.filter_map(|v| forward_impl(enum_ident, v));

quote! {
impl ::core::fmt::Display for #enum_ident {
Expand All @@ -32,6 +36,7 @@ pub fn emit_display_and_error(cfg: &EnumCfg) -> TokenStream {
}

#(#from_impls)*
#(#forward_impls)*
}
}

Expand All @@ -52,25 +57,40 @@ fn display_arm(enum_ident: &Ident, v: &VariantCfg) -> TokenStream {
}

if v.is_tuple {
// Bind Plain fields for positional format args; use _ for Source/From/Forward
// so they don't become stray unused arguments in write!().
let pat_idents: Vec<Ident> = v
.fields
.iter()
.enumerate()
.map(|(i, _)| format_ident!("__f{}", i))
.map(|(i, f)| {
if matches!(f.role, FieldRole::Plain) {
format_ident!("__f{}", i)
} else {
format_ident!("_")
}
})
.collect();
let plain_idents: Vec<Ident> = v
.fields
.iter()
.enumerate()
.filter_map(|(i, f)| {
if matches!(f.role, FieldRole::Plain) {
Some(format_ident!("__f{}", i))
} else {
None
}
})
.collect();
let pat = quote! { ( #(#pat_idents),* ) };
if has_explicit_fmt {
quote! {
Self::#variant #pat => ::core::write!(__f, #fmt_string, #(#pat_idents),*),
Self::#variant #pat => ::core::write!(__f, #fmt_string, #(#plain_idents),*),
}
} else {
// Default snake_case name — fields go unused.
let _suppress = pat_idents.iter().map(|i| quote! { let _ = #i; });
quote! {
Self::#variant #pat => {
#(#_suppress)*
::core::write!(__f, #fmt_string)
}
Self::#variant #pat => ::core::write!(__f, #fmt_string),
}
}
} else {
Expand All @@ -95,10 +115,12 @@ fn source_arm(v: &VariantCfg) -> TokenStream {
return quote! { Self::#variant => ::core::option::Option::None, };
}

let src_idx = v
.fields
.iter()
.position(|f| matches!(f.role, FieldRole::Source | FieldRole::From));
let src_idx = v.fields.iter().position(|f| {
matches!(
f.role,
FieldRole::Source | FieldRole::From | FieldRole::Forward
)
});

if let Some(idx) = src_idx {
if v.is_tuple {
Expand Down Expand Up @@ -170,6 +192,32 @@ fn from_impl(enum_ident: &Ident, v: &VariantCfg) -> Option<TokenStream> {
})
}

fn forward_impl(enum_ident: &Ident, v: &VariantCfg) -> Option<TokenStream> {
let forward_field = v.fields.iter().find(|f| f.role == FieldRole::Forward)?;
let variant = &v.ident;
let ty = &forward_field.ty;

let ctor = if v.is_tuple {
quote! { #enum_ident::#variant(__inner) }
} else if let Some(name) = &forward_field.ident {
quote! { #enum_ident::#variant { #name: __inner } }
} else {
return None;
};

// Implement the local trait on the local enum type — avoids the orphan rule
// that prevents `impl From<ServiceFailure<T>> for ServiceFailure<Outer>` in
// downstream crates. Use `sf.forward::<Outer>()` to perform the conversion.
Some(quote! {
impl ::aerro::FromServiceFailure<#ty> for #enum_ident {
fn from_failure(__sf: ::aerro::ServiceFailure<#ty>) -> ::aerro::ServiceFailure<Self> {
let (__inner, __frames, __trace) = __sf.into_parts();
::aerro::ServiceFailure::from_parts(#ctor, __frames, __trace)
}
}
})
}

// silence unused imports if the file is parsed standalone
#[allow(dead_code)]
fn _span_anchor() -> Span {
Expand Down
7 changes: 7 additions & 0 deletions crates/aerro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
dhat = { workspace = true }
criterion = { workspace = true }
trybuild = { workspace = true }
opentelemetry_sdk = { version = "0.26", features = ["trace"] }
opentelemetry-stdout = { version = "0.26" }
tracing-subscriber = { version = "0.3", features = ["registry"] }

[[bench]]
name = "error_path"
Expand All @@ -53,3 +56,7 @@ required-features = ["macro"]
name = "exposure"
required-features = ["macro"]

[[example]]
name = "tracing"
required-features = ["macro", "tracing"]

10 changes: 5 additions & 5 deletions crates/aerro/benches/error_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ fn main() {
#[cfg(feature = "macro")]
use aerro::wire::encode::EncodeOptions;
#[cfg(feature = "macro")]
use aerro::{IntoStatus, StatusIntoResultExt};
use aerro::{AerroEncode, ServiceFailure};
#[cfg(feature = "macro")]
use criterion::{Criterion, black_box, criterion_group, criterion_main};

#[cfg(feature = "macro")]
#[derive(Debug, aerro::Aerro)]
pub enum Bench {
#[aerro(category = Business, code = AlreadyExists, error = "x={x} y={y}")]
#[aerro(code = Business::AlreadyExists, error = "x={x} y={y}")]
Item { x: u64, y: String },
}

Expand All @@ -32,7 +32,7 @@ fn bench_encode(c: &mut Criterion) {
x: 42,
y: "hello-world".into(),
};
black_box(v.into_status(black_box(&opts)));
black_box(v.encode_with_opts(black_box(&opts)));
});
});

Expand All @@ -47,7 +47,7 @@ fn bench_decode(c: &mut Criterion) {
x: 42,
y: "hello-world".into(),
}
.into_status(&opts);
.encode_with_opts(&opts);

group.bench_function("aerro_bincode", |b| {
b.iter(|| {
Expand All @@ -56,7 +56,7 @@ fn bench_decode(c: &mut Criterion) {
encoded_status.message(),
bytes::Bytes::copy_from_slice(encoded_status.details()),
);
black_box(st.into_aerro::<Bench>().unwrap());
black_box(ServiceFailure::<Bench>::try_from(st).unwrap());
});
});

Expand Down
31 changes: 16 additions & 15 deletions crates/aerro/examples/basic.rs
Original file line number Diff line number Diff line change
@@ -1,49 +1,50 @@
//! One enum, one round-trip across the wire — the simplest possible aerro usage.

use aerro::wire::encode::EncodeOptions;
use aerro::{Aerro, IntoStatus, StatusIntoResultExt};
use aerro::{Aerro, AerroEncode, ServiceFailure};

#[derive(Debug, aerro::Aerro)]
pub enum CreateUser {
pub enum CreateUserError {
#[aerro(
category = Business,
code = AlreadyExists,
code = Business::AlreadyExists,
error = "email already taken: {email}"
)]
EmailTaken { email: String },

#[aerro(category = System, code = Internal, error = "create_user.boom")]
#[aerro(code = System::Internal)]
Boom,
}

fn main() {
// Server side: a typed failure (explicit options).
let err = CreateUser::EmailTaken {
let err = CreateUserError::EmailTaken {
email: "alice@example.com".into(),
};
let status = err.into_status(&EncodeOptions::default());
let status = err.encode();
println!(
"server emitted: code={:?} message={:?}",
status.code(),
status.message()
);
println!("details() length: {} bytes", status.details().len());

// Server side: same thing with the convenience shorthand.
let err2 = CreateUser::Boom;
let status2 = err2.into_status_default();
// Server side: same thing with default options.
let err2 = CreateUserError::Boom;
let status2 = err2.encode();
println!(
"server emitted (default): code={:?} message={:?}",
status2.code(),
status2.message()
);

// Client side: recover the typed variant.
let recovered = status.into_aerro::<CreateUser>().unwrap();
let recovered = ServiceFailure::<CreateUserError>::try_from(status).unwrap();
match recovered.into_inner() {
CreateUser::EmailTaken { email } => println!("client recovered: email={email}"),
CreateUser::Boom => unreachable!(),
CreateUserError::EmailTaken { email } => println!("client recovered: email={email}"),
CreateUserError::Boom => unreachable!(),
}

println!("type_ids known to CreateUser: {:?}", CreateUser::TYPE_IDS);
println!(
"type_ids known to CreateUserError: {:?}",
CreateUserError::TYPE_IDS
);
}
11 changes: 5 additions & 6 deletions crates/aerro/examples/exposure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@
//! ships to its audience.

use aerro::wire::encode::EncodeOptions;
use aerro::{Exposure, IntoStatus};
use aerro::{AerroEncode, Exposure};

#[derive(Debug, aerro::Aerro)]
pub enum Db {
pub enum DbError {
#[aerro(
category = System,
code = Internal,
code = System::Internal,
error = "db.unreachable: {host}"
)]
Unreachable { host: String },
}

fn show(label: &str, exposure: Exposure) {
let err = Db::Unreachable {
let err = DbError::Unreachable {
host: "prod-shard-42.internal".into(),
};
let st = err.into_status(&EncodeOptions {
let st = err.encode_with_opts(&EncodeOptions {
exposure,
max_frames: 16,
});
Expand Down
Loading
Loading