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