Skip to main content

arcly_http_macros/
lib.rs

1//! Procedural macros for arcly-http.
2//!
3//! Six attribute macros — each emits a single, deterministic compile-time
4//! registration. No runtime registry mutation; collection happens at link
5//! time via `inventory`.
6//!
7//! * `#[Injectable]` on a struct          → emits `ProviderDescriptor` + ctor
8//! * `#[Module(providers(…), controllers(…), imports(…))]` → emits `ModuleDescriptor`
9//! * `#[Controller("/prefix", tags("t"))]` on an `impl` block →
10//!   walks inner items, consumes `#[Get]/#[Post]/…/#[UseInterceptors]`
11//!   attributes on methods, emits one `RouteDescriptor` per method with the
12//!   controller prefix already concatenated
13//! * `#[Get/Post/Put/Delete/Patch("/path", …)]` on a free fn → standalone
14//!   route registration
15//! * `#[UseInterceptors(A, B)]` → wraps the handler thunk in the chain
16//! * `#[circuit_breaker(threshold = N, cooldown = "Ns")]` on an `async fn`
17//!   wraps its body in a per-method `static CircuitBreaker`
18
19use proc_macro::TokenStream;
20use proc_macro2::{Span, TokenStream as TokenStream2};
21use quote::{format_ident, quote};
22use syn::parse::{Parse, ParseStream};
23use syn::punctuated::Punctuated;
24use syn::spanned::Spanned;
25use syn::{
26    parse_macro_input, parse_quote, Attribute, Block, Expr, ExprLit, Fields, FnArg,
27    GenericArgument, Ident, ImplItem, ItemFn, ItemImpl, ItemStruct, Lit, LitInt, LitStr, Meta,
28    PatType, Path, PathArguments, ReturnType, Token, Type,
29};
30
31// ════════════════════════════════════════════════════════════════════════
32//  #[Injectable] — turns a struct into a wired DI provider
33// ════════════════════════════════════════════════════════════════════════
34#[proc_macro_attribute]
35#[allow(non_snake_case)]
36pub fn Injectable(_attr: TokenStream, item: TokenStream) -> TokenStream {
37    let st = parse_macro_input!(item as ItemStruct);
38    let name = st.ident.clone();
39    let name_str = name.to_string();
40
41    // Walk fields. For each `Inject<T>` field, record T as a dep and emit a
42    // builder line that pulls T from the resolver. For everything else,
43    // require `Default` (the most common case for atomics, counters, etc.).
44    let mut deps_tys: Vec<Type> = Vec::new();
45    let mut ctor_field_inits: Vec<TokenStream2> = Vec::new();
46
47    match &st.fields {
48        Fields::Named(named) => {
49            for f in &named.named {
50                let fname = f.ident.as_ref().unwrap();
51                if let Some(inner) = inject_inner_ty(&f.ty) {
52                    deps_tys.push(inner.clone());
53                    ctor_field_inits.push(quote! {
54                        #fname: ::arcly_http::__macro_support::Inject::__from_static(
55                            __r.get::<#inner>(),
56                        )
57                    });
58                } else {
59                    let fty = &f.ty;
60                    ctor_field_inits.push(quote! {
61                        #fname: <#fty as ::core::default::Default>::default()
62                    });
63                }
64            }
65        }
66        Fields::Unit => {
67            // Unit struct: nothing to construct.
68        }
69        Fields::Unnamed(_) => {
70            return syn::Error::new(
71                st.fields.span(),
72                "#[Injectable] does not yet support tuple structs — use a named-field struct",
73            )
74            .to_compile_error()
75            .into();
76        }
77    }
78
79    let deps_ty_paths: Vec<TokenStream2> = deps_tys.iter().map(|t| quote!(#t)).collect();
80    let desc_name = format_ident!("__ARCLY_PROVIDER_{}", name_str.to_uppercase());
81
82    let ctor_body = match &st.fields {
83        Fields::Named(_) => quote! { #name { #( #ctor_field_inits ),* } },
84        Fields::Unit => quote! { #name },
85        Fields::Unnamed(_) => unreachable!(),
86    };
87
88    quote! {
89        #st
90
91        impl #name {
92            #[doc(hidden)]
93            pub fn __arcly_build(__r: &::arcly_http::__macro_support::Resolver<'_>) -> Self {
94                #ctor_body
95            }
96        }
97
98        #[allow(non_upper_case_globals)]
99        static #desc_name: ::arcly_http::__macro_support::ProviderDescriptor =
100            ::arcly_http::__macro_support::ProviderDescriptor {
101                name: #name_str,
102                type_id_fn: || ::core::any::TypeId::of::<#name>(),
103                deps_fn: || ::std::vec![
104                    #( ::core::any::TypeId::of::<#deps_ty_paths>() ),*
105                ],
106                build: |__r| ::std::sync::Arc::new(#name::__arcly_build(__r)),
107            };
108
109        impl #name {
110            #[doc(hidden)]
111            pub const fn __arcly_descriptor() -> &'static ::arcly_http::__macro_support::ProviderDescriptor {
112                &#desc_name
113            }
114        }
115    }
116    .into()
117}
118
119fn inject_inner_ty(ty: &Type) -> Option<&Type> {
120    let Type::Path(tp) = ty else { return None };
121    let seg = tp.path.segments.last()?;
122    if seg.ident != "Inject" {
123        return None;
124    }
125    first_generic(&seg.arguments)
126}
127
128// ════════════════════════════════════════════════════════════════════════
129//  #[Module(providers(T,…), controllers(C,…), imports(M,…))]
130// ════════════════════════════════════════════════════════════════════════
131struct ModuleArgs {
132    providers: Vec<Path>,
133    controllers: Vec<Path>,
134    imports: Vec<Path>,
135    gateways: Vec<Path>,
136}
137
138impl Parse for ModuleArgs {
139    fn parse(input: ParseStream) -> syn::Result<Self> {
140        let mut out = ModuleArgs {
141            providers: vec![],
142            controllers: vec![],
143            imports: vec![],
144            gateways: vec![],
145        };
146        while !input.is_empty() {
147            let key: Ident = input.parse()?;
148            let content;
149            syn::parenthesized!(content in input);
150            let list: Punctuated<Path, Token![,]> =
151                content.parse_terminated(Path::parse, Token![,])?;
152            match key.to_string().as_str() {
153                "providers"   => out.providers.extend(list),
154                "controllers" => out.controllers.extend(list),
155                "imports"     => out.imports.extend(list),
156                "gateways"    => out.gateways.extend(list),
157                other => return Err(syn::Error::new(
158                    key.span(),
159                    format!("unknown Module key `{other}` (expected providers/controllers/imports/gateways)"),
160                )),
161            }
162            let _ = input.parse::<Token![,]>();
163        }
164        Ok(out)
165    }
166}
167
168#[proc_macro_attribute]
169#[allow(non_snake_case)]
170pub fn Module(attr: TokenStream, item: TokenStream) -> TokenStream {
171    let args = parse_macro_input!(attr as ModuleArgs);
172    let st = parse_macro_input!(item as ItemStruct);
173    let st_name = &st.ident;
174    let mod_name_str = st_name.to_string();
175
176    let provider_refs: Vec<TokenStream2> = args
177        .providers
178        .iter()
179        .map(|p| {
180            quote! { <#p>::__arcly_descriptor() }
181        })
182        .collect();
183
184    // controllers(...): turn each `UsersController` path into the literal
185    // string "UsersController" — Routes emitted by the Controller macro tag
186    // themselves with the same string, so the launch-time filter can match.
187    let controller_names: Vec<TokenStream2> = args
188        .controllers
189        .iter()
190        .map(|p| {
191            let n = p
192                .segments
193                .last()
194                .map(|s| s.ident.to_string())
195                .unwrap_or_default();
196            quote! { #n }
197        })
198        .collect();
199
200    // gateways(...): same treatment as controllers — store the type-name string
201    // so the launch-time reachability filter can match emitted GatewayDescriptors.
202    let gateway_names: Vec<TokenStream2> = args
203        .gateways
204        .iter()
205        .map(|p| {
206            let n = p
207                .segments
208                .last()
209                .map(|s| s.ident.to_string())
210                .unwrap_or_default();
211            quote! { #n }
212        })
213        .collect();
214
215    // imports(...): each sub-module exposes a const fn returning its
216    // ModuleDescriptor. Store function pointers (not direct references) so
217    // we don't tie ourselves to a single crate's static layout.
218    let import_fns: Vec<TokenStream2> = args
219        .imports
220        .iter()
221        .map(|p| {
222            quote! { (<#p as ::arcly_http::__macro_support::Module>::descriptor) }
223        })
224        .collect();
225
226    let static_name = format_ident!("__ARCLY_MODULE_{}", st_name.to_string().to_uppercase());
227
228    quote! {
229        #st
230
231        #[allow(non_upper_case_globals)]
232        static #static_name: ::arcly_http::__macro_support::ModuleDescriptor =
233            ::arcly_http::__macro_support::ModuleDescriptor {
234                name: #mod_name_str,
235                providers: &[ #( #provider_refs ),* ],
236                controllers: &[ #( #controller_names ),* ],
237                imports: &[ #( #import_fns ),* ],
238                gateways: &[ #( #gateway_names ),* ],
239            };
240
241        impl ::arcly_http::__macro_support::Module for #st_name {
242            fn descriptor() -> &'static ::arcly_http::__macro_support::ModuleDescriptor {
243                &#static_name
244            }
245        }
246
247        ::arcly_http::inventory::submit! {
248            &#static_name
249        }
250    }
251    .into()
252}
253
254// ════════════════════════════════════════════════════════════════════════
255//  Route attribute args — shared by #[Get/Post/…] free-fn and impl-block forms
256// ════════════════════════════════════════════════════════════════════════
257struct RouteArgs {
258    path: LitStr,
259    guards: Vec<Expr>,
260    tags: Vec<LitStr>,
261    security: Vec<LitStr>,
262    summary: Option<LitStr>,
263    description: Option<LitStr>,
264    operation_id: Option<LitStr>,
265    status: Option<LitInt>,
266    deprecated: bool,
267}
268
269impl Parse for RouteArgs {
270    fn parse(input: ParseStream) -> syn::Result<Self> {
271        let path: LitStr = input.parse()?;
272        let mut out = RouteArgs {
273            path,
274            guards: vec![],
275            tags: vec![],
276            security: vec![],
277            summary: None,
278            description: None,
279            operation_id: None,
280            status: None,
281            deprecated: false,
282        };
283        while input.peek(Token![,]) {
284            let _: Token![,] = input.parse()?;
285            if input.is_empty() {
286                break;
287            }
288            let key: Ident = input.parse()?;
289            let k = key.to_string();
290            if k == "deprecated" {
291                out.deprecated = true;
292                continue;
293            }
294            let content;
295            syn::parenthesized!(content in input);
296            match k.as_str() {
297                "guards" => {
298                    let list: Punctuated<Expr, Token![,]> = content.parse_terminated(Expr::parse, Token![,])?;
299                    out.guards.extend(list);
300                }
301                "tags" => {
302                    let list: Punctuated<LitStr, Token![,]> = content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
303                    out.tags.extend(list);
304                }
305                "security" => {
306                    let list: Punctuated<LitStr, Token![,]> = content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
307                    out.security.extend(list);
308                }
309                "summary"      => out.summary = Some(content.parse()?),
310                "description"  => out.description = Some(content.parse()?),
311                "operation_id" => out.operation_id = Some(content.parse()?),
312                "status"       => out.status = Some(content.parse()?),
313                other => return Err(syn::Error::new(
314                    key.span(),
315                    format!("unknown route key `{other}` (expected guards/tags/security/summary/description/operation_id/status/deprecated)"),
316                )),
317            }
318        }
319        Ok(out)
320    }
321}
322
323// ════════════════════════════════════════════════════════════════════════
324//  #[Controller("/prefix", tags(…))] — on `impl Block { … }`
325// ════════════════════════════════════════════════════════════════════════
326struct ControllerArgs {
327    prefix: LitStr,
328    tags: Vec<LitStr>,
329}
330impl Parse for ControllerArgs {
331    fn parse(input: ParseStream) -> syn::Result<Self> {
332        let prefix: LitStr = input.parse()?;
333        let mut tags: Vec<LitStr> = vec![];
334        if input.peek(Token![,]) {
335            let _: Token![,] = input.parse()?;
336            if !input.is_empty() {
337                let key: Ident = input.parse()?;
338                if key != "tags" {
339                    return Err(syn::Error::new(key.span(), "expected `tags(...)`"));
340                }
341                let content;
342                syn::parenthesized!(content in input);
343                let list: Punctuated<LitStr, Token![,]> =
344                    content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
345                tags.extend(list);
346            }
347        }
348        Ok(Self { prefix, tags })
349    }
350}
351
352#[proc_macro_attribute]
353#[allow(non_snake_case)]
354pub fn Controller(attr: TokenStream, item: TokenStream) -> TokenStream {
355    // The struct-form is still permitted as a marker (back-compat). Detect:
356    // if the item parses as an ItemImpl, walk its methods; otherwise pass
357    // through unchanged.
358    if let Ok(imp) = syn::parse::<ItemImpl>(item.clone()) {
359        return controller_on_impl(attr, imp);
360    }
361    item
362}
363
364fn controller_on_impl(attr: TokenStream, mut imp: ItemImpl) -> TokenStream {
365    let ControllerArgs {
366        prefix,
367        tags: ctrl_tags,
368    } = parse_macro_input!(attr as ControllerArgs);
369
370    // Controller-level #[Version("v1")] / #[Deprecated(sunset = "…")] —
371    // harvested from the impl block's attribute list and stripped.
372    let mut api_version = String::new();
373    let mut sunset = String::new();
374    {
375        let mut keep: Vec<Attribute> = Vec::with_capacity(imp.attrs.len());
376        for a in imp.attrs.drain(..) {
377            let id = a
378                .path()
379                .get_ident()
380                .map(|i| i.to_string())
381                .unwrap_or_default();
382            match id.as_str() {
383                "Version" => {
384                    if let Ok(v) = a.parse_args::<LitStr>() {
385                        api_version = v.value().trim_matches('/').to_owned();
386                    }
387                }
388                "Deprecated" => {
389                    let _ = a.parse_nested_meta(|meta| {
390                        if meta.path.is_ident("sunset") {
391                            let v: LitStr = meta.value()?.parse()?;
392                            sunset = v.value();
393                        }
394                        Ok(())
395                    });
396                }
397                _ => keep.push(a),
398            }
399        }
400        imp.attrs = keep;
401    }
402
403    // Mount under /{version}/… when versioned.
404    let raw_prefix = prefix.value();
405    let prefix_str = if api_version.is_empty() {
406        raw_prefix
407    } else {
408        format!("/{api_version}{raw_prefix}")
409    };
410    let self_ty = (*imp.self_ty).clone();
411
412    // Extract the controller's type name ("HealthController") for routing
413    // membership checks against the module DAG at launch time.
414    let controller_name = match &self_ty {
415        Type::Path(tp) => tp
416            .path
417            .segments
418            .last()
419            .map(|s| s.ident.to_string())
420            .unwrap_or_default(),
421        _ => String::new(),
422    };
423
424    let mut route_registrations: Vec<TokenStream2> = Vec::new();
425    let mut errors: Vec<syn::Error> = Vec::new();
426
427    for item in imp.items.iter_mut() {
428        let ImplItem::Fn(m) = item else { continue };
429
430        // Scan for one of our route-method markers + any companions.
431        let mut route_attr_idx: Option<usize> = None;
432        let mut route_method: Option<&'static str> = None;
433        let mut interceptor_attr_idxs: Vec<usize> = Vec::new();
434
435        for (i, a) in m.attrs.iter().enumerate() {
436            let ident = a
437                .path()
438                .get_ident()
439                .map(|i| i.to_string())
440                .unwrap_or_default();
441            match ident.as_str() {
442                "Get" | "Post" | "Put" | "Delete" | "Patch" => {
443                    route_attr_idx = Some(i);
444                    route_method = Some(match ident.as_str() {
445                        "Get" => "GET",
446                        "Post" => "POST",
447                        "Put" => "PUT",
448                        "Delete" => "DELETE",
449                        "Patch" => "PATCH",
450                        _ => unreachable!(),
451                    });
452                }
453                "UseInterceptors" => interceptor_attr_idxs.push(i),
454                _ => {}
455            }
456        }
457
458        let Some(idx) = route_attr_idx else { continue };
459        let route_method = route_method.unwrap();
460
461        // Parse the route attr's tokens.
462        let route_attr = m.attrs[idx].clone();
463        let route_args: RouteArgs = match route_attr.parse_args() {
464            Ok(a) => a,
465            Err(e) => {
466                errors.push(e);
467                continue;
468            }
469        };
470
471        // Parse interceptor attrs.
472        let mut interceptor_paths: Vec<Path> = Vec::new();
473        for i in &interceptor_attr_idxs {
474            let a = &m.attrs[*i];
475            match a.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated) {
476                Ok(list) => interceptor_paths.extend(list),
477                Err(e) => errors.push(e),
478            }
479        }
480
481        // Compose the full path: prefix + route's local segment.
482        let local_path = route_args.path.value();
483        let full = join_paths(&prefix_str, &local_path);
484
485        // Default tags: controller tags ∪ route tags.
486        let merged_tags: Vec<LitStr> = ctrl_tags
487            .iter()
488            .cloned()
489            .chain(route_args.tags.iter().cloned())
490            .collect();
491
492        // Strip the route + interceptor attrs first so harvest_cache_attrs
493        // can mutate without index drift.
494        let mut keep: Vec<Attribute> = Vec::with_capacity(m.attrs.len());
495        for (i, a) in m.attrs.iter().enumerate() {
496            if i == idx || interceptor_attr_idxs.contains(&i) {
497                continue;
498            }
499            keep.push(a.clone());
500        }
501        m.attrs = keep;
502
503        let (cache_ttl_secs, cache_key) = match harvest_cache_attrs(&mut m.attrs) {
504            Ok(p) => p,
505            Err(e) => {
506                errors.push(e);
507                continue;
508            }
509        };
510
511        let audit = match harvest_audit_attr(&mut m.attrs) {
512            Ok(a) => a,
513            Err(e) => {
514                errors.push(e);
515                continue;
516            }
517        };
518
519        let timeout_ms = match harvest_timeout_attr(&mut m.attrs) {
520            Ok(t) => t,
521            Err(e) => {
522                errors.push(e);
523                continue;
524            }
525        };
526
527        let transactional = harvest_transactional_attr(&mut m.attrs);
528
529        let idempotent_ttl = match harvest_idempotent_attr(&mut m.attrs) {
530            Ok(t) => t,
531            Err(e) => {
532                errors.push(e);
533                continue;
534            }
535        };
536
537        let policies = match harvest_policies_attr(&mut m.attrs) {
538            Ok(p) => p,
539            Err(e) => {
540                errors.push(e);
541                continue;
542            }
543        };
544
545        let mask_fields = match harvest_mask_attr(&mut m.attrs) {
546            Ok(f) => f,
547            Err(e) => {
548                errors.push(e);
549                continue;
550            }
551        };
552
553        // Build a free-standing thunk that calls `Self::method(...)`.
554        let reg = match build_method_route_registration(
555            &self_ty,
556            m,
557            route_method,
558            full,
559            &route_args,
560            &merged_tags,
561            &interceptor_paths,
562            cache_ttl_secs,
563            &cache_key,
564            &controller_name,
565            &audit,
566            timeout_ms,
567            &api_version,
568            &sunset,
569            transactional,
570            idempotent_ttl,
571            &policies,
572            &mask_fields,
573        ) {
574            Ok(ts) => ts,
575            Err(e) => {
576                errors.push(e);
577                continue;
578            }
579        };
580        route_registrations.push(reg);
581
582        // Strip our parameter marker attrs (Param/Query/Body/Header).
583        for input in m.sig.inputs.iter_mut() {
584            if let FnArg::Typed(pt) = input {
585                pt.attrs.retain(|a| {
586                    let id = a
587                        .path()
588                        .get_ident()
589                        .map(|i| i.to_string())
590                        .unwrap_or_default();
591                    !matches!(id.as_str(), "Param" | "Query" | "Body" | "Header")
592                });
593            }
594        }
595    }
596
597    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
598        a.combine(b);
599        a
600    }) {
601        return err.to_compile_error().into();
602    }
603
604    quote! {
605        #imp
606        #( #route_registrations )*
607    }
608    .into()
609}
610
611/// Harvest sibling `#[CacheTTL(N)]` / `#[CacheKey("…")]` attributes from a
612/// method's attribute list. Returns `(ttl_secs, key_template)` and consumes
613/// the matched attributes from `attrs`.
614fn harvest_cache_attrs(attrs: &mut Vec<Attribute>) -> syn::Result<(u64, String)> {
615    let mut ttl_secs: u64 = 0;
616    let mut key: String = String::new();
617    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
618    for a in attrs.drain(..) {
619        let id = a
620            .path()
621            .get_ident()
622            .map(|i| i.to_string())
623            .unwrap_or_default();
624        match id.as_str() {
625            "CacheTTL" => {
626                let n: LitInt = a.parse_args()?;
627                ttl_secs = n.base10_parse()?;
628            }
629            "CacheKey" => {
630                let s: LitStr = a.parse_args()?;
631                key = s.value();
632            }
633            _ => {
634                keep.push(a);
635            }
636        }
637    }
638    *attrs = keep;
639    Ok((ttl_secs, key))
640}
641
642/// Harvest a sibling `#[AuditLog(action = "…", resource = "…")]` attribute.
643/// Returns `(action, resource)` and consumes the attribute.
644fn harvest_audit_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<(String, String)>> {
645    let mut found: Option<(String, String)> = None;
646    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
647    for a in attrs.drain(..) {
648        let id = a
649            .path()
650            .get_ident()
651            .map(|i| i.to_string())
652            .unwrap_or_default();
653        if id == "AuditLog" {
654            let mut action = String::new();
655            let mut resource = String::new();
656            a.parse_nested_meta(|meta| {
657                if meta.path.is_ident("action") {
658                    let v: LitStr = meta.value()?.parse()?;
659                    action = v.value();
660                } else if meta.path.is_ident("resource") {
661                    let v: LitStr = meta.value()?.parse()?;
662                    resource = v.value();
663                } else {
664                    return Err(meta.error("expected `action = \"…\"` or `resource = \"…\"`"));
665                }
666                Ok(())
667            })?;
668            if action.is_empty() {
669                return Err(syn::Error::new_spanned(
670                    &a,
671                    "#[AuditLog] requires action = \"…\"",
672                ));
673            }
674            found = Some((action, resource));
675        } else {
676            keep.push(a);
677        }
678    }
679    *attrs = keep;
680    Ok(found)
681}
682
683/// Harvest a sibling `#[Timeout("2s")]` attribute → milliseconds.
684fn harvest_timeout_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<u64>> {
685    let mut found: Option<u64> = None;
686    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
687    for a in attrs.drain(..) {
688        let id = a
689            .path()
690            .get_ident()
691            .map(|i| i.to_string())
692            .unwrap_or_default();
693        if id == "Timeout" {
694            let lit: LitStr = a.parse_args()?;
695            let raw = lit.value();
696            let ms = parse_duration_str_ms(&raw).ok_or_else(|| {
697                syn::Error::new_spanned(&a, "expected a duration like \"250ms\", \"2s\", or \"1m\"")
698            })?;
699            found = Some(ms);
700        } else {
701            keep.push(a);
702        }
703    }
704    *attrs = keep;
705    Ok(found)
706}
707
708fn parse_duration_str_ms(s: &str) -> Option<u64> {
709    let s = s.trim();
710    if let Some(v) = s.strip_suffix("ms") {
711        return v.trim().parse().ok();
712    }
713    if let Some(v) = s.strip_suffix('s') {
714        return v.trim().parse::<u64>().ok().map(|n| n * 1_000);
715    }
716    if let Some(v) = s.strip_suffix('h') {
717        return v.trim().parse::<u64>().ok().map(|n| n * 3_600_000);
718    }
719    if let Some(v) = s.strip_suffix('m') {
720        return v.trim().parse::<u64>().ok().map(|n| n * 60_000);
721    }
722    s.parse().ok()
723}
724
725/// Harvest a sibling `#[Transactional]` marker attribute.
726fn harvest_transactional_attr(attrs: &mut Vec<Attribute>) -> bool {
727    let before = attrs.len();
728    attrs.retain(|a| {
729        a.path()
730            .get_ident()
731            .map(|i| i.to_string())
732            .unwrap_or_default()
733            != "Transactional"
734    });
735    attrs.len() != before
736}
737
738/// Harvest a sibling `#[Idempotent(ttl = "24h")]` attribute → ttl seconds.
739fn harvest_idempotent_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Option<u64>> {
740    let mut found: Option<u64> = None;
741    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
742    for a in attrs.drain(..) {
743        let id = a
744            .path()
745            .get_ident()
746            .map(|i| i.to_string())
747            .unwrap_or_default();
748        if id == "Idempotent" {
749            let mut ttl_secs: u64 = 24 * 3600;
750            a.parse_nested_meta(|meta| {
751                if meta.path.is_ident("ttl") {
752                    let v: LitStr = meta.value()?.parse()?;
753                    let ms = parse_duration_str_ms(&v.value())
754                        .ok_or_else(|| meta.error("expected a duration like \"30m\", \"24h\""))?;
755                    ttl_secs = (ms / 1000).max(1);
756                }
757                Ok(())
758            })?;
759            found = Some(ttl_secs);
760        } else {
761            keep.push(a);
762        }
763    }
764    *attrs = keep;
765    Ok(found)
766}
767
768/// Harvest a sibling `#[RequirePolicies("a", "b")]` attribute.
769fn harvest_policies_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Vec<LitStr>> {
770    let mut found: Vec<LitStr> = Vec::new();
771    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
772    for a in attrs.drain(..) {
773        let id = a
774            .path()
775            .get_ident()
776            .map(|i| i.to_string())
777            .unwrap_or_default();
778        if id == "RequirePolicies" {
779            let list = a.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated)?;
780            found.extend(list);
781        } else {
782            keep.push(a);
783        }
784    }
785    *attrs = keep;
786    Ok(found)
787}
788
789/// Harvest a sibling `#[MaskFields("a", "b:last4")]` attribute.
790fn harvest_mask_attr(attrs: &mut Vec<Attribute>) -> syn::Result<Vec<LitStr>> {
791    let mut found: Vec<LitStr> = Vec::new();
792    let mut keep: Vec<Attribute> = Vec::with_capacity(attrs.len());
793    for a in attrs.drain(..) {
794        let id = a
795            .path()
796            .get_ident()
797            .map(|i| i.to_string())
798            .unwrap_or_default();
799        if id == "MaskFields" {
800            let list = a.parse_args_with(Punctuated::<LitStr, Token![,]>::parse_terminated)?;
801            found.extend(list);
802        } else {
803            keep.push(a);
804        }
805    }
806    *attrs = keep;
807    Ok(found)
808}
809
810fn join_paths(prefix: &str, local: &str) -> String {
811    let p = prefix.trim_end_matches('/');
812    let l = if local.starts_with('/') {
813        local
814    } else {
815        return format!("{p}/{local}");
816    };
817    if p.is_empty() {
818        l.to_owned()
819    } else {
820        format!("{p}{l}")
821    }
822}
823
824// ════════════════════════════════════════════════════════════════════════
825//  Ident generation — stable, collision-free names for generated items
826// ════════════════════════════════════════════════════════════════════════
827
828/// Produce the three generated idents for one route.
829///
830/// When `controller` is non-empty (controller-method routes), we prefix with
831/// the controller name so two controllers with identically-named methods never
832/// produce duplicate static idents:
833///   - thunk:  `__arcly_thunk_HealthController_ping`
834///   - desc:   `__ARCLY_ROUTE_HEALTHCONTROLLER_PING`
835///   - spec:   `__ARCLY_SPEC_HEALTHCONTROLLER_PING`
836///
837/// Free-fn routes pass `controller = ""` so the ident is just the fn name.
838fn route_idents(controller: &str, fn_name: &str) -> (Ident, Ident, Ident) {
839    let (thunk, desc, spec) = if controller.is_empty() {
840        (
841            format!("__arcly_thunk_{fn_name}"),
842            format!("__ARCLY_ROUTE_{}", fn_name.to_uppercase()),
843            format!("__ARCLY_SPEC_{}", fn_name.to_uppercase()),
844        )
845    } else {
846        (
847            format!("__arcly_thunk_{controller}_{fn_name}"),
848            format!(
849                "__ARCLY_ROUTE_{}_{}",
850                controller.to_uppercase(),
851                fn_name.to_uppercase()
852            ),
853            format!(
854                "__ARCLY_SPEC_{}_{}",
855                controller.to_uppercase(),
856                fn_name.to_uppercase()
857            ),
858        )
859    };
860    (
861        format_ident!("{}", thunk),
862        format_ident!("{}", desc),
863        format_ident!("{}", spec),
864    )
865}
866
867// ════════════════════════════════════════════════════════════════════════
868//  Method-on-impl route registration (shared builder)
869// ════════════════════════════════════════════════════════════════════════
870#[allow(clippy::too_many_arguments)]
871fn build_method_route_registration(
872    self_ty: &Type,
873    m: &syn::ImplItemFn,
874    method: &'static str,
875    full_path: String,
876    args: &RouteArgs,
877    tags: &[LitStr],
878    interceptors: &[Path],
879    cache_ttl_secs: u64,
880    cache_key: &str,
881    controller_name: &str,
882    audit: &Option<(String, String)>,
883    timeout_ms: Option<u64>,
884    api_version: &str,
885    sunset: &str,
886    transactional: bool,
887    idempotent_ttl: Option<u64>,
888    policies: &[LitStr],
889    mask_fields: &[LitStr],
890) -> syn::Result<TokenStream2> {
891    let fn_name = m.sig.ident.clone();
892    // Include the controller name in all generated idents so that two controllers
893    // with identically-named methods (e.g. both have `fn ping`) never collide.
894    let (thunk_name, desc_name, spec_name) = route_idents(controller_name, &fn_name.to_string());
895    let method_ident = Ident::new(method, Span::call_site());
896
897    let doc = collect_doc_comments(&m.attrs);
898
899    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
900    let mut call_args: Vec<TokenStream2> = Vec::new();
901    let mut spec_params: Vec<TokenStream2> = Vec::new();
902    let mut has_body = false;
903    let mut body_ty: Option<Type> = None;
904    let mut query_ty: Option<Type> = None;
905
906    for (i, input) in m.sig.inputs.iter().enumerate() {
907        let FnArg::Typed(pt) = input else {
908            return Err(syn::Error::new(
909                input.span(),
910                "controller methods must not take `self` — use Inject<T> fields instead",
911            ));
912        };
913        let var = format_ident!("__arg_{i}");
914        let (kind, ty) = classify_arg(pt)?;
915        let stmt = emit_extractor(
916            &kind,
917            &ty,
918            &var,
919            &mut spec_params,
920            &mut has_body,
921            &mut body_ty,
922            &mut query_ty,
923        );
924        extract_stmts.push(stmt);
925        call_args.push(quote! { #var });
926    }
927
928    let mut guard_stmts: Vec<TokenStream2> = args
929        .guards
930        .iter()
931        .map(|g| {
932            quote! {
933                <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
934            }
935        })
936        .collect();
937
938    // #[RequirePolicies] — ABAC route gate, evaluated with the other guards
939    // (before extraction/transaction). Resource attributes are Null here;
940    // handlers re-check with attributes via policy::check_policies.
941    if !policies.is_empty() {
942        let action_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
943        guard_stmts.push(quote! {
944            ::arcly_http::__macro_support::check_policies(
945                &ctx, &[ #( #action_lits ),* ], ::arcly_http::serde_json::Value::Null,
946            )?;
947        });
948    }
949
950    // #[Transactional]: begin on the tenant pool → run guards/extraction/
951    // handler → commit on Ok / rollback on Err. Innermost layer: the driver
952    // tx wraps exactly the handler's own Result, and a #[Timeout] above it
953    // cancels the future → tx drops uncommitted → driver rollback.
954    let run_body = if transactional {
955        // Same outer shape as the plain path — Ok(handler_result) — so the
956        // shared response-conversion match below applies unchanged. Guard and
957        // extractor `?` convert Error → HttpException via the HttpError
958        // blanket, and run_transactional commits/rolls back on that Result.
959        quote! {
960            let __tx_ctx = ctx.clone();
961            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
962                ::arcly_http::__macro_support::run_transactional(&__tx_ctx, async move {
963                    #( #guard_stmts )*
964                    #( #extract_stmts )*
965                    <#self_ty>::#fn_name( #( #call_args ),* ).await
966                }).await
967            )
968        }
969    } else {
970        quote! {
971            #( #guard_stmts )*
972            #( #extract_stmts )*
973            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
974                <#self_ty>::#fn_name( #( #call_args ),* ).await
975            )
976        }
977    };
978
979    let inner = quote! {
980        let __run = async move {
981            #run_body
982        };
983        match __run.await {
984            ::core::result::Result::Ok(v)  => {
985                ::arcly_http::__axum::response::IntoResponse::into_response(v)
986            }
987            ::core::result::Result::Err(e) => {
988                ::arcly_http::__axum::response::IntoResponse::into_response(e)
989            }
990        }
991    };
992
993    // #[Timeout] — enforce a route deadline; the dropped future releases the
994    // worker immediately and the client gets a 504 ProblemDetails. Sits inside
995    // the audit wrapper so a timeout is recorded as outcome=Error (status 504).
996    let inner = match timeout_ms {
997        Some(ms) => {
998            let route_lit = LitStr::new(&full_path, Span::call_site());
999            quote! {
1000                ::arcly_http::__macro_support::run_with_timeout(
1001                    #ms, #route_lit, async move { #inner },
1002                ).await
1003            }
1004        }
1005        None => inner,
1006    };
1007
1008    // #[AuditLog] — clone the context up front (handler consumes it), then
1009    // emit one audit record keyed on the final response status. No pipeline
1010    // in the DI container → emit_route_audit is a no-op.
1011    let inner = match audit {
1012        Some((action, resource)) => {
1013            let action_lit = LitStr::new(action, Span::call_site());
1014            let resource_lit = LitStr::new(resource, Span::call_site());
1015            quote! {
1016                let __audit_ctx = ctx.clone();
1017                let __resp: ::arcly_http::__axum::response::Response = { #inner };
1018                ::arcly_http::__macro_support::emit_route_audit(
1019                    &__audit_ctx, #action_lit, #resource_lit, __resp.status().as_u16(),
1020                );
1021                __resp
1022            }
1023        }
1024        None => inner,
1025    };
1026
1027    // #[MaskFields] — redact the JSON response before any outer layer can
1028    // persist it (the idempotency replay cache stores masked bodies only).
1029    let inner = if mask_fields.is_empty() {
1030        inner
1031    } else {
1032        let mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1033        quote! {
1034            let __mask_ctx = ctx.clone();
1035            let __resp: ::arcly_http::__axum::response::Response = { #inner };
1036            ::arcly_http::__macro_support::mask_response(
1037                &__mask_ctx, &[ #( #mask_lits ),* ], __resp,
1038            ).await
1039        }
1040    };
1041
1042    // #[Idempotent] — outermost: replays skip guards/tx/audit entirely and
1043    // return the stored response; concurrent duplicates get 409.
1044    let inner = match idempotent_ttl {
1045        Some(ttl) => {
1046            let route_lit = LitStr::new(&full_path, Span::call_site());
1047            quote! {
1048                let __idem_ctx = ctx.clone();
1049                ::arcly_http::__macro_support::run_idempotent(
1050                    &__idem_ctx, #ttl, #route_lit, async move { #inner },
1051                ).await
1052            }
1053        }
1054        None => inner,
1055    };
1056
1057    let thunk_body = wrap_interceptors(inner, interceptors);
1058
1059    let fn_str = fn_name.to_string();
1060    let summary_str = args
1061        .summary
1062        .as_ref()
1063        .map(|s| s.value())
1064        .unwrap_or_else(|| fn_str.clone());
1065    let operation_id = args
1066        .operation_id
1067        .as_ref()
1068        .map(|s| s.value())
1069        .unwrap_or_else(|| fn_str.clone());
1070    let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1071    let deprecated = args.deprecated;
1072    let tag_lits: Vec<TokenStream2> = tags.iter().map(|t| quote!(#t)).collect();
1073    let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1074    let status_expr = match &args.status {
1075        Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1076        None => quote! { ::core::option::Option::None },
1077    };
1078    let body_schema_expr = schema_expr(&body_ty);
1079    let query_schema_expr = schema_expr(&query_ty);
1080    let response_schema_expr = schema_expr(&extract_response_ty(&m.sig.output));
1081    let full_path_lit = LitStr::new(&full_path, Span::call_site());
1082
1083    // OpenAPI metadata mirrors of the hardening attributes.
1084    let spec_idem_ttl = idempotent_ttl.unwrap_or(0);
1085    let spec_policy_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
1086    let (spec_audit_action, spec_audit_resource) = match audit {
1087        Some((a, r)) => (a.clone(), r.clone()),
1088        None => (String::new(), String::new()),
1089    };
1090    let spec_timeout_ms = timeout_ms.unwrap_or(0);
1091    let spec_mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1092
1093    Ok(quote! {
1094        fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1095            -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1096        {
1097            ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1098        }
1099
1100        #[allow(non_upper_case_globals)]
1101        static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1102            ::arcly_http::__macro_support::RouteSpec {
1103                summary: #summary_str,
1104                description: #description_str,
1105                operation_id: #operation_id,
1106                tags: &[ #( #tag_lits ),* ],
1107                security: &[ #( #sec_lits ),* ],
1108                status_code: #status_expr,
1109                deprecated: #deprecated,
1110                params: &[ #( #spec_params ),* ],
1111                has_body: #has_body,
1112                body_schema: #body_schema_expr,
1113                query_schema: #query_schema_expr,
1114                response_schema: #response_schema_expr,
1115                cache_ttl_secs: #cache_ttl_secs,
1116                cache_key: #cache_key,
1117                api_version: #api_version,
1118                sunset: #sunset,
1119                idempotent_ttl_secs: #spec_idem_ttl,
1120                policies: &[ #( #spec_policy_lits ),* ],
1121                audit_action: #spec_audit_action,
1122                audit_resource: #spec_audit_resource,
1123                timeout_ms: #spec_timeout_ms,
1124                transactional: #transactional,
1125                mask_fields: &[ #( #spec_mask_lits ),* ],
1126            };
1127
1128        #[allow(non_upper_case_globals)]
1129        static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1130            ::arcly_http::__macro_support::RouteDescriptor {
1131                method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1132                path: #full_path_lit,
1133                handler: #thunk_name,
1134                spec: &#spec_name,
1135                controller: #controller_name,
1136            };
1137
1138        ::arcly_http::inventory::submit! {
1139            &#desc_name
1140        }
1141    })
1142}
1143
1144fn schema_expr(ty: &Option<Type>) -> TokenStream2 {
1145    match ty {
1146        Some(t) => quote! { ::core::option::Option::Some(|| ::arcly_http::__schema_for::<#t>()) },
1147        None => quote! { ::core::option::Option::None },
1148    }
1149}
1150
1151/// Compose interceptors around `inner` (which yields a Response).
1152///
1153/// Three monomorphic shapes, picked at macro time based on the chain length:
1154///
1155/// * **N = 0** — no interceptors. Inline the handler body verbatim. Zero
1156///   extra Box, zero extra `await`.
1157/// * **N = 1** — fast path. Build one `NextHandler` for the handler body,
1158///   call the single interceptor's `around()` directly. Saves the outer
1159///   `NextHandler::new` + one `Box::pin` versus the general chain.
1160/// * **N ≥ 2** — general chain. Each layer wraps the next in a
1161///   `NextHandler`; the outermost is invoked via `.run(ctx).await`.
1162fn wrap_interceptors(inner: TokenStream2, interceptors: &[Path]) -> TokenStream2 {
1163    match interceptors.len() {
1164        0 => return inner,
1165        1 => {
1166            let icp = &interceptors[0];
1167            return quote! {
1168                {
1169                    static __ICP: #icp = #icp;
1170                    let __inner = ::arcly_http::__macro_support::NextHandler::new(
1171                        move |ctx: ::arcly_http::__macro_support::RequestContext|
1172                            ::arcly_http::futures::FutureExt::boxed(async move {
1173                                let ctx = ctx;
1174                                #inner
1175                            })
1176                    );
1177                    <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner).await
1178                }
1179            };
1180        }
1181        _ => {}
1182    }
1183
1184    // General N-layer chain.
1185    let mut current = quote! {
1186        ::arcly_http::__macro_support::NextHandler::new(
1187            move |ctx: ::arcly_http::__macro_support::RequestContext|
1188                ::arcly_http::futures::FutureExt::boxed(async move {
1189                    let ctx = ctx;
1190                    #inner
1191                })
1192        )
1193    };
1194    for icp in interceptors.iter().rev() {
1195        current = quote! {
1196            {
1197                static __ICP: #icp = #icp;
1198                let __inner = #current;
1199                ::arcly_http::__macro_support::NextHandler::new(
1200                    move |ctx: ::arcly_http::__macro_support::RequestContext| {
1201                        <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner.__clone_for_chain())
1202                    },
1203                )
1204            }
1205        };
1206    }
1207    quote! { #current.run(ctx).await }
1208}
1209
1210// ════════════════════════════════════════════════════════════════════════
1211//  Standalone #[Get/Post/…] free-fn macros
1212// ════════════════════════════════════════════════════════════════════════
1213
1214#[proc_macro_attribute]
1215#[allow(non_snake_case)]
1216pub fn Get(a: TokenStream, i: TokenStream) -> TokenStream {
1217    route_free_fn(a, i, "GET")
1218}
1219#[proc_macro_attribute]
1220#[allow(non_snake_case)]
1221pub fn Post(a: TokenStream, i: TokenStream) -> TokenStream {
1222    route_free_fn(a, i, "POST")
1223}
1224#[proc_macro_attribute]
1225#[allow(non_snake_case)]
1226pub fn Put(a: TokenStream, i: TokenStream) -> TokenStream {
1227    route_free_fn(a, i, "PUT")
1228}
1229#[proc_macro_attribute]
1230#[allow(non_snake_case)]
1231pub fn Delete(a: TokenStream, i: TokenStream) -> TokenStream {
1232    route_free_fn(a, i, "DELETE")
1233}
1234#[proc_macro_attribute]
1235#[allow(non_snake_case)]
1236pub fn Patch(a: TokenStream, i: TokenStream) -> TokenStream {
1237    route_free_fn(a, i, "PATCH")
1238}
1239
1240/// `#[CacheTTL(N)]` — TTL in seconds. Marker attribute consumed by the
1241/// route macro and stuffed into `RouteSpec.cache_ttl_secs`. Pass-through
1242/// here so the type system accepts it standalone.
1243#[proc_macro_attribute]
1244#[allow(non_snake_case)]
1245pub fn CacheTTL(_attr: TokenStream, item: TokenStream) -> TokenStream {
1246    item
1247}
1248
1249/// `#[AuditLog(action = "…", resource = "…")]` — emit one compliance audit
1250/// record per invocation, keyed on the response status. Consumed by
1251/// `#[Controller]`; pass-through marker on free fns.
1252#[proc_macro_attribute]
1253#[allow(non_snake_case)]
1254pub fn AuditLog(_attr: TokenStream, item: TokenStream) -> TokenStream {
1255    item
1256}
1257
1258/// `#[Timeout("2s")]` — route deadline; 504 + future cancellation on expiry.
1259/// Consumed by `#[Controller]`; pass-through marker on free fns.
1260#[proc_macro_attribute]
1261#[allow(non_snake_case)]
1262pub fn Timeout(_attr: TokenStream, item: TokenStream) -> TokenStream {
1263    item
1264}
1265
1266/// `#[Version("v1")]` on a `#[Controller]` impl — mounts every route under
1267/// `/v1/...` and records the version in each RouteSpec. Marker; consumed by
1268/// `#[Controller]`.
1269#[proc_macro_attribute]
1270#[allow(non_snake_case)]
1271pub fn Version(_attr: TokenStream, item: TokenStream) -> TokenStream {
1272    item
1273}
1274
1275/// `#[Deprecated(sunset = "YYYY-MM-DD")]` on a `#[Controller]` impl — adds
1276/// RFC 8594 `Deprecation`/`Sunset` headers to every response from this
1277/// controller. Marker; consumed by `#[Controller]`.
1278#[proc_macro_attribute]
1279#[allow(non_snake_case)]
1280pub fn Deprecated(_attr: TokenStream, item: TokenStream) -> TokenStream {
1281    item
1282}
1283
1284/// `#[Transactional]` — wrap the handler in a database transaction on the
1285/// request-tenant's pool: commit on `Ok`, rollback on `Err`/cancellation.
1286/// Consumed by `#[Controller]`; pass-through marker on free fns.
1287#[proc_macro_attribute]
1288#[allow(non_snake_case)]
1289pub fn Transactional(_attr: TokenStream, item: TokenStream) -> TokenStream {
1290    item
1291}
1292
1293/// `#[Idempotent(ttl = "24h")]` — Stripe-style Idempotency-Key handling:
1294/// claim → run → store; retries replay the stored response; concurrent
1295/// duplicates get 409. Consumed by `#[Controller]`.
1296#[proc_macro_attribute]
1297#[allow(non_snake_case)]
1298pub fn Idempotent(_attr: TokenStream, item: TokenStream) -> TokenStream {
1299    item
1300}
1301
1302/// `#[RequirePolicies("orders.refund", …)]` — ABAC route gate: every listed
1303/// action must Permit under the hot-reloadable PolicyEngine (default-deny).
1304/// Consumed by `#[Controller]`.
1305#[proc_macro_attribute]
1306#[allow(non_snake_case)]
1307pub fn RequirePolicies(_attr: TokenStream, item: TokenStream) -> TokenStream {
1308    item
1309}
1310
1311/// `#[EventPattern("topic")]` — marks a method inside an `#[EventConsumer]`
1312/// impl as the handler for one topic. Marker; consumed by `#[EventConsumer]`.
1313#[proc_macro_attribute]
1314#[allow(non_snake_case)]
1315pub fn EventPattern(_attr: TokenStream, item: TokenStream) -> TokenStream {
1316    item
1317}
1318
1319/// `#[EventConsumer]` on an impl block — registers every `#[EventPattern]`
1320/// method into the link-time event registry (the messaging analogue of
1321/// `#[Controller]`). Methods take `EventContext` and return
1322/// `Result<(), String>`.
1323#[proc_macro_attribute]
1324#[allow(non_snake_case)]
1325pub fn EventConsumer(_attr: TokenStream, item: TokenStream) -> TokenStream {
1326    let mut imp = parse_macro_input!(item as ItemImpl);
1327    let self_ty = (*imp.self_ty).clone();
1328    let consumer_name = match &self_ty {
1329        Type::Path(tp) => tp
1330            .path
1331            .segments
1332            .last()
1333            .map(|s| s.ident.to_string())
1334            .unwrap_or_default(),
1335        _ => String::new(),
1336    };
1337
1338    let mut registrations: Vec<TokenStream2> = Vec::new();
1339    let mut errors: Vec<syn::Error> = Vec::new();
1340
1341    for item in imp.items.iter_mut() {
1342        let ImplItem::Fn(m) = item else { continue };
1343        let mut topic: Option<LitStr> = None;
1344        let mut keep: Vec<Attribute> = Vec::with_capacity(m.attrs.len());
1345        for a in m.attrs.drain(..) {
1346            let id = a
1347                .path()
1348                .get_ident()
1349                .map(|i| i.to_string())
1350                .unwrap_or_default();
1351            if id == "EventPattern" {
1352                match a.parse_args::<LitStr>() {
1353                    Ok(t) => topic = Some(t),
1354                    Err(e) => errors.push(e),
1355                }
1356            } else {
1357                keep.push(a);
1358            }
1359        }
1360        m.attrs = keep;
1361        let Some(topic) = topic else { continue };
1362
1363        let fn_name = m.sig.ident.clone();
1364        let thunk = format_ident!("__arcly_event_{}_{}", consumer_name, fn_name);
1365        let desc = format_ident!("__ARCLY_EVENT_DESC_{}_{}", consumer_name, fn_name);
1366        let consumer_lit = LitStr::new(&consumer_name, Span::call_site());
1367
1368        registrations.push(quote! {
1369            #[allow(non_snake_case)]
1370            fn #thunk(ctx: ::arcly_http::__macro_support::EventContext)
1371                -> ::arcly_http::futures::future::BoxFuture<'static, ::core::result::Result<(), ::std::string::String>>
1372            {
1373                ::arcly_http::futures::FutureExt::boxed(<#self_ty>::#fn_name(ctx))
1374            }
1375
1376            #[allow(non_upper_case_globals)]
1377            static #desc: ::arcly_http::__macro_support::EventHandlerDescriptor =
1378                ::arcly_http::__macro_support::EventHandlerDescriptor {
1379                    topic:    #topic,
1380                    consumer: #consumer_lit,
1381                    handler:  #thunk,
1382                };
1383
1384            ::arcly_http::inventory::submit! { &#desc }
1385        });
1386    }
1387
1388    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1389        a.combine(b);
1390        a
1391    }) {
1392        return err.to_compile_error().into();
1393    }
1394
1395    quote! {
1396        #imp
1397        #( #registrations )*
1398    }
1399    .into()
1400}
1401
1402/// `#[MaskFields("email", "card:last4")]` — redact these JSON response
1403/// fields (plus the global Masker rules) before any durable layer sees the
1404/// body. Consumed by `#[Controller]`.
1405#[proc_macro_attribute]
1406#[allow(non_snake_case)]
1407pub fn MaskFields(_attr: TokenStream, item: TokenStream) -> TokenStream {
1408    item
1409}
1410
1411/// `#[CacheKey("template")]` — custom key. Marker attribute; see `CacheTTL`.
1412#[proc_macro_attribute]
1413#[allow(non_snake_case)]
1414pub fn CacheKey(_attr: TokenStream, item: TokenStream) -> TokenStream {
1415    item
1416}
1417
1418/// `#[UseInterceptors(A, B)]` on a free fn — wraps that handler's thunk.
1419#[proc_macro_attribute]
1420#[allow(non_snake_case)]
1421pub fn UseInterceptors(_attr: TokenStream, item: TokenStream) -> TokenStream {
1422    // When used on an impl-method, Controller has already consumed this
1423    // attribute. When used on a free fn, the route macro above sits *below*
1424    // it (outer attrs run later), and our Get/Post/... macros look for a
1425    // sibling `#[UseInterceptors]` on the same fn — so here we just pass
1426    // through.
1427    item
1428}
1429
1430fn route_free_fn(attr: TokenStream, item: TokenStream, method: &'static str) -> TokenStream {
1431    let args = parse_macro_input!(attr as RouteArgs);
1432    let mut f = parse_macro_input!(item as ItemFn);
1433
1434    // Pluck any sibling #[UseInterceptors(...)] attributes from the fn so they
1435    // contribute to the chain. (The companion macro above is a pass-through.)
1436    let mut interceptors: Vec<Path> = Vec::new();
1437    let mut keep_attrs: Vec<Attribute> = Vec::with_capacity(f.attrs.len());
1438    for a in f.attrs.drain(..) {
1439        let id = a
1440            .path()
1441            .get_ident()
1442            .map(|i| i.to_string())
1443            .unwrap_or_default();
1444        if id == "UseInterceptors" {
1445            match a.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated) {
1446                Ok(list) => interceptors.extend(list),
1447                Err(e) => return e.to_compile_error().into(),
1448            }
1449        } else {
1450            keep_attrs.push(a);
1451        }
1452    }
1453    f.attrs = keep_attrs;
1454
1455    let (cache_ttl_secs, cache_key): (u64, String) = match harvest_cache_attrs(&mut f.attrs) {
1456        Ok(p) => p,
1457        Err(e) => return e.to_compile_error().into(),
1458    };
1459
1460    let path_lit = args.path.clone();
1461    let full_path = path_lit.value();
1462
1463    let fn_name = f.sig.ident.clone();
1464    // Free-fn routes have no controller — use an empty prefix so idents remain
1465    // unique across multiple free-fn routes with different function names.
1466    let (thunk_name, desc_name, spec_name) = route_idents("", &fn_name.to_string());
1467    let method_ident = Ident::new(method, Span::call_site());
1468
1469    let doc = collect_doc_comments(&f.attrs);
1470
1471    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1472    let mut call_args: Vec<TokenStream2> = Vec::new();
1473    let mut errors: Vec<syn::Error> = Vec::new();
1474    let mut spec_params: Vec<TokenStream2> = Vec::new();
1475    let mut has_body = false;
1476    let mut body_ty: Option<Type> = None;
1477    let mut query_ty: Option<Type> = None;
1478
1479    for (i, input) in f.sig.inputs.iter_mut().enumerate() {
1480        let FnArg::Typed(pt) = input else {
1481            errors.push(syn::Error::new(input.span(), "handler must not take self"));
1482            continue;
1483        };
1484        let var = format_ident!("__arg_{i}");
1485        match classify_arg(pt) {
1486            Ok((kind, ty)) => {
1487                let stmt = emit_extractor(
1488                    &kind,
1489                    &ty,
1490                    &var,
1491                    &mut spec_params,
1492                    &mut has_body,
1493                    &mut body_ty,
1494                    &mut query_ty,
1495                );
1496                extract_stmts.push(stmt);
1497                call_args.push(quote! { #var });
1498            }
1499            Err(e) => errors.push(e),
1500        }
1501        pt.attrs.retain(|a| {
1502            let id = a
1503                .path()
1504                .get_ident()
1505                .map(|i| i.to_string())
1506                .unwrap_or_default();
1507            !matches!(id.as_str(), "Param" | "Query" | "Body" | "Header")
1508        });
1509    }
1510
1511    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1512        a.combine(b);
1513        a
1514    }) {
1515        return err.to_compile_error().into();
1516    }
1517
1518    let guard_stmts: Vec<TokenStream2> = args
1519        .guards
1520        .iter()
1521        .map(|g| {
1522            quote! {
1523                <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
1524            }
1525        })
1526        .collect();
1527
1528    let inner = quote! {
1529        let __run = async move {
1530            #( #guard_stmts )*
1531            #( #extract_stmts )*
1532            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1533                #fn_name( #( #call_args ),* ).await
1534            )
1535        };
1536        match __run.await {
1537            ::core::result::Result::Ok(v)  => {
1538                ::arcly_http::__axum::response::IntoResponse::into_response(v)
1539            }
1540            ::core::result::Result::Err(e) => {
1541                ::arcly_http::__axum::response::IntoResponse::into_response(e)
1542            }
1543        }
1544    };
1545
1546    let thunk_body = wrap_interceptors(inner, &interceptors);
1547
1548    let fn_str = fn_name.to_string();
1549    let summary_str = args
1550        .summary
1551        .as_ref()
1552        .map(|s| s.value())
1553        .unwrap_or_else(|| fn_str.clone());
1554    let operation_id = args
1555        .operation_id
1556        .as_ref()
1557        .map(|s| s.value())
1558        .unwrap_or_else(|| fn_str.clone());
1559    let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1560    let deprecated = args.deprecated;
1561    let tag_lits: Vec<TokenStream2> = args.tags.iter().map(|t| quote!(#t)).collect();
1562    let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1563    let status_expr = match &args.status {
1564        Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1565        None => quote! { ::core::option::Option::None },
1566    };
1567    let body_schema_expr = schema_expr(&body_ty);
1568    let query_schema_expr = schema_expr(&query_ty);
1569    let response_schema_expr = schema_expr(&extract_response_ty(&f.sig.output));
1570    let full_path_lit = LitStr::new(&full_path, Span::call_site());
1571
1572    quote! {
1573        #f
1574
1575        fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1576            -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1577        {
1578            ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1579        }
1580
1581        #[allow(non_upper_case_globals)]
1582        static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1583            ::arcly_http::__macro_support::RouteSpec {
1584                summary: #summary_str,
1585                description: #description_str,
1586                operation_id: #operation_id,
1587                tags: &[ #( #tag_lits ),* ],
1588                security: &[ #( #sec_lits ),* ],
1589                status_code: #status_expr,
1590                deprecated: #deprecated,
1591                params: &[ #( #spec_params ),* ],
1592                has_body: #has_body,
1593                body_schema: #body_schema_expr,
1594                query_schema: #query_schema_expr,
1595                response_schema: #response_schema_expr,
1596                cache_ttl_secs: #cache_ttl_secs,
1597                cache_key: #cache_key,
1598                api_version: "",
1599                sunset: "",
1600                idempotent_ttl_secs: 0,
1601                policies: &[],
1602                audit_action: "",
1603                audit_resource: "",
1604                timeout_ms: 0,
1605                transactional: false,
1606                mask_fields: &[],
1607            };
1608
1609        #[allow(non_upper_case_globals)]
1610        static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1611            ::arcly_http::__macro_support::RouteDescriptor {
1612                method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1613                path: #full_path_lit,
1614                handler: #thunk_name,
1615                spec: &#spec_name,
1616                controller: "",
1617            };
1618
1619        ::arcly_http::inventory::submit! {
1620            &#desc_name
1621        }
1622    }
1623    .into()
1624}
1625
1626// ════════════════════════════════════════════════════════════════════════
1627//  Param classification + extractor emission (shared)
1628// ════════════════════════════════════════════════════════════════════════
1629enum ParamKind {
1630    Param(LitStr),
1631    Query,
1632    Body,
1633    Header(LitStr),
1634    Ctx,
1635    FromContext,
1636}
1637
1638fn classify_arg(arg: &PatType) -> syn::Result<(ParamKind, Type)> {
1639    for attr in &arg.attrs {
1640        let ident = attr
1641            .path()
1642            .get_ident()
1643            .map(|i| i.to_string())
1644            .unwrap_or_default();
1645        match ident.as_str() {
1646            "Param" => {
1647                let name: LitStr = attr.parse_args()?;
1648                return Ok((ParamKind::Param(name), (*arg.ty).clone()));
1649            }
1650            "Query" => return Ok((ParamKind::Query, (*arg.ty).clone())),
1651            "Body" => return Ok((ParamKind::Body, (*arg.ty).clone())),
1652            "Header" => {
1653                let name: LitStr = attr.parse_args()?;
1654                return Ok((ParamKind::Header(name), (*arg.ty).clone()));
1655            }
1656            _ => {}
1657        }
1658    }
1659    let ty_ref = &*arg.ty;
1660    let ty_str = quote!(#ty_ref).to_string();
1661    let ty = (*arg.ty).clone();
1662    let kind = if ty_str.contains("RequestContext") {
1663        ParamKind::Ctx
1664    } else {
1665        ParamKind::FromContext
1666    };
1667    Ok((kind, ty))
1668}
1669
1670fn emit_extractor(
1671    kind: &ParamKind,
1672    ty: &Type,
1673    var: &Ident,
1674    spec_params: &mut Vec<TokenStream2>,
1675    has_body: &mut bool,
1676    body_ty: &mut Option<Type>,
1677    query_ty: &mut Option<Type>,
1678) -> TokenStream2 {
1679    match kind {
1680        ParamKind::Param(name) => {
1681            spec_params.push(quote! {
1682                ::arcly_http::__macro_support::ParamSpec {
1683                    name: #name,
1684                    loc: ::arcly_http::__macro_support::ParamLoc::Path,
1685                    required: true,
1686                    schema: || ::arcly_http::__schema_for::<#ty>(),
1687                }
1688            });
1689            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_param(&ctx, #name)?; }
1690        }
1691        ParamKind::Query => {
1692            *query_ty = Some(ty.clone());
1693            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_query_validated(&ctx)?; }
1694        }
1695        ParamKind::Body => {
1696            *has_body = true;
1697            *body_ty = Some(ty.clone());
1698            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_body_validated(&ctx)?; }
1699        }
1700        ParamKind::Header(name) => {
1701            spec_params.push(quote! {
1702                ::arcly_http::__macro_support::ParamSpec {
1703                    name: #name,
1704                    loc: ::arcly_http::__macro_support::ParamLoc::Header,
1705                    required: true,
1706                    schema: || ::arcly_http::__schema_for::<#ty>(),
1707                }
1708            });
1709            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_header(&ctx, #name)?.to_owned(); }
1710        }
1711        ParamKind::Ctx => quote! { let #var: #ty = ctx.clone(); },
1712        ParamKind::FromContext => quote! { let #var: #ty = <#ty>::from_ctx(&ctx); },
1713    }
1714}
1715
1716// ════════════════════════════════════════════════════════════════════════
1717//  Return-type walker — Json<T> / Result<X,_> / Created<T> / NoContent / Accepted<T>
1718// ════════════════════════════════════════════════════════════════════════
1719fn extract_response_ty(ret: &ReturnType) -> Option<Type> {
1720    let ty = match ret {
1721        ReturnType::Default => return None,
1722        ReturnType::Type(_, ty) => &**ty,
1723    };
1724    inner_payload_ty(ty).cloned()
1725}
1726
1727fn inner_payload_ty(ty: &Type) -> Option<&Type> {
1728    let Type::Path(tp) = ty else { return None };
1729    let seg = tp.path.segments.last()?;
1730    match seg.ident.to_string().as_str() {
1731        "Json" | "Created" | "Accepted" => first_generic(&seg.arguments),
1732        "Result" => {
1733            let ok = first_generic(&seg.arguments)?;
1734            inner_payload_ty(ok)
1735        }
1736        "NoContent" => None,
1737        _ => None,
1738    }
1739}
1740
1741fn first_generic(args: &PathArguments) -> Option<&Type> {
1742    let PathArguments::AngleBracketed(ab) = args else {
1743        return None;
1744    };
1745    ab.args.iter().find_map(|a| match a {
1746        GenericArgument::Type(t) => Some(t),
1747        _ => None,
1748    })
1749}
1750
1751fn collect_doc_comments(attrs: &[Attribute]) -> String {
1752    let mut out = String::new();
1753    for a in attrs {
1754        if !a.path().is_ident("doc") {
1755            continue;
1756        }
1757        if let Meta::NameValue(nv) = &a.meta {
1758            if let Expr::Lit(ExprLit {
1759                lit: Lit::Str(s), ..
1760            }) = &nv.value
1761            {
1762                let line = s.value();
1763                if !out.is_empty() {
1764                    out.push('\n');
1765                }
1766                out.push_str(line.trim_start());
1767            }
1768        }
1769    }
1770    out
1771}
1772
1773// ════════════════════════════════════════════════════════════════════════
1774//  #[Gateway("/path", tags(…))] — real-time WebSocket gateway (on `impl`)
1775// ════════════════════════════════════════════════════════════════════════
1776//
1777// Placed on the gateway's handler `impl` block (same rule/reason as
1778// #[Controller]). The struct itself carries #[Injectable] for field DI and a
1779// separate `impl ArclyGateway` for connection lifecycle. This macro walks the
1780// impl for #[Subscribe("event")] methods, builds an event→handler dispatch
1781// table, and emits a link-time GatewayDescriptor.
1782
1783struct GatewayArgs {
1784    path: LitStr,
1785    #[allow(dead_code)]
1786    tags: Vec<LitStr>,
1787}
1788
1789impl Parse for GatewayArgs {
1790    fn parse(input: ParseStream) -> syn::Result<Self> {
1791        let path: LitStr = input.parse()?;
1792        let mut tags: Vec<LitStr> = vec![];
1793        if input.peek(Token![,]) {
1794            let _: Token![,] = input.parse()?;
1795            if !input.is_empty() {
1796                let key: Ident = input.parse()?;
1797                if key != "tags" {
1798                    return Err(syn::Error::new(key.span(), "expected `tags(...)`"));
1799                }
1800                let content;
1801                syn::parenthesized!(content in input);
1802                let list: Punctuated<LitStr, Token![,]> =
1803                    content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
1804                tags.extend(list);
1805            }
1806        }
1807        Ok(Self { path, tags })
1808    }
1809}
1810
1811#[proc_macro_attribute]
1812#[allow(non_snake_case)]
1813pub fn Gateway(attr: TokenStream, item: TokenStream) -> TokenStream {
1814    match syn::parse::<ItemImpl>(item) {
1815        Ok(imp) => gateway_on_impl(attr, imp),
1816        Err(_) => syn::Error::new(
1817            Span::call_site(),
1818            "#[Gateway(\"/path\")] must be placed on the gateway's `impl` block \
1819             (the struct uses #[Injectable]; lifecycle goes in `impl ArclyGateway`)",
1820        )
1821        .to_compile_error()
1822        .into(),
1823    }
1824}
1825
1826/// `#[Subscribe("event::name")]` — marker consumed by the enclosing `#[Gateway]`
1827/// walker. Pass-through so it also type-checks if a tool resolves it directly.
1828#[proc_macro_attribute]
1829#[allow(non_snake_case)]
1830pub fn Subscribe(_attr: TokenStream, item: TokenStream) -> TokenStream {
1831    item
1832}
1833
1834fn gateway_on_impl(attr: TokenStream, mut imp: ItemImpl) -> TokenStream {
1835    let GatewayArgs { path, .. } = parse_macro_input!(attr as GatewayArgs);
1836    let self_ty = (*imp.self_ty).clone();
1837    let name = match &self_ty {
1838        Type::Path(tp) => tp
1839            .path
1840            .segments
1841            .last()
1842            .map(|s| s.ident.to_string())
1843            .unwrap_or_default(),
1844        _ => String::new(),
1845    };
1846
1847    let mut dispatch_inserts: Vec<TokenStream2> = Vec::new();
1848    let mut errors: Vec<syn::Error> = Vec::new();
1849
1850    for item in imp.items.iter_mut() {
1851        let ImplItem::Fn(m) = item else { continue };
1852
1853        // Find a #[Subscribe("event")] marker on this method.
1854        let sub_idx = m.attrs.iter().position(|a| {
1855            a.path()
1856                .get_ident()
1857                .map(|i| i.to_string())
1858                .unwrap_or_default()
1859                == "Subscribe"
1860        });
1861        let Some(idx) = sub_idx else { continue };
1862
1863        let event: LitStr = match m.attrs[idx].parse_args() {
1864            Ok(e) => e,
1865            Err(e) => {
1866                errors.push(e);
1867                continue;
1868            }
1869        };
1870        m.attrs.remove(idx); // strip before re-emitting the impl
1871
1872        let fn_name = m.sig.ident.clone();
1873        match build_subscribe_insert(&self_ty, m, &event, &fn_name) {
1874            Ok(ts) => dispatch_inserts.push(ts),
1875            Err(e) => errors.push(e),
1876        }
1877    }
1878
1879    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1880        a.combine(b);
1881        a
1882    }) {
1883        return err.to_compile_error().into();
1884    }
1885
1886    let path_lit = LitStr::new(&path.value(), Span::call_site());
1887    let name_lit = LitStr::new(&name, Span::call_site());
1888    let build_ident = format_ident!("__arcly_build_gateway_{}", name.to_uppercase());
1889    let desc_ident = format_ident!("__ARCLY_GATEWAY_{}", name.to_uppercase());
1890
1891    quote! {
1892        #imp
1893
1894        #[doc(hidden)]
1895        #[allow(non_snake_case)]
1896        fn #build_ident(__container: &'static ::arcly_http::__macro_support::FrozenDiContainer)
1897            -> &'static ::arcly_http::__macro_support::GatewayRuntime
1898        {
1899            // Wire the gateway's Inject<T> fields via the #[Injectable]-generated
1900            // builder, then leak to &'static for the process lifetime.
1901            let __gw: &'static #self_ty = ::std::boxed::Box::leak(::std::boxed::Box::new(
1902                <#self_ty>::__arcly_build(&__container.resolver())
1903            ));
1904
1905            let mut __dispatch: ::std::collections::HashMap<
1906                &'static str,
1907                ::arcly_http::__macro_support::MessageHandler,
1908            > = ::std::collections::HashMap::new();
1909            #( #dispatch_inserts )*
1910
1911            ::std::boxed::Box::leak(::std::boxed::Box::new(
1912                ::arcly_http::__macro_support::GatewayRuntime {
1913                    path: #path_lit,
1914                    on_connect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
1915                        let __gw = __gw;
1916                        ::arcly_http::futures::FutureExt::boxed(async move {
1917                            <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_connect(__gw, __c).await
1918                        })
1919                    }),
1920                    on_disconnect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
1921                        let __gw = __gw;
1922                        ::arcly_http::futures::FutureExt::boxed(async move {
1923                            <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_disconnect(__gw, __c).await
1924                        })
1925                    }),
1926                    dispatch: __dispatch,
1927                }
1928            ))
1929        }
1930
1931        #[allow(non_upper_case_globals)]
1932        static #desc_ident: ::arcly_http::__macro_support::GatewayDescriptor =
1933            ::arcly_http::__macro_support::GatewayDescriptor {
1934                name: #name_lit,
1935                path: #path_lit,
1936                build: #build_ident,
1937            };
1938
1939        ::arcly_http::inventory::submit! { &#desc_ident }
1940    }
1941    .into()
1942}
1943
1944/// Emit one `dispatch.insert("event", handler)` block. The handler closure
1945/// captures the `&'static` gateway, extracts each parameter (a `WsClient`
1946/// clone, or a `Json<T>` deserialized from the envelope `data`), then awaits
1947/// the user's `async fn`.
1948fn build_subscribe_insert(
1949    self_ty: &Type,
1950    m: &syn::ImplItemFn,
1951    event: &LitStr,
1952    fn_name: &Ident,
1953) -> syn::Result<TokenStream2> {
1954    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1955    let mut call_args: Vec<TokenStream2> = Vec::new();
1956
1957    for (i, input) in m.sig.inputs.iter().enumerate() {
1958        let pt = match input {
1959            FnArg::Receiver(_) => continue, // &self — supplied as __gw
1960            FnArg::Typed(pt) => pt,
1961        };
1962        let ty = (*pt.ty).clone();
1963        let var = format_ident!("__sub_arg_{i}");
1964
1965        if type_last_ident_is(&ty, "WsClient") {
1966            extract_stmts.push(
1967                quote! { let #var: ::arcly_http::__macro_support::WsClient = __client.clone(); },
1968            );
1969            call_args.push(quote! { #var });
1970        } else if let Some(inner) = json_inner_ty(&ty) {
1971            extract_stmts.push(quote! {
1972                let #var: ::arcly_http::__macro_support::Json<#inner> = ::arcly_http::__macro_support::Json(
1973                    ::arcly_http::serde_json::from_str::<#inner>(&__data)
1974                        .map_err(|_| ::arcly_http::__macro_support::Error::BadRequest("invalid websocket payload"))?
1975                );
1976            });
1977            call_args.push(quote! { #var });
1978        } else {
1979            return Err(syn::Error::new(
1980                pt.span(),
1981                "#[Subscribe] handler params must be `WsClient` or `Json<T>`",
1982            ));
1983        }
1984    }
1985
1986    Ok(quote! {
1987        {
1988            let __gw = __gw;
1989            let __handler: ::arcly_http::__macro_support::MessageHandler = ::std::sync::Arc::new(
1990                move |__client: ::arcly_http::__macro_support::WsClient, __data: ::std::sync::Arc<str>| {
1991                    let __gw = __gw;
1992                    ::arcly_http::futures::FutureExt::boxed(async move {
1993                        #( #extract_stmts )*
1994                        <#self_ty>::#fn_name(__gw, #( #call_args ),*).await
1995                    })
1996                }
1997            );
1998            __dispatch.insert(#event, __handler);
1999        }
2000    })
2001}
2002
2003fn type_last_ident_is(ty: &Type, name: &str) -> bool {
2004    matches!(ty, Type::Path(tp)
2005        if tp.path.segments.last().map(|s| s.ident == name).unwrap_or(false))
2006}
2007
2008fn json_inner_ty(ty: &Type) -> Option<Type> {
2009    let Type::Path(tp) = ty else { return None };
2010    let seg = tp.path.segments.last()?;
2011    if seg.ident != "Json" {
2012        return None;
2013    }
2014    first_generic(&seg.arguments).cloned()
2015}
2016
2017// ════════════════════════════════════════════════════════════════════════
2018//  #[circuit_breaker(threshold = N, cooldown = "Ns")]
2019// ════════════════════════════════════════════════════════════════════════
2020struct BreakerArgs {
2021    threshold: u32,
2022    cooldown_millis: u64,
2023}
2024
2025impl Parse for BreakerArgs {
2026    fn parse(input: ParseStream) -> syn::Result<Self> {
2027        let mut threshold: Option<u32> = None;
2028        let mut cooldown_millis: Option<u64> = None;
2029        while !input.is_empty() {
2030            let key: Ident = input.parse()?;
2031            let _: Token![=] = input.parse()?;
2032            match key.to_string().as_str() {
2033                "threshold" => {
2034                    let n: LitInt = input.parse()?;
2035                    threshold = Some(n.base10_parse()?);
2036                }
2037                "cooldown" => {
2038                    let s: LitStr = input.parse()?;
2039                    cooldown_millis = Some(parse_duration_ms(&s)?);
2040                }
2041                other => {
2042                    return Err(syn::Error::new(
2043                        key.span(),
2044                        format!("unknown circuit_breaker key `{other}`"),
2045                    ))
2046                }
2047            }
2048            let _ = input.parse::<Token![,]>();
2049        }
2050        Ok(Self {
2051            threshold: threshold
2052                .ok_or_else(|| syn::Error::new(input.span(), "missing `threshold = N`"))?,
2053            cooldown_millis: cooldown_millis
2054                .ok_or_else(|| syn::Error::new(input.span(), "missing `cooldown = \"…\"`"))?,
2055        })
2056    }
2057}
2058
2059fn parse_duration_ms(s: &LitStr) -> syn::Result<u64> {
2060    let raw = s.value();
2061    let r = raw.trim();
2062    let (num_s, unit) = match r.rfind(|c: char| c.is_ascii_digit()) {
2063        Some(i) => (&r[..=i], &r[i + 1..]),
2064        None => return Err(syn::Error::new(s.span(), "invalid duration")),
2065    };
2066    let n: u64 = num_s
2067        .parse()
2068        .map_err(|_| syn::Error::new(s.span(), "invalid duration number"))?;
2069    let mult = match unit.trim() {
2070        "ms" => 1,
2071        "s" | "" => 1_000,
2072        "m" => 60_000,
2073        "h" => 3_600_000,
2074        other => {
2075            return Err(syn::Error::new(
2076                s.span(),
2077                format!("unknown duration unit `{other}`"),
2078            ))
2079        }
2080    };
2081    Ok(n * mult)
2082}
2083
2084#[proc_macro_attribute]
2085pub fn circuit_breaker(attr: TokenStream, item: TokenStream) -> TokenStream {
2086    let args = parse_macro_input!(attr as BreakerArgs);
2087    let mut f = parse_macro_input!(item as syn::ImplItemFn);
2088
2089    let threshold = args.threshold;
2090    let cooldown_ms = args.cooldown_millis;
2091
2092    let breaker_name = format_ident!("__BREAKER_{}", f.sig.ident.to_string().to_uppercase());
2093
2094    // Replace the fn body with a breaker-wrapped version.
2095    let original_body = f.block.clone();
2096    let new_block: Block = parse_quote! {{
2097        static #breaker_name: ::arcly_http::__macro_support::CircuitBreaker =
2098            ::arcly_http::__macro_support::CircuitBreaker::const_new(#threshold, #cooldown_ms);
2099        match #breaker_name.execute(|| async move #original_body).await {
2100            ::core::result::Result::Ok(inner) => inner,
2101            ::core::result::Result::Err(_open) => ::core::result::Result::Err(
2102                <_ as ::core::convert::From<::arcly_http::__macro_support::BreakerOpen>>::from(_open),
2103            ),
2104        }
2105    }};
2106    f.block = new_block;
2107
2108    quote! { #f }.into()
2109}