diff --git a/.claude/skills/working-with-nix/SKILL.md b/.claude/skills/working-with-nix/SKILL.md index 8f0c2990d5..0c6cfa3610 100644 --- a/.claude/skills/working-with-nix/SKILL.md +++ b/.claude/skills/working-with-nix/SKILL.md @@ -27,7 +27,7 @@ echo $XMTP_DEV_SHELL # "local", "android", "ios", or unset ### Rust Version is Pinned — Do Not Change -**Rust 1.92.0** via `flake.nix` → `rust-manifest`. All shells use `xmtp.mkToolchain`. Never modify without project-wide coordination. +**Rust 1.95.0** via `flake.nix` → `rust-manifest` (`inputs.rust-manifest.url = ".../channel-rust-1.95.0.toml"`). All shells use `xmtp.mkToolchain`. Never modify without project-wide coordination. (`rust-toolchain.toml` says `channel = "stable"` for non-Nix tooling; `Cargo.toml`'s `rust-version = "1.94.0"` is the MSRV floor, not the pin.) ### iOS Shell is macOS Only diff --git a/Cargo.lock b/Cargo.lock index 03b9794a11..3ee62dbe8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11500,6 +11500,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -11513,6 +11519,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termize" version = "0.2.1" @@ -12325,6 +12340,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "tsify" version = "0.5.6" @@ -14726,6 +14756,7 @@ dependencies = [ "quote", "syn 2.0.117", "tokio", + "trybuild", "wasm-bindgen-test", "xmtp-workspace-hack", ] diff --git a/crates/xmtp_common/src/lib.rs b/crates/xmtp_common/src/lib.rs index cb96c2724d..00974a763b 100644 --- a/crates/xmtp_common/src/lib.rs +++ b/crates/xmtp_common/src/lib.rs @@ -10,6 +10,9 @@ pub use error_code::ErrorCode; #[doc(inline)] pub use xmtp_macro::ErrorCode; +#[doc(inline)] +pub use xmtp_macro::Retryable; + #[cfg(any(test, feature = "test-utils"))] mod test; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/xmtp_common/src/retry.rs b/crates/xmtp_common/src/retry.rs index 413ad8759a..ef1919ec6e 100644 --- a/crates/xmtp_common/src/retry.rs +++ b/crates/xmtp_common/src/retry.rs @@ -518,3 +518,279 @@ pub(crate) mod tests { assert!(backoff_retry.backoff(3, time_spent).unwrap().as_millis() - 450 <= 25); } } + +/// Behavior tests for the `#[derive(Retryable)]` proc macro. +/// +/// These exercise the generated `RetryableError` impl across every variant rule +/// in the design (default-false, `#[retry]`, `#[from]` forward, `#[retry(true| +/// false)]` override, `#[retry(when = ...)]`, the `#[retry(default = ...)]` +/// container baseline, named-field forward, and struct support). +#[cfg(test)] +mod derive_tests { + use super::RetryableError; + use thiserror::Error; + use xmtp_macro::Retryable; + + /// An inner error that is itself retryable, for testing forwarding. + #[derive(Debug, Error, Retryable)] + enum Inner { + #[error("retryable inner")] + #[retry] + Transient, + #[error("permanent inner")] + Permanent, + } + + #[test] + fn unannotated_variant_is_not_retryable() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("nope")] + Plain, + } + assert!(!E::Plain.is_retryable()); + } + + #[test] + fn retry_attribute_marks_variant_retryable() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("yes")] + #[retry] + Yes, + #[error("no")] + No, + } + assert!(E::Yes.is_retryable()); + assert!(!E::No.is_retryable()); + } + + #[test] + fn retry_true_and_false_are_explicit() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("t")] + #[retry(true)] + T, + #[error("f")] + #[retry(false)] + F, + } + assert!(E::T.is_retryable()); + assert!(!E::F.is_retryable()); + } + + #[test] + fn from_variant_without_attr_uses_baseline() { + // `#[from]` carries NO retry semantics: an unannotated wrapper variant + // takes the container baseline (false), even when the inner error is + // retryable. Forwarding is always explicit via `#[retry(inherit)]`. + #[derive(Debug, Error, Retryable)] + enum E { + #[error(transparent)] + Wrapped(#[from] Inner), + } + assert!(!E::Wrapped(Inner::Transient).is_retryable()); + assert!(!E::Wrapped(Inner::Permanent).is_retryable()); + } + + #[test] + fn from_variant_with_inherit_forwards() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error(transparent)] + #[retry(inherit)] + Wrapped(#[from] Inner), + } + assert!(E::Wrapped(Inner::Transient).is_retryable()); + assert!(!E::Wrapped(Inner::Permanent).is_retryable()); + } + + #[test] + fn retry_false_on_from_variant_is_false() { + // Explicit false on a foreign-wrapping #[from] variant. + #[derive(Debug, Error, Retryable)] + enum E { + #[error(transparent)] + #[retry(false)] + Parse(#[from] core::num::ParseIntError), + } + let err: E = "x".parse::().unwrap_err().into(); + assert!(!err.is_retryable()); + } + + #[test] + fn default_true_covers_from_variants() { + // Under `default = true`, a bare #[from] variant is `true` — it does + // NOT forward. This lets retryable-unless-listed enums stay clean. + #[derive(Debug, Error, Retryable)] + #[retry(default = true)] + enum E { + #[error(transparent)] + Wrapped(#[from] Inner), + } + // Inner::Permanent is non-retryable, but baseline-true wins: no forward. + assert!(E::Wrapped(Inner::Permanent).is_retryable()); + } + + #[test] + fn retry_inherit_forwards_without_from() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("inner: {0}")] + #[retry(inherit)] + Inner(Inner), + } + assert!(E::Inner(Inner::Transient).is_retryable()); + assert!(!E::Inner(Inner::Permanent).is_retryable()); + } + + #[test] + fn when_expression_on_tuple_binds_this() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("generic: {0}")] + #[retry(when = this.contains("database is locked"))] + Generic(String), + } + assert!(E::Generic("database is locked".to_string()).is_retryable()); + assert!(!E::Generic("syntax error".to_string()).is_retryable()); + } + + #[test] + fn when_expression_on_named_fields_binds_names() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("slow")] + #[retry(when = *latency > 500)] + Slow { latency: u64 }, + } + assert!(E::Slow { latency: 900 }.is_retryable()); + assert!(!E::Slow { latency: 100 }.is_retryable()); + } + + #[test] + fn container_default_true_flips_baseline() { + #[derive(Debug, Error, Retryable)] + #[retry(default = true)] + enum E { + #[error("a")] + A, + #[error("b")] + B, + #[error("c")] + #[retry(false)] + C, + } + assert!(E::A.is_retryable()); + assert!(E::B.is_retryable()); + assert!(!E::C.is_retryable()); + } + + #[test] + fn struct_error_uses_container_default() { + #[derive(Debug, Error, Retryable)] + #[error("retryable struct")] + #[retry(default = true)] + struct Retryful; + + #[derive(Debug, Error, Retryable)] + #[error("non-retryable struct")] + struct NotRetryful; + + assert!(Retryful.is_retryable()); + assert!(!NotRetryful.is_retryable()); + } + + #[test] + fn boxed_inner_forwards_through_blanket_impl() { + // Box gets the blanket Box impl, so + // the generated `inner.is_retryable()` resolves on it. (For + // `Box` resolution is via auto-deref instead — the + // blanket impl requires `E: Sized`.) + #[derive(Debug, Error, Retryable)] + enum E { + #[error("boxed")] + #[retry(inherit)] + Boxed(Box), + } + assert!(E::Boxed(Box::new(Inner::Transient)).is_retryable()); + assert!(!E::Boxed(Box::new(Inner::Permanent)).is_retryable()); + } + + #[test] + fn generic_enum_derives() { + // The impl must carry the type's generics: impl RetryableError for E. + #[derive(Debug, Error, Retryable)] + enum E { + #[error("wrapped")] + #[retry] + Wrapped(T), + #[error("plain")] + Plain, + } + assert!(E::Wrapped("payload".to_string()).is_retryable()); + assert!(!E::::Plain.is_retryable()); + } + + #[test] + fn when_on_named_fields_binds_only_referenced_fields() { + // `context` is not referenced by the expression; the generated arm must + // not bind it (an unused binding fails -Dwarnings builds). + #[derive(Debug, Error, Retryable)] + enum E { + #[error("slow")] + #[retry(when = *latency > 500)] + Slow { latency: u64, context: String }, + } + assert!( + E::Slow { + latency: 900, + context: "ctx".into() + } + .is_retryable() + ); + assert!( + !E::Slow { + latency: 100, + context: "ctx".into() + } + .is_retryable() + ); + } + + #[test] + fn when_on_multi_tuple_binds_positionally() { + // First field binds `this0`, second `this1`; only `this0` is referenced, + // so `this1`'s position must not produce an unused binding. + #[derive(Debug, Error, Retryable)] + enum E { + #[error("pair")] + #[retry(when = *this0 > 10)] + Pair(u32, String), + } + assert!(E::Pair(11, "x".into()).is_retryable()); + assert!(!E::Pair(9, "x".into()).is_retryable()); + } + + #[test] + fn when_on_unit_variant_is_allowed() { + #[derive(Debug, Error, Retryable)] + enum E { + #[error("unit")] + #[retry(when = 1 > 0)] + Unit, + } + assert!(E::Unit.is_retryable()); + } + + #[test] + fn empty_enum_derives() { + // Uninhabited error enums must still derive a valid impl. + #[derive(Debug, Error, Retryable)] + enum Never {} + + fn assert_impl() {} + assert_impl::(); + } +} diff --git a/crates/xmtp_macro/Cargo.toml b/crates/xmtp_macro/Cargo.toml index 49502de0dc..51bc2e0950 100644 --- a/crates/xmtp_macro/Cargo.toml +++ b/crates/xmtp_macro/Cargo.toml @@ -22,6 +22,7 @@ proc-macro = true [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread"] } +trybuild = "1.0" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test.workspace = true diff --git a/crates/xmtp_macro/src/lib.rs b/crates/xmtp_macro/src/lib.rs index 92abf7eba4..8adedb7a4d 100644 --- a/crates/xmtp_macro/src/lib.rs +++ b/crates/xmtp_macro/src/lib.rs @@ -7,6 +7,7 @@ mod error_code; mod log_macros; mod logging; mod parse_logs_macro; +mod retryable; mod span_macro; mod test_macro; mod timeout_macro; @@ -201,6 +202,83 @@ pub fn derive_error_code(input: proc_macro::TokenStream) -> proc_macro::TokenStr error_code::derive_error_code(input) } +/// Derive macro for the `xmtp_common::RetryableError` trait. +/// +/// Generates an `is_retryable(&self) -> bool` implementation. By default every +/// variant is **not** retryable; annotate the ones that are. `#[from]` carries +/// **no** retry semantics — forwarding to a wrapped error is always explicit +/// via `#[retry(inherit)]`. (A workspace census found ~3× more `#[from]` +/// variants needing a hardcoded value than forwarding ones, so auto-forwarding +/// produced noise, not savings.) +/// +/// # Example +/// +/// ```ignore +/// use thiserror::Error; +/// use xmtp_common::Retryable; +/// +/// #[derive(Debug, Error, Retryable)] +/// pub enum MyError { +/// // not retryable (the default) — including #[from] wrappers +/// #[error("bad input")] +/// BadInput, +/// #[error(transparent)] +/// Decode(#[from] prost::DecodeError), +/// +/// // always retryable +/// #[error("server busy")] +/// #[retry] +/// ServerBusy, +/// +/// // forwards to StorageError::is_retryable() — explicit +/// #[error(transparent)] +/// #[retry(inherit)] +/// Storage(#[from] StorageError), +/// +/// // finer-grained: inspect the payload +/// #[error("generic: {0}")] +/// #[retry(when = this.contains("database is locked"))] +/// Generic(String), +/// } +/// ``` +/// +/// # Attributes +/// +/// Container attribute (on the enum/struct): +/// +/// - `#[retry(default = true)]` / `#[retry(default = false)]` — set the baseline +/// retryability for any variant with no rule of its own. Omitted ⇒ `false`. +/// On an *enum* container, `default` is the only valid key (anything else is a +/// compile error). A struct container additionally accepts exactly one of +/// `true`/`false`/`when`. +/// +/// Generic types are supported — the impl carries the type's declared generics +/// and where-clause. Forwarding a generic field requires bounding it yourself +/// (e.g. `T: RetryableError`). +/// +/// Variant attributes (first match wins, top to bottom): +/// +/// - `#[retry(when = EXPR)]` — run an arbitrary `bool` expression with the +/// variant's fields in scope. A single tuple field binds to `this`; named +/// fields bind to their own names; multi-field tuples bind to `this0`, `this1`, +/// … Only fields the expression references are bound (the rest pattern as +/// `..`/`_`), so unused fields never trip `-D warnings`. Bindings are +/// **shared references** (`&Field`), so `Copy`/numeric comparisons need an +/// explicit deref (e.g. `*latency > 500`). `EXPR` must be total — a +/// `panic!`/`unwrap` here would crash the retry path. +/// - `#[retry(false)]` — never retryable (e.g. an exception under `default = true`). +/// - `#[retry(true)]` or bare `#[retry]` — always retryable. +/// - `#[retry(inherit)]` — forward to the single inner field's `is_retryable()`. +/// The only way a variant forwards; `#[from]` alone does nothing. +/// - Anything else — falls back to the container `default` baseline. +/// +/// On a struct, `#[derive(Retryable)]` returns the container baseline (or a +/// `#[retry(when = EXPR)]` expression evaluated against `self`). +#[proc_macro_derive(Retryable, attributes(retry))] +pub fn derive_retryable(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + retryable::derive_retryable(input) +} + /// Attribute macro that wraps an async test body with a WASM-compatible timeout. /// /// This is a drop-in replacement for rstest's `#[timeout]` that works on diff --git a/crates/xmtp_macro/src/retryable.rs b/crates/xmtp_macro/src/retryable.rs new file mode 100644 index 0000000000..5b0505473e --- /dev/null +++ b/crates/xmtp_macro/src/retryable.rs @@ -0,0 +1,375 @@ +use std::collections::HashSet; + +use proc_macro2::{Span, TokenStream as TokenStream2, TokenTree}; +use quote::{ToTokens, format_ident, quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{Data, DeriveInput, Expr, Fields, parse_macro_input}; + +/// How a single variant (or struct) decides its retryability. +enum Decision { + /// `#[retry(true)]` / bare `#[retry]` → always `true`. + AlwaysTrue, + /// `#[retry(false)]` → always `false`. + AlwaysFalse, + /// `#[retry(inherit)]` → forward to the inner field's `is_retryable()`. + Forward, + /// `#[retry(when = EXPR)]` → run `EXPR` with the variant's fields in scope. + /// Boxed because `syn::Expr` is large relative to the other (unit) variants. + When(Box), + /// No `#[retry(...)]` and no `#[from]` → fall back to the container baseline. + Default, +} + +/// Parsed `#[retry(...)]` attribute(s) on a variant or container. +#[derive(Default)] +struct RetryAttr { + always_true: bool, + always_false: bool, + inherit: bool, + when: Option, + /// Container-only: `#[retry(default = true|false)]`. + default: Option, + /// Span of the attribute, for error reporting. + span: Option, +} + +impl RetryAttr { + fn parse(attrs: &[syn::Attribute]) -> syn::Result { + let mut result = Self::default(); + + // Reject a key that is already set: without this, a duplicated or + // conflicting attribute (`#[retry(default = true)] #[retry(default = + // false)]`) would silently let the last one win and flip + // `is_retryable()` results with no diagnostic. + fn set(flag: &mut bool, span: Span, key: &str) -> syn::Result<()> { + if *flag { + return Err(syn::Error::new( + span, + format!("duplicate `#[retry(...)]` key `{key}`"), + )); + } + *flag = true; + Ok(()) + } + + for attr in attrs { + if !attr.path().is_ident("retry") { + continue; + } + result.span = Some(attr.span()); + + // Bare `#[retry]` with no arguments → always true. + if matches!(attr.meta, syn::Meta::Path(_)) { + set(&mut result.always_true, attr.span(), "true")?; + continue; + } + + // `#[retry()]` would otherwise sail through `parse_nested_meta` + // without invoking the callback and silently take the baseline. + if matches!(&attr.meta, syn::Meta::List(l) if l.tokens.is_empty()) { + return Err(syn::Error::new( + attr.span(), + "empty `#[retry()]`: use bare `#[retry]` for `true`, or specify a key \ + (`true`, `false`, `inherit`, `when = `, `default = `)", + )); + } + + attr.parse_nested_meta(|meta| { + let span = meta.path.span(); + if meta.path.is_ident("true") { + set(&mut result.always_true, span, "true") + } else if meta.path.is_ident("false") { + set(&mut result.always_false, span, "false") + } else if meta.path.is_ident("inherit") { + set(&mut result.inherit, span, "inherit") + } else if meta.path.is_ident("when") { + if result.when.is_some() { + return Err(syn::Error::new(span, "duplicate `#[retry(...)]` key `when`")); + } + let value = meta.value()?; + result.when = Some(value.parse()?); + Ok(()) + } else if meta.path.is_ident("default") { + if result.default.is_some() { + return Err(syn::Error::new( + span, + "duplicate `#[retry(...)]` key `default`", + )); + } + let value = meta.value()?; + let lit: syn::LitBool = value.parse()?; + result.default = Some(lit.value); + Ok(()) + } else { + Err(meta.error( + "expected `true`, `false`, `inherit`, `when = `, or `default = `", + )) + } + })?; + } + + Ok(result) + } + + fn span(&self) -> Span { + self.span.unwrap_or_else(Span::call_site) + } + + /// Validate this as the container attribute of an enum: only `default` is + /// meaningful there — the other keys act on variants. + fn validate_enum_container(&self) -> syn::Result<()> { + if self.always_true || self.always_false || self.inherit || self.when.is_some() { + return Err(syn::Error::new( + self.span(), + "only `#[retry(default = )]` is valid on an enum; \ + set per-variant behavior with `#[retry(...)]` on the variants", + )); + } + Ok(()) + } + + /// Validate this as the container attribute of a struct: `inherit` has no + /// meaning (there is no variant field selection), and the remaining keys + /// are mutually exclusive. + fn validate_struct_container(&self) -> syn::Result<()> { + if self.inherit { + return Err(syn::Error::new( + self.span(), + "`#[retry(inherit)]` is not valid on a struct; \ + use `#[retry(when = self..is_retryable())]` to forward", + )); + } + let set = [ + self.always_true, + self.always_false, + self.when.is_some(), + self.default.is_some(), + ] + .into_iter() + .filter(|b| *b) + .count(); + if set > 1 { + return Err(syn::Error::new( + self.span(), + "conflicting `#[retry(...)]` keys on a struct: use exactly one of \ + `true`, `false`, `when`, or `default`", + )); + } + Ok(()) + } + + /// Resolve the decision for a variant. + /// + /// `#[from]` deliberately carries no retry semantics: a workspace census + /// found 98 `#[from]` variants needing a hardcoded override vs 33 that + /// forward — auto-forwarding was the minority case and forced noisy + /// `#[retry(false)]` annotations on every foreign-wrapping variant. + /// Forwarding is always explicit via `#[retry(inherit)]`. + fn decision(self, fallback_span: Span) -> syn::Result { + let span = self.span.unwrap_or(fallback_span); + + if self.default.is_some() { + return Err(syn::Error::new( + span, + "`#[retry(default = ...)]` is only valid on the enum or struct itself, not on a variant", + )); + } + + // Count how many mutually-exclusive decision keys were set. + let set = [ + self.always_true, + self.always_false, + self.inherit, + self.when.is_some(), + ] + .into_iter() + .filter(|b| *b) + .count(); + if set > 1 { + return Err(syn::Error::new( + span, + "conflicting `#[retry(...)]` keys: use exactly one of `true`, `false`, `inherit`, or `when`", + )); + } + + Ok(if let Some(expr) = self.when { + Decision::When(Box::new(expr)) + } else if self.always_true { + Decision::AlwaysTrue + } else if self.always_false { + Decision::AlwaysFalse + } else if self.inherit { + Decision::Forward + } else { + Decision::Default + }) + } +} + +pub fn derive_retryable(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let container = match RetryAttr::parse(&input.attrs) { + Ok(c) => c, + Err(e) => return e.to_compile_error().into(), + }; + + let body = match &input.data { + Data::Enum(data_enum) => { + if let Err(e) = container.validate_enum_container() { + return e.to_compile_error().into(); + } + let baseline = container.default.unwrap_or(false); + + if data_enum.variants.is_empty() { + // `match self {}` is rejected for an uninhabited enum — a + // *reference* to it is still considered inhabited for + // exhaustiveness. Matching the dereferenced place is allowed. + quote! { match *self {} } + } else { + let arms: Result, syn::Error> = data_enum + .variants + .iter() + .map(|variant| variant_arm(variant, baseline)) + .collect(); + match arms { + Ok(arms) => quote! { + match self { + #(#arms)* + } + }, + Err(e) => return e.to_compile_error().into(), + } + } + } + Data::Struct(_) => { + if let Err(e) = container.validate_struct_container() { + return e.to_compile_error().into(); + } + struct_body(&container) + } + Data::Union(_) => { + return syn::Error::new_spanned(&input, "`Retryable` cannot be derived for unions") + .to_compile_error() + .into(); + } + }; + + quote! { + #[automatically_derived] + impl #impl_generics xmtp_common::RetryableError for #name #ty_generics #where_clause { + fn is_retryable(&self) -> bool { + #body + } + } + } + .into() +} + +/// Build the `is_retryable` body for a struct: either a `when` expression over +/// `self`, or a constant (`true`/`false`/bare `#[retry]`, else the `default` +/// baseline). Container validation has already rejected conflicting keys. +fn struct_body(container: &RetryAttr) -> TokenStream2 { + if let Some(expr) = &container.when { + return quote! { #expr }; + } + let value = container.always_true || container.default.unwrap_or(false); + quote! { #value } +} + +/// Build a single `match` arm for one enum variant. +fn variant_arm(variant: &syn::Variant, baseline: bool) -> syn::Result { + let vname = &variant.ident; + let attr = RetryAttr::parse(&variant.attrs)?; + let decision = attr.decision(variant.span())?; + + let arm = match decision { + Decision::AlwaysTrue => quote! { Self::#vname { .. } => true, }, + Decision::AlwaysFalse => quote! { Self::#vname { .. } => false, }, + Decision::Default => { + quote! { Self::#vname { .. } => #baseline, } + } + Decision::Forward => forward_arm(vname, &variant.fields)?, + Decision::When(expr) => when_arm(vname, &variant.fields, *expr), + }; + Ok(arm) +} + +/// `Self::V(inner) => inner.is_retryable()` — forward to the single inner field. +fn forward_arm(vname: &syn::Ident, fields: &Fields) -> syn::Result { + match fields { + Fields::Unnamed(f) if f.unnamed.len() == 1 => Ok(quote! { + Self::#vname(inner) => inner.is_retryable(), + }), + Fields::Named(f) if f.named.len() == 1 => { + let fname = f.named.first().unwrap().ident.as_ref().unwrap(); + Ok(quote! { + Self::#vname { #fname } => #fname.is_retryable(), + }) + } + _ => Err(syn::Error::new_spanned( + vname, + "`#[retry(inherit)]` requires exactly one field", + )), + } +} + +/// Collect every identifier appearing in a token stream, recursing into groups. +/// +/// Used to decide which variant fields a `when` expression references; an +/// over-approximation (e.g. an identifier inside a nested macro call) only +/// costs an extra binding, never a miss. +fn collect_idents(ts: TokenStream2, out: &mut HashSet) { + for tt in ts { + match tt { + TokenTree::Ident(i) => { + out.insert(i.to_string()); + } + TokenTree::Group(g) => collect_idents(g.stream(), out), + _ => {} + } + } +} + +/// `Self::V(this) => { EXPR }` — bind the referenced fields and run the custom +/// expression. Only fields the expression mentions are bound, so an unused +/// field never produces an `unused_variables` warning under `-D warnings`. +fn when_arm(vname: &syn::Ident, fields: &Fields, expr: Expr) -> TokenStream2 { + // Narrow the expression's span to the body block only, so pattern + // diagnostics keep their own spans. + let span = expr.span(); + let body = quote_spanned! {span=> { #expr } }; + + let mut referenced = HashSet::new(); + collect_idents(expr.to_token_stream(), &mut referenced); + + match fields { + Fields::Unit => quote! { Self::#vname => #body, }, + Fields::Unnamed(f) => { + let bindings = (0..f.unnamed.len()).map(|i| { + let name = if f.unnamed.len() == 1 { + format_ident!("this") + } else { + format_ident!("this{}", i) + }; + if referenced.contains(&name.to_string()) { + name.to_token_stream() + } else { + quote! { _ } + } + }); + quote! { Self::#vname(#(#bindings),*) => #body, } + } + Fields::Named(f) => { + let used: Vec<_> = f + .named + .iter() + .filter_map(|fld| fld.ident.as_ref()) + .filter(|id| referenced.contains(&id.to_string())) + .collect(); + quote! { Self::#vname { #(#used,)* .. } => #body, } + } + } +} diff --git a/crates/xmtp_macro/tests/retryable_ui.rs b/crates/xmtp_macro/tests/retryable_ui.rs new file mode 100644 index 0000000000..87c2322104 --- /dev/null +++ b/crates/xmtp_macro/tests/retryable_ui.rs @@ -0,0 +1,11 @@ +//! UI tests for the `#[derive(Retryable)]` macro's `compile_error!` paths. +//! +//! Each fixture in `tests/ui/` triggers exactly one rejection path; its +//! `.stderr` snapshot pins the spanned diagnostic. Regenerate snapshots after an +//! intentional message change with `TRYBUILD=overwrite cargo test -p xmtp_macro`. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn retryable_compile_fail() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/retryable/*.rs"); +} diff --git a/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.rs b/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.rs new file mode 100644 index 0000000000..4ddce3fb30 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.rs @@ -0,0 +1,9 @@ +use xmtp_macro::Retryable; + +#[derive(Retryable)] +enum E { + #[retry(true, false)] + Both, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.stderr b/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.stderr new file mode 100644 index 0000000000..66fbf816c7 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/conflicting_keys.stderr @@ -0,0 +1,5 @@ +error: conflicting `#[retry(...)]` keys: use exactly one of `true`, `false`, `inherit`, or `when` + --> tests/ui/retryable/conflicting_keys.rs:5:5 + | +5 | #[retry(true, false)] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.rs b/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.rs new file mode 100644 index 0000000000..1a0efb65cf --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.rs @@ -0,0 +1,11 @@ +use xmtp_macro::Retryable; + +// `inherit` forwards a variant's single field; it is meaningless on a struct +// container (use `when = self.field.is_retryable()` instead). +#[derive(Retryable)] +#[retry(inherit)] +struct S { + inner: u32, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.stderr b/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.stderr new file mode 100644 index 0000000000..1dc7114340 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_inherit_on_struct.stderr @@ -0,0 +1,5 @@ +error: `#[retry(inherit)]` is not valid on a struct; use `#[retry(when = self..is_retryable())]` to forward + --> tests/ui/retryable/container_inherit_on_struct.rs:6:1 + | +6 | #[retry(inherit)] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.rs b/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.rs new file mode 100644 index 0000000000..0b53a6cd5f --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.rs @@ -0,0 +1,11 @@ +use xmtp_macro::Retryable; + +// `true`/`false`/`inherit` are variant-level keys; on an enum container only +// `default = ` is meaningful. +#[derive(Retryable)] +#[retry(true)] +enum E { + A, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.stderr b/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.stderr new file mode 100644 index 0000000000..5e4c755a01 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_true_on_enum.stderr @@ -0,0 +1,5 @@ +error: only `#[retry(default = )]` is valid on an enum; set per-variant behavior with `#[retry(...)]` on the variants + --> tests/ui/retryable/container_true_on_enum.rs:6:1 + | +6 | #[retry(true)] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.rs b/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.rs new file mode 100644 index 0000000000..d60a3e6288 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.rs @@ -0,0 +1,10 @@ +use xmtp_macro::Retryable; + +// `when` on an enum container has no variant to bind; it is struct-only. +#[derive(Retryable)] +#[retry(when = 1 > 0)] +enum E { + A, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.stderr b/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.stderr new file mode 100644 index 0000000000..fc2330da5b --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/container_when_on_enum.stderr @@ -0,0 +1,5 @@ +error: only `#[retry(default = )]` is valid on an enum; set per-variant behavior with `#[retry(...)]` on the variants + --> tests/ui/retryable/container_when_on_enum.rs:5:1 + | +5 | #[retry(when = 1 > 0)] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/default_on_variant.rs b/crates/xmtp_macro/tests/ui/retryable/default_on_variant.rs new file mode 100644 index 0000000000..e9a6e1a456 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/default_on_variant.rs @@ -0,0 +1,9 @@ +use xmtp_macro::Retryable; + +#[derive(Retryable)] +enum E { + #[retry(default = true)] + Variant, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/default_on_variant.stderr b/crates/xmtp_macro/tests/ui/retryable/default_on_variant.stderr new file mode 100644 index 0000000000..2eb913380c --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/default_on_variant.stderr @@ -0,0 +1,5 @@ +error: `#[retry(default = ...)]` is only valid on the enum or struct itself, not on a variant + --> tests/ui/retryable/default_on_variant.rs:5:5 + | +5 | #[retry(default = true)] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.rs b/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.rs new file mode 100644 index 0000000000..8473996e54 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.rs @@ -0,0 +1,12 @@ +use xmtp_macro::Retryable; + +// Without duplicate detection the last attribute would silently win and flip +// every is_retryable() result. +#[derive(Retryable)] +#[retry(default = true)] +#[retry(default = false)] +enum E { + A, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.stderr b/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.stderr new file mode 100644 index 0000000000..0775906828 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/duplicate_conflicting_value.stderr @@ -0,0 +1,5 @@ +error: duplicate `#[retry(...)]` key `default` + --> tests/ui/retryable/duplicate_conflicting_value.rs:7:9 + | +7 | #[retry(default = false)] + | ^^^^^^^ diff --git a/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.rs b/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.rs new file mode 100644 index 0000000000..0f29355009 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.rs @@ -0,0 +1,12 @@ +use xmtp_macro::Retryable; + +// The same key twice (even with equal values) is rejected — a duplicate is +// always a copy-paste or merge artifact. +#[derive(Retryable)] +enum E { + #[retry(inherit)] + #[retry(inherit)] + Wrapped(u32), +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.stderr b/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.stderr new file mode 100644 index 0000000000..2daeaef131 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/duplicate_same_key.stderr @@ -0,0 +1,5 @@ +error: duplicate `#[retry(...)]` key `inherit` + --> tests/ui/retryable/duplicate_same_key.rs:8:13 + | +8 | #[retry(inherit)] + | ^^^^^^^ diff --git a/crates/xmtp_macro/tests/ui/retryable/empty_parens.rs b/crates/xmtp_macro/tests/ui/retryable/empty_parens.rs new file mode 100644 index 0000000000..da69a22afa --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/empty_parens.rs @@ -0,0 +1,11 @@ +use xmtp_macro::Retryable; + +// `#[retry()]` is malformed — without rejection it would silently fall back to +// the baseline instead of acting like bare `#[retry]`. +#[derive(Retryable)] +enum E { + #[retry()] + A, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/empty_parens.stderr b/crates/xmtp_macro/tests/ui/retryable/empty_parens.stderr new file mode 100644 index 0000000000..edc118240a --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/empty_parens.stderr @@ -0,0 +1,5 @@ +error: empty `#[retry()]`: use bare `#[retry]` for `true`, or specify a key (`true`, `false`, `inherit`, `when = `, `default = `) + --> tests/ui/retryable/empty_parens.rs:7:5 + | +7 | #[retry()] + | ^ diff --git a/crates/xmtp_macro/tests/ui/retryable/inherit_arity.rs b/crates/xmtp_macro/tests/ui/retryable/inherit_arity.rs new file mode 100644 index 0000000000..7d291b5907 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/inherit_arity.rs @@ -0,0 +1,10 @@ +use xmtp_macro::Retryable; + +#[derive(Retryable)] +enum E { + // inherit needs exactly one field; this has two + #[retry(inherit)] + TwoFields(u32, u32), +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/inherit_arity.stderr b/crates/xmtp_macro/tests/ui/retryable/inherit_arity.stderr new file mode 100644 index 0000000000..022f0c38f6 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/inherit_arity.stderr @@ -0,0 +1,5 @@ +error: `#[retry(inherit)]` requires exactly one field + --> tests/ui/retryable/inherit_arity.rs:7:5 + | +7 | TwoFields(u32, u32), + | ^^^^^^^^^ diff --git a/crates/xmtp_macro/tests/ui/retryable/on_union.rs b/crates/xmtp_macro/tests/ui/retryable/on_union.rs new file mode 100644 index 0000000000..3869a9336e --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/on_union.rs @@ -0,0 +1,9 @@ +use xmtp_macro::Retryable; + +#[derive(Retryable)] +union U { + a: u32, + b: f32, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/on_union.stderr b/crates/xmtp_macro/tests/ui/retryable/on_union.stderr new file mode 100644 index 0000000000..39ab47007a --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/on_union.stderr @@ -0,0 +1,8 @@ +error: `Retryable` cannot be derived for unions + --> tests/ui/retryable/on_union.rs:4:1 + | +4 | / union U { +5 | | a: u32, +6 | | b: f32, +7 | | } + | |_^ diff --git a/crates/xmtp_macro/tests/ui/retryable/unknown_key.rs b/crates/xmtp_macro/tests/ui/retryable/unknown_key.rs new file mode 100644 index 0000000000..40a24db06c --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/unknown_key.rs @@ -0,0 +1,9 @@ +use xmtp_macro::Retryable; + +#[derive(Retryable)] +enum E { + #[retry(sometimes)] + Bogus, +} + +fn main() {} diff --git a/crates/xmtp_macro/tests/ui/retryable/unknown_key.stderr b/crates/xmtp_macro/tests/ui/retryable/unknown_key.stderr new file mode 100644 index 0000000000..7723a86dd8 --- /dev/null +++ b/crates/xmtp_macro/tests/ui/retryable/unknown_key.stderr @@ -0,0 +1,5 @@ +error: expected `true`, `false`, `inherit`, `when = `, or `default = ` + --> tests/ui/retryable/unknown_key.rs:5:13 + | +5 | #[retry(sometimes)] + | ^^^^^^^^^ diff --git a/nix/lib/filesets.nix b/nix/lib/filesets.nix index a6b6c9f123..2d93977788 100644 --- a/nix/lib/filesets.nix +++ b/nix/lib/filesets.nix @@ -56,6 +56,9 @@ let (src + /crates/xmtp_id/artifact) (src + /crates/xmtp_id/src/scw_verifier/signature_validation.hex) (src + /crates/xmtp_db/migrations) + # trybuild .stderr snapshots — commonCargoSources only keeps .rs/Cargo.toml, + # so without this the UI tests regenerate-and-fail in the hermetic build + (src + /crates/xmtp_macro/tests/ui) (src + /crates/xmtp_proto/src/gen/proto_descriptor.bin) (src + /webdriver.json) (src + /apps/xnet/lib/signers.txt)