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