From 1862fede9bf0637bb45049597ba10954ad70032b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 11 Jun 2026 00:13:18 -0500 Subject: [PATCH] refactor: harden distributed_macros expansion and diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the four proc-macros in distributed_macros/src/lib.rs (sourced, aggregate!, digest, enqueue) to the testable `expand_* -> syn::Result` shape already used by snapshot.rs and read_model.rs. The thin `#[proc_macro*]` entry points now convert errors via `.unwrap_or_else(|e| e.to_compile_error())`. Generated output is byte-identical — only the structure and error plumbing changed (verified: all 250+ macro-using tests in the main crate still pass). Diagnostics: unknown keyword args in parse_digest_args, parse_sourced_args, parse_event_args, and parse_enqueue_args now produce pointed, spanned syn::Error messages that name the bad key and list the valid ones, instead of being silently left unconsumed and surfacing as a bare "unexpected token". Also added up-front checks for duplicate #[event] names and #[event] methods missing a self receiver, and a clearer "missing entity field" message for bare #[sourced()]. enqueue fix: #[enqueue] now accepts `entity = field` so a renamed entity field produces a correct `is_replaying()` guard instead of a confusing "no field `entity`" error pointing at the user's method. ReplayError: kept as `String`. Replay errors are flattened from heterogeneous sources (per-event decode errors, user method errors of arbitrary E, unknown-event messages) via `e.to_string()`; a typed error would have to be generic over each method's error type or erase them anyway. Rationale documented inline. Tests: added unit tests for the new expand_*/parse_* functions and a trybuild compile-fail suite (tests/compile_fail/*.rs + harness) covering an unsupported #[event] signature, duplicate event names, unknown attribute keys, #[sourced] missing the entity field, and the renamed-entity #[enqueue] footgun. Implements [[tasks/macro-crate-hardening]] --- distributed_macros/Cargo.toml | 7 + distributed_macros/src/lib.rs | 547 +++++++++++++----- distributed_macros/tests/compile_fail.rs | 14 + .../compile_fail/duplicate_event_names.rs | 17 + .../compile_fail/duplicate_event_names.stderr | 5 + .../compile_fail/enqueue_renamed_entity.rs | 19 + .../enqueue_renamed_entity.stderr | 7 + .../tests/compile_fail/event_bad_signature.rs | 17 + .../compile_fail/event_bad_signature.stderr | 5 + .../compile_fail/sourced_missing_entity.rs | 16 + .../sourced_missing_entity.stderr | 7 + .../tests/compile_fail/unknown_digest_key.rs | 16 + .../compile_fail/unknown_digest_key.stderr | 5 + 13 files changed, 545 insertions(+), 137 deletions(-) create mode 100644 distributed_macros/tests/compile_fail.rs create mode 100644 distributed_macros/tests/compile_fail/duplicate_event_names.rs create mode 100644 distributed_macros/tests/compile_fail/duplicate_event_names.stderr create mode 100644 distributed_macros/tests/compile_fail/enqueue_renamed_entity.rs create mode 100644 distributed_macros/tests/compile_fail/enqueue_renamed_entity.stderr create mode 100644 distributed_macros/tests/compile_fail/event_bad_signature.rs create mode 100644 distributed_macros/tests/compile_fail/event_bad_signature.stderr create mode 100644 distributed_macros/tests/compile_fail/sourced_missing_entity.rs create mode 100644 distributed_macros/tests/compile_fail/sourced_missing_entity.stderr create mode 100644 distributed_macros/tests/compile_fail/unknown_digest_key.rs create mode 100644 distributed_macros/tests/compile_fail/unknown_digest_key.stderr diff --git a/distributed_macros/Cargo.toml b/distributed_macros/Cargo.toml index e39f141..1d2f1d5 100644 --- a/distributed_macros/Cargo.toml +++ b/distributed_macros/Cargo.toml @@ -13,3 +13,10 @@ proc-macro = true proc-macro2 = "1.0" quote = "1.0" syn = { version = "2.0", features = ["full"] } + +[dev-dependencies] +trybuild = "1.0" +# Dev-only cycle: `distributed` depends on this crate, but trybuild fixtures +# need the runtime crate to type-check generated `distributed::...` code. +# Cargo permits dev-dependency cycles. +distributed = { path = ".." } diff --git a/distributed_macros/src/lib.rs b/distributed_macros/src/lib.rs index 03a3f63..961f2df 100644 --- a/distributed_macros/src/lib.rs +++ b/distributed_macros/src/lib.rs @@ -2,11 +2,12 @@ mod read_model; mod snapshot; use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use syn::{ braced, - parse::{Parse, ParseStream}, - parse_macro_input, Expr, FnArg, Ident, ItemFn, ItemImpl, LitStr, Pat, ReturnType, Token, Type, + parse::{Parse, ParseStream, Parser}, + Expr, FnArg, Ident, ItemFn, ItemImpl, LitStr, Pat, ReturnType, Token, Type, }; // ============================================================================ @@ -330,37 +331,37 @@ fn generate_upcaster_tokens( /// } /// ``` /// +/// With a renamed entity field (used for the replay guard): +/// ```ignore +/// #[enqueue("order.initialized", entity = state)] +/// fn create(&mut self, id: String) { +/// // uses self.state.is_replaying() instead of self.entity.is_replaying() +/// } +/// ``` +/// /// The macro supports: /// - Default emitter field name: `emitter` (can be overridden by specifying field name first) /// - `when = condition`: guard that wraps the entire method body +/// - `entity = field`: entity field used for the `is_replaying()` guard (default: `entity`) /// - Methods may omit the return type; the macro expands them to `distributed::SourcedResult<()>` #[proc_macro_attribute] pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr with parse_enqueue_args); - let mut func = parse_macro_input!(item as ItemFn); + expand_enqueue(attr.into(), item.into()) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} - let signature_synthesized = match ensure_sourced_result_signature(&mut func.sig, "enqueue") { - Ok(signature_synthesized) => signature_synthesized, - Err(err) => return err.to_compile_error().into(), - }; +fn expand_enqueue(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let args = parse_enqueue_args.parse2(attr)?; + let mut func = syn::parse2::(item)?; + + let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "enqueue")?; let emitter_field = &args.emitter_field; let event_name = &args.event_name; // Use function parameters - serialize as tuple to JSON - let param_names: Vec<_> = func - .sig - .inputs - .iter() - .filter_map(|arg| { - if let FnArg::Typed(pat_type) = arg { - if let Pat::Ident(pat_ident) = &*pat_type.pat { - return Some(&pat_ident.ident); - } - } - None - }) - .collect(); + let param_names = extract_param_names(&func.sig); let entity_field = &args.entity_field; @@ -395,7 +396,7 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { ); *func.block = new_body; - TokenStream::from(quote! { #func }) + Ok(quote! { #func }) } struct EnqueueArgs { @@ -420,24 +421,37 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result()?; - - if input.peek(syn::Ident) { - let ident: syn::Ident = input.fork().parse()?; - if ident == "when" { - input.parse::()?; // consume "when" - input.parse::()?; - guard = Some(input.parse()?); - } + // Allow (and ignore) a trailing comma. + if input.is_empty() { + break; + } + let ident: syn::Ident = input.parse()?; + if ident == "when" { + input.parse::()?; + guard = Some(input.parse()?); + } else if ident == "entity" { + input.parse::()?; + entity_field = input.parse()?; + } else { + return Err(syn::Error::new_spanned( + &ident, + format!( + "unsupported key `{ident}` in #[enqueue(...)]; expected `when` or `entity`" + ), + )); } } Ok(EnqueueArgs { emitter_field, - entity_field: format_ident!("entity"), + entity_field, event_name, guard, }) @@ -504,13 +518,16 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result TokenStream { - let args = parse_macro_input!(attr with parse_digest_args); - let mut func = parse_macro_input!(item as ItemFn); + expand_digest(attr.into(), item.into()) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} - let signature_synthesized = match ensure_sourced_result_signature(&mut func.sig, "digest") { - Ok(signature_synthesized) => signature_synthesized, - Err(err) => return err.to_compile_error().into(), - }; +fn expand_digest(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let args = parse_digest_args.parse2(attr)?; + let mut func = syn::parse2::(item)?; + + let signature_synthesized = ensure_sourced_result_signature(&mut func.sig, "digest")?; let param_names = extract_param_names(&func.sig); let digest_call = generate_digest_call( @@ -528,7 +545,7 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { ); *func.block = new_body; - TokenStream::from(quote! { #func }) + Ok(quote! { #func }) } struct DigestArgs { @@ -558,18 +575,24 @@ fn parse_digest_args(input: syn::parse::ParseStream) -> syn::Result // Parse optional keyword arguments: `when = condition`, `version = N` while input.peek(Token![,]) { input.parse::()?; - - if input.peek(syn::Ident) { - let ident: syn::Ident = input.fork().parse()?; - if ident == "when" { - input.parse::()?; // consume "when" - input.parse::()?; - guard = Some(input.parse()?); - } else if ident == "version" { - input.parse::()?; // consume "version" - input.parse::()?; - version = Some(input.parse()?); - } + // Allow (and ignore) a trailing comma. + if input.is_empty() { + break; + } + let ident: syn::Ident = input.parse()?; + if ident == "when" { + input.parse::()?; + guard = Some(input.parse()?); + } else if ident == "version" { + input.parse::()?; + version = Some(input.parse()?); + } else { + return Err(syn::Error::new_spanned( + &ident, + format!( + "unsupported key `{ident}` in #[digest(...)]; expected `when` or `version`" + ), + )); } } @@ -603,7 +626,13 @@ fn parse_digest_args(input: syn::parse::ParseStream) -> syn::Result /// Use `=> method` (no parens) to pass all event args to the method. #[proc_macro] pub fn aggregate(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as AggregateInput); + expand_aggregate(input.into()) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +fn expand_aggregate(input: TokenStream2) -> syn::Result { + let input = syn::parse2::(input)?; let agg_name = &input.agg_name; let entity_field = &input.entity_field; @@ -665,6 +694,11 @@ pub fn aggregate(input: TokenStream) -> TokenStream { } }); + // ReplayError stays `String`: replay errors are flattened from + // heterogeneous sources (per-event `decode()` errors, user method errors of + // arbitrary `E`, and unknown-event messages) via `e.to_string()`. A typed + // error would have to be generic over each method's error type or erase + // them anyway, so `String` is the smaller, honest representation here. let expanded = quote! { #upcaster_wrappers @@ -696,7 +730,7 @@ pub fn aggregate(input: TokenStream) -> TokenStream { } }; - TokenStream::from(expanded) + Ok(expanded) } struct UpcasterDef { @@ -867,6 +901,11 @@ struct SourcedArgs { } fn parse_sourced_args(input: ParseStream) -> syn::Result { + if input.is_empty() { + return Err( + input.error("#[sourced] requires the entity field name, e.g. `#[sourced(entity)]`") + ); + } let entity_field: Ident = input.parse()?; let mut enum_name = None; let mut aggregate_type = None; @@ -875,59 +914,64 @@ fn parse_sourced_args(input: ParseStream) -> syn::Result { while input.peek(Token![,]) { input.parse::()?; - if input.peek(Ident) { - let kw: Ident = input.fork().parse()?; - if kw == "events" { - input.parse::()?; - input.parse::()?; - enum_name = Some(input.parse::()?); - } else if kw == "aggregate_type" { - input.parse::()?; - input.parse::()?; - let lit = input.parse::()?; - validate_aggregate_type_literal(&lit)?; - aggregate_type = Some(lit); - } else if kw == "enqueue" { - input.parse::()?; - // Optional custom emitter field: enqueue(my_emitter) - if input.peek(syn::token::Paren) { - let inner; - syn::parenthesized!(inner in input); - enqueue = Some(inner.parse::()?); - } else { - enqueue = Some(format_ident!("emitter")); - } - } else if kw == "upcasters" { - input.parse::()?; - let upcaster_content; - syn::parenthesized!(upcaster_content in input); - while !upcaster_content.is_empty() { - let inner; - syn::parenthesized!(inner in upcaster_content); - let ev_name: LitStr = inner.parse()?; - inner.parse::()?; - let from_ver: syn::LitInt = inner.parse()?; - inner.parse::]>()?; - let to_ver: syn::LitInt = inner.parse()?; - inner.parse::()?; - let source_type: syn::Type = inner.parse()?; - inner.parse::]>()?; - let target_type: syn::Type = inner.parse()?; - inner.parse::()?; - let transform: syn::Path = inner.parse()?; - upcasters.push(UpcasterDef { - event_name: ev_name, - from_version: from_ver, - to_version: to_ver, - source_type, - target_type, - transform_fn: transform, - }); - if upcaster_content.peek(Token![,]) { - upcaster_content.parse::()?; - } + // Allow (and ignore) a trailing comma. + if input.is_empty() { + break; + } + let kw: Ident = input.parse()?; + if kw == "events" { + input.parse::()?; + enum_name = Some(input.parse::()?); + } else if kw == "aggregate_type" { + input.parse::()?; + let lit = input.parse::()?; + validate_aggregate_type_literal(&lit)?; + aggregate_type = Some(lit); + } else if kw == "enqueue" { + // Optional custom emitter field: enqueue(my_emitter) + if input.peek(syn::token::Paren) { + let inner; + syn::parenthesized!(inner in input); + enqueue = Some(inner.parse::()?); + } else { + enqueue = Some(format_ident!("emitter")); + } + } else if kw == "upcasters" { + let upcaster_content; + syn::parenthesized!(upcaster_content in input); + while !upcaster_content.is_empty() { + let inner; + syn::parenthesized!(inner in upcaster_content); + let ev_name: LitStr = inner.parse()?; + inner.parse::()?; + let from_ver: syn::LitInt = inner.parse()?; + inner.parse::]>()?; + let to_ver: syn::LitInt = inner.parse()?; + inner.parse::()?; + let source_type: syn::Type = inner.parse()?; + inner.parse::]>()?; + let target_type: syn::Type = inner.parse()?; + inner.parse::()?; + let transform: syn::Path = inner.parse()?; + upcasters.push(UpcasterDef { + event_name: ev_name, + from_version: from_ver, + to_version: to_ver, + source_type, + target_type, + transform_fn: transform, + }); + if upcaster_content.peek(Token![,]) { + upcaster_content.parse::()?; } } + } else { + return Err(syn::Error::new_spanned( + &kw, + format!( + "unsupported key `{kw}` in #[sourced(...)]; expected `events`, `aggregate_type`, `enqueue`, or `upcasters`" + ), + )); } } @@ -953,17 +997,22 @@ fn parse_event_args(input: ParseStream) -> syn::Result { while input.peek(Token![,]) { input.parse::()?; - if input.peek(Ident) { - let ident: Ident = input.fork().parse()?; - if ident == "when" { - input.parse::()?; - input.parse::()?; - guard = Some(input.parse()?); - } else if ident == "version" { - input.parse::()?; - input.parse::()?; - version = Some(input.parse()?); - } + // Allow (and ignore) a trailing comma. + if input.is_empty() { + break; + } + let ident: Ident = input.parse()?; + if ident == "when" { + input.parse::()?; + guard = Some(input.parse()?); + } else if ident == "version" { + input.parse::()?; + version = Some(input.parse()?); + } else { + return Err(syn::Error::new_spanned( + &ident, + format!("unsupported key `{ident}` in #[event(...)]; expected `when` or `version`"), + )); } } @@ -1030,45 +1079,67 @@ struct EventMethodInfo { /// - `#[sourced(entity, upcasters(("initialized", 1 => 2, OldPayload => NewPayload, upcast_fn)))]` - upcasters #[proc_macro_attribute] pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr with parse_sourced_args); - let mut impl_block = parse_macro_input!(item as ItemImpl); + expand_sourced(attr.into(), item.into()) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +fn expand_sourced(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let args = parse_sourced_args.parse2(attr)?; + let mut impl_block = syn::parse2::(item)?; // Extract struct name from self type let struct_name = match &*impl_block.self_ty { - syn::Type::Path(type_path) => { - if let Some(segment) = type_path.path.segments.last() { - segment.ident.clone() - } else { - return syn::Error::new_spanned( + syn::Type::Path(type_path) => match type_path.path.segments.last() { + Some(segment) => segment.ident.clone(), + None => { + return Err(syn::Error::new_spanned( &impl_block.self_ty, "#[sourced] requires a named type", - ) - .to_compile_error() - .into(); + )); } - } + }, _ => { - return syn::Error::new_spanned( + return Err(syn::Error::new_spanned( &impl_block.self_ty, "#[sourced] requires a named type", - ) - .to_compile_error() - .into(); + )); } }; // Collect event info and modify methods let mut event_methods: Vec = Vec::new(); + // Detect duplicate event names so the conflict points at the offending + // attribute instead of surfacing as a confusing duplicate match arm later. + let mut seen_events: std::collections::HashMap = + std::collections::HashMap::new(); for item in &mut impl_block.items { if let syn::ImplItem::Fn(method) = item { match find_and_remove_event_attr(&mut method.attrs) { Ok(Some(event_attr)) => { + // Event methods are replayed as `self.method(...)`, so they + // must take a `self` receiver. Reject free associated + // functions up front with a pointed message. + if !matches!(method.sig.inputs.first(), Some(FnArg::Receiver(_))) { + return Err(syn::Error::new_spanned( + &method.sig, + "#[event] methods must take a `&mut self` receiver", + )); + } + + let event_key = event_attr.event_name.value(); + if let Some(_prev) = + seen_events.insert(event_key.clone(), event_attr.event_name.span()) + { + return Err(syn::Error::new_spanned( + &event_attr.event_name, + format!("duplicate #[event] name `{event_key}` in this #[sourced] impl block"), + )); + } + let signature_synthesized = - match ensure_sourced_result_signature(&mut method.sig, "event") { - Ok(signature_synthesized) => signature_synthesized, - Err(err) => return err.to_compile_error().into(), - }; + ensure_sourced_result_signature(&mut method.sig, "event")?; let params = extract_params_with_types(&method.sig); let param_name_refs: Vec<&Ident> = @@ -1109,7 +1180,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { }); } Ok(None) => { /* not an event method, skip */ } - Err(err) => return err.to_compile_error().into(), + Err(err) => return Err(err), } } } @@ -1240,6 +1311,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { } }); + // ReplayError stays `String` — see the rationale on `expand_aggregate`. let aggregate_impl = quote! { impl distributed::Aggregate for #struct_name { type ReplayError = String; @@ -1278,7 +1350,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { #aggregate_impl }; - TokenStream::from(expanded) + Ok(expanded) } // ============================================================================ @@ -1358,3 +1430,204 @@ pub fn derive_read_model(input: TokenStream) -> TokenStream { pub fn derive_snapshot(input: TokenStream) -> TokenStream { snapshot::derive_snapshot(input) } + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + use syn::parse::Parser; + + // ---- digest ---------------------------------------------------------- + + #[test] + fn expand_digest_inserts_digest_call() { + let attr = quote! { "initialized" }; + let item = quote! { + fn initialize(&mut self, id: String) { + self.id = id; + } + }; + let out = expand_digest(attr, item).unwrap().to_string(); + assert!(out.contains("digest"), "unexpected output: {out}"); + assert!(out.contains("\"initialized\""), "unexpected output: {out}"); + } + + #[test] + fn parse_digest_args_rejects_unknown_key() { + let attr = quote! { "x", versoin = 2 }; + let err = parse_digest_args + .parse2(attr) + .err() + .expect("unknown key should error"); + let msg = err.to_string(); + assert!(msg.contains("unsupported key `versoin`"), "got: {msg}"); + assert!(msg.contains("version"), "got: {msg}"); + } + + #[test] + fn parse_digest_args_accepts_version_and_when() { + let attr = quote! { entity, "renamed", when = true, version = 2 }; + let args = parse_digest_args.parse2(attr).unwrap(); + assert_eq!(args.entity_field, "entity"); + assert!(args.guard.is_some()); + assert!(args.version.is_some()); + } + + // ---- enqueue --------------------------------------------------------- + + #[test] + fn parse_enqueue_args_defaults_entity_field() { + let attr = quote! { "order.initialized" }; + let args = parse_enqueue_args.parse2(attr).unwrap(); + assert_eq!(args.emitter_field, "emitter"); + assert_eq!(args.entity_field, "entity"); + } + + #[test] + fn parse_enqueue_args_overrides_entity_field() { + let attr = quote! { "order.initialized", entity = state }; + let args = parse_enqueue_args.parse2(attr).unwrap(); + assert_eq!(args.entity_field, "state"); + } + + #[test] + fn expand_enqueue_uses_renamed_entity_field_in_guard() { + let attr = quote! { "order.initialized", entity = state }; + let item = quote! { + fn create(&mut self, id: String) { + self.id = id; + } + }; + let out = expand_enqueue(attr, item).unwrap().to_string(); + assert!( + out.contains("self . state . is_replaying"), + "expected renamed entity guard, got: {out}" + ); + } + + #[test] + fn parse_enqueue_args_rejects_unknown_key() { + let attr = quote! { "x", wen = true }; + let err = parse_enqueue_args + .parse2(attr) + .err() + .expect("unknown key should error"); + assert!( + err.to_string().contains("unsupported key `wen`"), + "got: {err}" + ); + } + + // ---- event (parse) --------------------------------------------------- + + #[test] + fn parse_event_args_rejects_unknown_key() { + let attr = quote! { "completed", wen = true }; + let err = parse_event_args + .parse2(attr) + .err() + .expect("unknown key should error"); + assert!( + err.to_string().contains("unsupported key `wen`"), + "got: {err}" + ); + } + + // ---- sourced --------------------------------------------------------- + + #[test] + fn parse_sourced_args_requires_entity_field() { + let attr = quote! {}; + let err = parse_sourced_args + .parse2(attr) + .err() + .expect("missing entity should error"); + assert!( + err.to_string().contains("requires the entity field name"), + "got: {err}" + ); + } + + #[test] + fn parse_sourced_args_rejects_unknown_key() { + let attr = quote! { entity, evnts = "Foo" }; + let err = parse_sourced_args + .parse2(attr) + .err() + .expect("unknown key should error"); + assert!( + err.to_string().contains("unsupported key `evnts`"), + "got: {err}" + ); + } + + #[test] + fn expand_sourced_generates_enum_and_aggregate() { + let attr = quote! { entity }; + let item = quote! { + impl Todo { + #[event("initialized")] + pub fn initialize(&mut self, id: String) { + self.id = id; + } + } + }; + let out = expand_sourced(attr, item).unwrap().to_string(); + assert!(out.contains("enum TodoEvent"), "got: {out}"); + assert!( + out.contains("impl distributed :: Aggregate for Todo"), + "got: {out}" + ); + } + + #[test] + fn expand_sourced_rejects_duplicate_event_names() { + let attr = quote! { entity }; + let item = quote! { + impl Todo { + #[event("done")] + pub fn complete(&mut self) {} + #[event("done")] + pub fn finish(&mut self) {} + } + }; + let err = expand_sourced(attr, item).expect_err("duplicate should error"); + assert!( + err.to_string().contains("duplicate #[event] name `done`"), + "got: {err}" + ); + } + + #[test] + fn expand_sourced_rejects_event_without_receiver() { + let attr = quote! { entity }; + let item = quote! { + impl Todo { + #[event("initialized")] + pub fn initialize(id: String) {} + } + }; + let err = expand_sourced(attr, item).expect_err("missing receiver should error"); + assert!( + err.to_string().contains("must take a `&mut self` receiver"), + "got: {err}" + ); + } + + // ---- aggregate ------------------------------------------------------- + + #[test] + fn expand_aggregate_generates_impl() { + let input = quote! { + Todo, entity { + "initialized"(id) => initialize, + } + }; + let out = expand_aggregate(input).unwrap().to_string(); + assert!( + out.contains("impl distributed :: Aggregate for Todo"), + "got: {out}" + ); + assert!(out.contains("replay_event"), "got: {out}"); + } +} diff --git a/distributed_macros/tests/compile_fail.rs b/distributed_macros/tests/compile_fail.rs new file mode 100644 index 0000000..ea5111b --- /dev/null +++ b/distributed_macros/tests/compile_fail.rs @@ -0,0 +1,14 @@ +//! Compile-fail suite for `distributed_macros`. +//! +//! Each fixture in `tests/compile_fail/` is expected to fail to compile with a +//! pointed, spanned diagnostic. The matching `.stderr` file pins the message so +//! regressions in diagnostic quality are caught. +//! +//! Regenerate `.stderr` snapshots with: +//! `TRYBUILD=overwrite cargo test -p distributed_macros` + +#[test] +fn compile_fail() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/distributed_macros/tests/compile_fail/duplicate_event_names.rs b/distributed_macros/tests/compile_fail/duplicate_event_names.rs new file mode 100644 index 0000000..7d5c97e --- /dev/null +++ b/distributed_macros/tests/compile_fail/duplicate_event_names.rs @@ -0,0 +1,17 @@ +use distributed::{sourced, Entity}; + +struct Todo { + entity: Entity, +} + +#[sourced(entity)] +impl Todo { + #[event("done")] + pub fn complete(&mut self) {} + + // Same event name as `complete` — ambiguous on replay, rejected up front. + #[event("done")] + pub fn finish(&mut self) {} +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/duplicate_event_names.stderr b/distributed_macros/tests/compile_fail/duplicate_event_names.stderr new file mode 100644 index 0000000..6397b5d --- /dev/null +++ b/distributed_macros/tests/compile_fail/duplicate_event_names.stderr @@ -0,0 +1,5 @@ +error: duplicate #[event] name `done` in this #[sourced] impl block + --> tests/compile_fail/duplicate_event_names.rs:13:13 + | +13 | #[event("done")] + | ^^^^^^ diff --git a/distributed_macros/tests/compile_fail/enqueue_renamed_entity.rs b/distributed_macros/tests/compile_fail/enqueue_renamed_entity.rs new file mode 100644 index 0000000..5cb7c4b --- /dev/null +++ b/distributed_macros/tests/compile_fail/enqueue_renamed_entity.rs @@ -0,0 +1,19 @@ +use distributed::emitter::EntityEmitter; +use distributed::{enqueue, Entity}; + +// The entity field is named `state`, not `entity`. Without telling `#[enqueue]` +// about the rename (via `entity = state`), the generated replay guard references +// the non-existent `self.entity` field. The fix is `#[enqueue("...", entity = state)]`. +struct Order { + state: Entity, + emitter: EntityEmitter, +} + +impl Order { + #[enqueue("order.initialized")] + pub fn create(&mut self, id: String) { + let _ = id; + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/enqueue_renamed_entity.stderr b/distributed_macros/tests/compile_fail/enqueue_renamed_entity.stderr new file mode 100644 index 0000000..ff20577 --- /dev/null +++ b/distributed_macros/tests/compile_fail/enqueue_renamed_entity.stderr @@ -0,0 +1,7 @@ +error[E0609]: no field `entity` on type `&mut Order` + --> tests/compile_fail/enqueue_renamed_entity.rs:13:5 + | +13 | #[enqueue("order.initialized")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unknown field + | + = note: this error originates in the attribute macro `enqueue` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/distributed_macros/tests/compile_fail/event_bad_signature.rs b/distributed_macros/tests/compile_fail/event_bad_signature.rs new file mode 100644 index 0000000..a8de8e3 --- /dev/null +++ b/distributed_macros/tests/compile_fail/event_bad_signature.rs @@ -0,0 +1,17 @@ +use distributed::{sourced, Entity}; + +struct Todo { + entity: Entity, +} + +#[sourced(entity)] +impl Todo { + // `#[event]` methods are replayed as `self.method(...)`, so they must take + // a `self` receiver. A free associated function is unsupported. + #[event("initialized")] + pub fn initialize(id: String) { + let _ = id; + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/event_bad_signature.stderr b/distributed_macros/tests/compile_fail/event_bad_signature.stderr new file mode 100644 index 0000000..31da3a7 --- /dev/null +++ b/distributed_macros/tests/compile_fail/event_bad_signature.stderr @@ -0,0 +1,5 @@ +error: #[event] methods must take a `&mut self` receiver + --> tests/compile_fail/event_bad_signature.rs:12:9 + | +12 | pub fn initialize(id: String) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/distributed_macros/tests/compile_fail/sourced_missing_entity.rs b/distributed_macros/tests/compile_fail/sourced_missing_entity.rs new file mode 100644 index 0000000..cf6cd0b --- /dev/null +++ b/distributed_macros/tests/compile_fail/sourced_missing_entity.rs @@ -0,0 +1,16 @@ +use distributed::{sourced, Entity}; + +struct Todo { + entity: Entity, +} + +// `#[sourced]` requires the entity field name as its first argument. +#[sourced()] +impl Todo { + #[event("initialized")] + pub fn initialize(&mut self, id: String) { + let _ = id; + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/sourced_missing_entity.stderr b/distributed_macros/tests/compile_fail/sourced_missing_entity.stderr new file mode 100644 index 0000000..6941765 --- /dev/null +++ b/distributed_macros/tests/compile_fail/sourced_missing_entity.stderr @@ -0,0 +1,7 @@ +error: unexpected end of input, #[sourced] requires the entity field name, e.g. `#[sourced(entity)]` + --> tests/compile_fail/sourced_missing_entity.rs:8:1 + | +8 | #[sourced()] + | ^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `sourced` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/distributed_macros/tests/compile_fail/unknown_digest_key.rs b/distributed_macros/tests/compile_fail/unknown_digest_key.rs new file mode 100644 index 0000000..d812f98 --- /dev/null +++ b/distributed_macros/tests/compile_fail/unknown_digest_key.rs @@ -0,0 +1,16 @@ +use distributed::{digest, Entity}; + +struct Todo { + entity: Entity, +} + +impl Todo { + // Typo: `versoin` is not a recognized key. The diagnostic should name the + // bad key and list the valid ones. + #[digest("x", versoin = 2)] + pub fn initialize(&mut self, id: String) { + let _ = id; + } +} + +fn main() {} diff --git a/distributed_macros/tests/compile_fail/unknown_digest_key.stderr b/distributed_macros/tests/compile_fail/unknown_digest_key.stderr new file mode 100644 index 0000000..f3ddfae --- /dev/null +++ b/distributed_macros/tests/compile_fail/unknown_digest_key.stderr @@ -0,0 +1,5 @@ +error: unsupported key `versoin` in #[digest(...)]; expected `when` or `version` + --> tests/compile_fail/unknown_digest_key.rs:10:19 + | +10 | #[digest("x", versoin = 2)] + | ^^^^^^^