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// ════════════════════════════════════════════════════════════════════════
993//  Method-on-impl route registration (shared builder)
994// ════════════════════════════════════════════════════════════════════════
995#[allow(clippy::too_many_arguments)]
996fn build_method_route_registration(
997    self_ty: &Type,
998    m: &syn::ImplItemFn,
999    method: &'static str,
1000    full_path: String,
1001    args: &RouteArgs,
1002    tags: &[LitStr],
1003    interceptors: &[Path],
1004    cache_ttl_secs: u64,
1005    cache_key: &str,
1006    controller_name: &str,
1007    audit: &Option<(String, String)>,
1008    timeout_ms: Option<u64>,
1009    api_version: &str,
1010    sunset: &str,
1011    transactional: bool,
1012    idempotent_ttl: Option<u64>,
1013    policies: &[LitStr],
1014    mask_fields: &[LitStr],
1015    multipart: Option<&[MultipartField]>,
1016) -> syn::Result<TokenStream2> {
1017    let fn_name = m.sig.ident.clone();
1018    // Include the controller name in all generated idents so that two controllers
1019    // with identically-named methods (e.g. both have `fn ping`) never collide.
1020    let (thunk_name, desc_name, spec_name) = route_idents(controller_name, &fn_name.to_string());
1021    let method_ident = Ident::new(method, Span::call_site());
1022
1023    let doc = collect_doc_comments(&m.attrs);
1024
1025    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1026    let mut call_args: Vec<TokenStream2> = Vec::new();
1027    let mut spec_params: Vec<TokenStream2> = Vec::new();
1028    let mut has_body = false;
1029    let mut body_ty: Option<Type> = None;
1030    let mut query_ty: Option<Type> = None;
1031
1032    for (i, input) in m.sig.inputs.iter().enumerate() {
1033        let FnArg::Typed(pt) = input else {
1034            return Err(syn::Error::new(
1035                input.span(),
1036                "controller methods must not take `self` — use Inject<T> fields instead",
1037            ));
1038        };
1039        let var = format_ident!("__arg_{i}");
1040        let (kind, ty) = classify_arg(pt)?;
1041        let stmt = emit_extractor(
1042            &kind,
1043            &ty,
1044            &var,
1045            &mut spec_params,
1046            &mut has_body,
1047            &mut body_ty,
1048            &mut query_ty,
1049        );
1050        extract_stmts.push(stmt);
1051        call_args.push(quote! { #var });
1052    }
1053
1054    let mut guard_stmts: Vec<TokenStream2> = args
1055        .guards
1056        .iter()
1057        .map(|g| {
1058            quote! {
1059                <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
1060            }
1061        })
1062        .collect();
1063
1064    // #[RequirePolicies] — ABAC route gate, evaluated with the other guards
1065    // (before extraction/transaction). Resource attributes are Null here;
1066    // handlers re-check with attributes via policy::check_policies.
1067    if !policies.is_empty() {
1068        let action_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
1069        guard_stmts.push(quote! {
1070            ::arcly_http::__macro_support::check_policies(
1071                &ctx, &[ #( #action_lits ),* ], ::arcly_http::serde_json::Value::Null,
1072            )?;
1073        });
1074    }
1075
1076    // #[Transactional]: begin on the tenant pool → run guards/extraction/
1077    // handler → commit on Ok / rollback on Err. Innermost layer: the driver
1078    // tx wraps exactly the handler's own Result, and a #[Timeout] above it
1079    // cancels the future → tx drops uncommitted → driver rollback.
1080    let run_body = if transactional {
1081        // Same outer shape as the plain path — Ok(handler_result) — so the
1082        // shared response-conversion match below applies unchanged. Guard and
1083        // extractor `?` convert Error → HttpException via the HttpError
1084        // blanket, and run_transactional commits/rolls back on that Result.
1085        quote! {
1086            let __tx_ctx = ctx.clone();
1087            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1088                ::arcly_http::__macro_support::run_transactional(&__tx_ctx, async move {
1089                    #( #guard_stmts )*
1090                    #( #extract_stmts )*
1091                    <#self_ty>::#fn_name( #( #call_args ),* ).await
1092                }).await
1093            )
1094        }
1095    } else {
1096        quote! {
1097            #( #guard_stmts )*
1098            #( #extract_stmts )*
1099            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1100                <#self_ty>::#fn_name( #( #call_args ),* ).await
1101            )
1102        }
1103    };
1104
1105    let inner = quote! {
1106        let __run = async move {
1107            #run_body
1108        };
1109        match __run.await {
1110            ::core::result::Result::Ok(v)  => {
1111                ::arcly_http::__axum::response::IntoResponse::into_response(v)
1112            }
1113            ::core::result::Result::Err(e) => {
1114                ::arcly_http::__axum::response::IntoResponse::into_response(e)
1115            }
1116        }
1117    };
1118
1119    // #[Timeout] — enforce a route deadline; the dropped future releases the
1120    // worker immediately and the client gets a 504 ProblemDetails. Sits inside
1121    // the audit wrapper so a timeout is recorded as outcome=Error (status 504).
1122    let inner = match timeout_ms {
1123        Some(ms) => {
1124            let route_lit = LitStr::new(&full_path, Span::call_site());
1125            quote! {
1126                ::arcly_http::__macro_support::run_with_timeout(
1127                    #ms, #route_lit, async move { #inner },
1128                ).await
1129            }
1130        }
1131        None => inner,
1132    };
1133
1134    // #[AuditLog] — clone the context up front (handler consumes it), then
1135    // emit one audit record keyed on the final response status. No pipeline
1136    // in the DI container → emit_route_audit is a no-op.
1137    let inner = match audit {
1138        Some((action, resource)) => {
1139            let action_lit = LitStr::new(action, Span::call_site());
1140            let resource_lit = LitStr::new(resource, Span::call_site());
1141            quote! {
1142                let __audit_ctx = ctx.clone();
1143                let __resp: ::arcly_http::__axum::response::Response = { #inner };
1144                ::arcly_http::__macro_support::emit_route_audit(
1145                    &__audit_ctx, #action_lit, #resource_lit, __resp.status().as_u16(),
1146                );
1147                __resp
1148            }
1149        }
1150        None => inner,
1151    };
1152
1153    // #[MaskFields] — redact the JSON response before any outer layer can
1154    // persist it (the idempotency replay cache stores masked bodies only).
1155    let inner = if mask_fields.is_empty() {
1156        inner
1157    } else {
1158        let mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1159        quote! {
1160            let __mask_ctx = ctx.clone();
1161            let __resp: ::arcly_http::__axum::response::Response = { #inner };
1162            ::arcly_http::__macro_support::mask_response(
1163                &__mask_ctx, &[ #( #mask_lits ),* ], __resp,
1164            ).await
1165        }
1166    };
1167
1168    // #[Idempotent] — outermost: replays skip guards/tx/audit entirely and
1169    // return the stored response; concurrent duplicates get 409.
1170    let inner = match idempotent_ttl {
1171        Some(ttl) => {
1172            let route_lit = LitStr::new(&full_path, Span::call_site());
1173            quote! {
1174                let __idem_ctx = ctx.clone();
1175                ::arcly_http::__macro_support::run_idempotent(
1176                    &__idem_ctx, #ttl, #route_lit, async move { #inner },
1177                ).await
1178            }
1179        }
1180        None => inner,
1181    };
1182
1183    let thunk_body = wrap_interceptors(inner, interceptors);
1184
1185    let fn_str = fn_name.to_string();
1186    let summary_str = args
1187        .summary
1188        .as_ref()
1189        .map(|s| s.value())
1190        .unwrap_or_else(|| fn_str.clone());
1191    let operation_id = args
1192        .operation_id
1193        .as_ref()
1194        .map(|s| s.value())
1195        .unwrap_or_else(|| fn_str.clone());
1196    let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1197    let deprecated = args.deprecated;
1198    let tag_lits: Vec<TokenStream2> = tags.iter().map(|t| quote!(#t)).collect();
1199    let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1200    let status_expr = match &args.status {
1201        Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1202        None => quote! { ::core::option::Option::None },
1203    };
1204    // `#[Multipart]` overrides the body media type and schema. The handler
1205    // still reads the body via `MultipartForm::from_ctx`; this attribute is the
1206    // OpenAPI mirror that makes the upload form appear in Swagger UI.
1207    let (consumes_lit, body_schema_expr, has_body) = match multipart {
1208        Some(fields) => (
1209            LitStr::new("multipart/form-data", Span::call_site()),
1210            multipart_schema_expr(fields),
1211            true,
1212        ),
1213        None => (
1214            LitStr::new("application/json", Span::call_site()),
1215            schema_expr(&body_ty),
1216            has_body,
1217        ),
1218    };
1219    let query_schema_expr = schema_expr(&query_ty);
1220    let response_schema_expr = schema_expr(&extract_response_ty(&m.sig.output));
1221    let full_path_lit = LitStr::new(&full_path, Span::call_site());
1222
1223    // OpenAPI metadata mirrors of the hardening attributes.
1224    let spec_idem_ttl = idempotent_ttl.unwrap_or(0);
1225    let spec_policy_lits: Vec<TokenStream2> = policies.iter().map(|p| quote!(#p)).collect();
1226    let (spec_audit_action, spec_audit_resource) = match audit {
1227        Some((a, r)) => (a.clone(), r.clone()),
1228        None => (String::new(), String::new()),
1229    };
1230    let spec_timeout_ms = timeout_ms.unwrap_or(0);
1231    let spec_mask_lits: Vec<TokenStream2> = mask_fields.iter().map(|f| quote!(#f)).collect();
1232
1233    Ok(quote! {
1234        fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1235            -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1236        {
1237            ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1238        }
1239
1240        #[allow(non_upper_case_globals)]
1241        static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1242            ::arcly_http::__macro_support::RouteSpec {
1243                summary: #summary_str,
1244                description: #description_str,
1245                operation_id: #operation_id,
1246                tags: &[ #( #tag_lits ),* ],
1247                security: &[ #( #sec_lits ),* ],
1248                status_code: #status_expr,
1249                deprecated: #deprecated,
1250                params: &[ #( #spec_params ),* ],
1251                has_body: #has_body,
1252                body_schema: #body_schema_expr,
1253                consumes: #consumes_lit,
1254                query_schema: #query_schema_expr,
1255                response_schema: #response_schema_expr,
1256                cache_ttl_secs: #cache_ttl_secs,
1257                cache_key: #cache_key,
1258                api_version: #api_version,
1259                sunset: #sunset,
1260                idempotent_ttl_secs: #spec_idem_ttl,
1261                policies: &[ #( #spec_policy_lits ),* ],
1262                audit_action: #spec_audit_action,
1263                audit_resource: #spec_audit_resource,
1264                timeout_ms: #spec_timeout_ms,
1265                transactional: #transactional,
1266                mask_fields: &[ #( #spec_mask_lits ),* ],
1267            };
1268
1269        #[allow(non_upper_case_globals)]
1270        static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1271            ::arcly_http::__macro_support::RouteDescriptor {
1272                method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1273                path: #full_path_lit,
1274                handler: #thunk_name,
1275                spec: &#spec_name,
1276                controller: #controller_name,
1277            };
1278
1279        ::arcly_http::inventory::submit! {
1280            &#desc_name
1281        }
1282    })
1283}
1284
1285fn schema_expr(ty: &Option<Type>) -> TokenStream2 {
1286    match ty {
1287        Some(t) => quote! { ::core::option::Option::Some(|| ::arcly_http::__schema_for::<#t>()) },
1288        None => quote! { ::core::option::Option::None },
1289    }
1290}
1291
1292/// Compose interceptors around `inner` (which yields a Response).
1293///
1294/// Three monomorphic shapes, picked at macro time based on the chain length:
1295///
1296/// * **N = 0** — no interceptors. Inline the handler body verbatim. Zero
1297///   extra Box, zero extra `await`.
1298/// * **N = 1** — fast path. Build one `NextHandler` for the handler body,
1299///   call the single interceptor's `around()` directly. Saves the outer
1300///   `NextHandler::new` + one `Box::pin` versus the general chain.
1301/// * **N ≥ 2** — general chain. Each layer wraps the next in a
1302///   `NextHandler`; the outermost is invoked via `.run(ctx).await`.
1303fn wrap_interceptors(inner: TokenStream2, interceptors: &[Path]) -> TokenStream2 {
1304    match interceptors.len() {
1305        0 => return inner,
1306        1 => {
1307            let icp = &interceptors[0];
1308            return quote! {
1309                {
1310                    static __ICP: #icp = #icp;
1311                    let __inner = ::arcly_http::__macro_support::NextHandler::new(
1312                        move |ctx: ::arcly_http::__macro_support::RequestContext|
1313                            ::arcly_http::futures::FutureExt::boxed(async move {
1314                                let ctx = ctx;
1315                                #inner
1316                            })
1317                    );
1318                    <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner).await
1319                }
1320            };
1321        }
1322        _ => {}
1323    }
1324
1325    // General N-layer chain.
1326    let mut current = quote! {
1327        ::arcly_http::__macro_support::NextHandler::new(
1328            move |ctx: ::arcly_http::__macro_support::RequestContext|
1329                ::arcly_http::futures::FutureExt::boxed(async move {
1330                    let ctx = ctx;
1331                    #inner
1332                })
1333        )
1334    };
1335    for icp in interceptors.iter().rev() {
1336        current = quote! {
1337            {
1338                static __ICP: #icp = #icp;
1339                let __inner = #current;
1340                ::arcly_http::__macro_support::NextHandler::new(
1341                    move |ctx: ::arcly_http::__macro_support::RequestContext| {
1342                        <#icp as ::arcly_http::__macro_support::Interceptor>::around(&__ICP, ctx, __inner.__clone_for_chain())
1343                    },
1344                )
1345            }
1346        };
1347    }
1348    quote! { #current.run(ctx).await }
1349}
1350
1351// ════════════════════════════════════════════════════════════════════════
1352//  Standalone #[Get/Post/…] free-fn macros
1353// ════════════════════════════════════════════════════════════════════════
1354
1355/// `#[Get("/path")]` — map a method (or free function) to an HTTP `GET` route.
1356#[proc_macro_attribute]
1357#[allow(non_snake_case)]
1358pub fn Get(a: TokenStream, i: TokenStream) -> TokenStream {
1359    route_free_fn(a, i, "GET")
1360}
1361/// `#[Post("/path")]` — map a method (or free function) to an HTTP `POST` route.
1362#[proc_macro_attribute]
1363#[allow(non_snake_case)]
1364pub fn Post(a: TokenStream, i: TokenStream) -> TokenStream {
1365    route_free_fn(a, i, "POST")
1366}
1367/// `#[Put("/path")]` — map a method (or free function) to an HTTP `PUT` route.
1368#[proc_macro_attribute]
1369#[allow(non_snake_case)]
1370pub fn Put(a: TokenStream, i: TokenStream) -> TokenStream {
1371    route_free_fn(a, i, "PUT")
1372}
1373/// `#[Delete("/path")]` — map a method (or free function) to an HTTP `DELETE` route.
1374#[proc_macro_attribute]
1375#[allow(non_snake_case)]
1376pub fn Delete(a: TokenStream, i: TokenStream) -> TokenStream {
1377    route_free_fn(a, i, "DELETE")
1378}
1379/// `#[Patch("/path")]` — map a method (or free function) to an HTTP `PATCH` route.
1380#[proc_macro_attribute]
1381#[allow(non_snake_case)]
1382pub fn Patch(a: TokenStream, i: TokenStream) -> TokenStream {
1383    route_free_fn(a, i, "PATCH")
1384}
1385
1386/// `#[CacheTTL(N)]` — TTL in seconds. Marker attribute consumed by the
1387/// route macro and stuffed into `RouteSpec.cache_ttl_secs`. Pass-through
1388/// here so the type system accepts it standalone.
1389#[proc_macro_attribute]
1390#[allow(non_snake_case)]
1391pub fn CacheTTL(_attr: TokenStream, item: TokenStream) -> TokenStream {
1392    item
1393}
1394
1395/// `#[AuditLog(action = "…", resource = "…")]` — emit one compliance audit
1396/// record per invocation, keyed on the response status. Consumed by
1397/// `#[Controller]`; pass-through marker on free fns.
1398#[proc_macro_attribute]
1399#[allow(non_snake_case)]
1400pub fn AuditLog(_attr: TokenStream, item: TokenStream) -> TokenStream {
1401    item
1402}
1403
1404/// `#[Timeout("2s")]` — route deadline; 504 + future cancellation on expiry.
1405/// Consumed by `#[Controller]`; pass-through marker on free fns.
1406#[proc_macro_attribute]
1407#[allow(non_snake_case)]
1408pub fn Timeout(_attr: TokenStream, item: TokenStream) -> TokenStream {
1409    item
1410}
1411
1412/// `#[Version("v1")]` on a `#[Controller]` impl — mounts every route under
1413/// `/v1/...` and records the version in each RouteSpec. Marker; consumed by
1414/// `#[Controller]`.
1415#[proc_macro_attribute]
1416#[allow(non_snake_case)]
1417pub fn Version(_attr: TokenStream, item: TokenStream) -> TokenStream {
1418    item
1419}
1420
1421/// `#[Deprecated(sunset = "YYYY-MM-DD")]` on a `#[Controller]` impl — adds
1422/// RFC 8594 `Deprecation`/`Sunset` headers to every response from this
1423/// controller. Marker; consumed by `#[Controller]`.
1424#[proc_macro_attribute]
1425#[allow(non_snake_case)]
1426pub fn Deprecated(_attr: TokenStream, item: TokenStream) -> TokenStream {
1427    item
1428}
1429
1430/// `#[Transactional]` — wrap the handler in a database transaction on the
1431/// request-tenant's pool: commit on `Ok`, rollback on `Err`/cancellation.
1432/// Consumed by `#[Controller]`; pass-through marker on free fns.
1433#[proc_macro_attribute]
1434#[allow(non_snake_case)]
1435pub fn Transactional(_attr: TokenStream, item: TokenStream) -> TokenStream {
1436    item
1437}
1438
1439/// `#[Idempotent(ttl = "24h")]` — Stripe-style Idempotency-Key handling:
1440/// claim → run → store; retries replay the stored response; concurrent
1441/// duplicates get 409. Consumed by `#[Controller]`.
1442#[proc_macro_attribute]
1443#[allow(non_snake_case)]
1444pub fn Idempotent(_attr: TokenStream, item: TokenStream) -> TokenStream {
1445    item
1446}
1447
1448/// `#[RequirePolicies("orders.refund", …)]` — ABAC route gate: every listed
1449/// action must Permit under the hot-reloadable PolicyEngine (default-deny).
1450/// Consumed by `#[Controller]`.
1451#[proc_macro_attribute]
1452#[allow(non_snake_case)]
1453pub fn RequirePolicies(_attr: TokenStream, item: TokenStream) -> TokenStream {
1454    item
1455}
1456
1457/// `#[EventPattern("topic")]` — marks a method inside an `#[EventConsumer]`
1458/// impl as the handler for one topic. Marker; consumed by `#[EventConsumer]`.
1459#[proc_macro_attribute]
1460#[allow(non_snake_case)]
1461pub fn EventPattern(_attr: TokenStream, item: TokenStream) -> TokenStream {
1462    item
1463}
1464
1465/// `#[EventConsumer]` on an impl block — registers every `#[EventPattern]`
1466/// method into the link-time event registry (the messaging analogue of
1467/// `#[Controller]`). Methods take `EventContext` and return
1468/// `Result<(), String>`.
1469#[proc_macro_attribute]
1470#[allow(non_snake_case)]
1471pub fn EventConsumer(_attr: TokenStream, item: TokenStream) -> TokenStream {
1472    let mut imp = parse_macro_input!(item as ItemImpl);
1473    let self_ty = (*imp.self_ty).clone();
1474    let consumer_name = match &self_ty {
1475        Type::Path(tp) => tp
1476            .path
1477            .segments
1478            .last()
1479            .map(|s| s.ident.to_string())
1480            .unwrap_or_default(),
1481        _ => String::new(),
1482    };
1483
1484    let mut registrations: Vec<TokenStream2> = Vec::new();
1485    let mut errors: Vec<syn::Error> = Vec::new();
1486
1487    for item in imp.items.iter_mut() {
1488        let ImplItem::Fn(m) = item else { continue };
1489        let mut topic: Option<LitStr> = None;
1490        let mut keep: Vec<Attribute> = Vec::with_capacity(m.attrs.len());
1491        for a in m.attrs.drain(..) {
1492            let id = a
1493                .path()
1494                .get_ident()
1495                .map(|i| i.to_string())
1496                .unwrap_or_default();
1497            if id == "EventPattern" {
1498                match a.parse_args::<LitStr>() {
1499                    Ok(t) => topic = Some(t),
1500                    Err(e) => errors.push(e),
1501                }
1502            } else {
1503                keep.push(a);
1504            }
1505        }
1506        m.attrs = keep;
1507        let Some(topic) = topic else { continue };
1508
1509        let fn_name = m.sig.ident.clone();
1510        let thunk = format_ident!("__arcly_event_{}_{}", consumer_name, fn_name);
1511        let desc = format_ident!("__ARCLY_EVENT_DESC_{}_{}", consumer_name, fn_name);
1512        let consumer_lit = LitStr::new(&consumer_name, Span::call_site());
1513
1514        registrations.push(quote! {
1515            #[allow(non_snake_case)]
1516            fn #thunk(ctx: ::arcly_http::__macro_support::EventContext)
1517                -> ::arcly_http::futures::future::BoxFuture<'static, ::core::result::Result<(), ::arcly_http::__macro_support::EventError>>
1518            {
1519                // `Into::into` accepts both `Result<(), String>` (→ Retry)
1520                // and `Result<(), EventError>` (identity) handler signatures.
1521                ::arcly_http::futures::FutureExt::boxed(async move {
1522                    <#self_ty>::#fn_name(ctx).await.map_err(::core::convert::Into::into)
1523                })
1524            }
1525
1526            #[allow(non_upper_case_globals)]
1527            static #desc: ::arcly_http::__macro_support::EventHandlerDescriptor =
1528                ::arcly_http::__macro_support::EventHandlerDescriptor {
1529                    topic:    #topic,
1530                    consumer: #consumer_lit,
1531                    handler:  #thunk,
1532                };
1533
1534            ::arcly_http::inventory::submit! { &#desc }
1535        });
1536    }
1537
1538    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1539        a.combine(b);
1540        a
1541    }) {
1542        return err.to_compile_error().into();
1543    }
1544
1545    quote! {
1546        #imp
1547        #( #registrations )*
1548    }
1549    .into()
1550}
1551
1552/// `#[MaskFields("email", "card:last4")]` — redact these JSON response
1553/// fields (plus the global Masker rules) before any durable layer sees the
1554/// body. Consumed by `#[Controller]`.
1555#[proc_macro_attribute]
1556#[allow(non_snake_case)]
1557pub fn MaskFields(_attr: TokenStream, item: TokenStream) -> TokenStream {
1558    item
1559}
1560
1561/// `#[Multipart(file("avatar"), text("alt"))]` — declare that a handler
1562/// consumes a `multipart/form-data` body, for the OpenAPI surface.
1563///
1564/// `file(..)` parts render as `string`/`binary` (a file picker in Swagger UI)
1565/// and are marked required; `text(..)` parts render as `string`. The handler
1566/// still reads the body with `MultipartForm::from_ctx`; this attribute is the
1567/// documentation mirror. Marker attribute; consumed by `#[Controller]`.
1568#[proc_macro_attribute]
1569#[allow(non_snake_case)]
1570pub fn Multipart(_attr: TokenStream, item: TokenStream) -> TokenStream {
1571    item
1572}
1573
1574/// `#[CacheKey("template")]` — custom key. Marker attribute; see `CacheTTL`.
1575#[proc_macro_attribute]
1576#[allow(non_snake_case)]
1577pub fn CacheKey(_attr: TokenStream, item: TokenStream) -> TokenStream {
1578    item
1579}
1580
1581/// `#[UseInterceptors(A, B)]` on a free fn — wraps that handler's thunk.
1582#[proc_macro_attribute]
1583#[allow(non_snake_case)]
1584pub fn UseInterceptors(_attr: TokenStream, item: TokenStream) -> TokenStream {
1585    // When used on an impl-method, Controller has already consumed this
1586    // attribute. When used on a free fn, the route macro above sits *below*
1587    // it (outer attrs run later), and our Get/Post/... macros look for a
1588    // sibling `#[UseInterceptors]` on the same fn — so here we just pass
1589    // through.
1590    item
1591}
1592
1593fn route_free_fn(attr: TokenStream, item: TokenStream, method: &'static str) -> TokenStream {
1594    let args = parse_macro_input!(attr as RouteArgs);
1595    let mut f = parse_macro_input!(item as ItemFn);
1596
1597    // Pluck any sibling #[UseInterceptors(...)] attributes from the fn so they
1598    // contribute to the chain. (The companion macro above is a pass-through.)
1599    let mut interceptors: Vec<Path> = Vec::new();
1600    let mut keep_attrs: Vec<Attribute> = Vec::with_capacity(f.attrs.len());
1601    for a in f.attrs.drain(..) {
1602        let id = a
1603            .path()
1604            .get_ident()
1605            .map(|i| i.to_string())
1606            .unwrap_or_default();
1607        if id == "UseInterceptors" {
1608            match a.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated) {
1609                Ok(list) => interceptors.extend(list),
1610                Err(e) => return e.to_compile_error().into(),
1611            }
1612        } else {
1613            keep_attrs.push(a);
1614        }
1615    }
1616    f.attrs = keep_attrs;
1617
1618    let (cache_ttl_secs, cache_key): (u64, String) = match harvest_cache_attrs(&mut f.attrs) {
1619        Ok(p) => p,
1620        Err(e) => return e.to_compile_error().into(),
1621    };
1622
1623    let path_lit = args.path.clone();
1624    let full_path = path_lit.value();
1625
1626    let fn_name = f.sig.ident.clone();
1627    // Free-fn routes have no controller — use an empty prefix so idents remain
1628    // unique across multiple free-fn routes with different function names.
1629    let (thunk_name, desc_name, spec_name) = route_idents("", &fn_name.to_string());
1630    let method_ident = Ident::new(method, Span::call_site());
1631
1632    let doc = collect_doc_comments(&f.attrs);
1633
1634    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
1635    let mut call_args: Vec<TokenStream2> = Vec::new();
1636    let mut errors: Vec<syn::Error> = Vec::new();
1637    let mut spec_params: Vec<TokenStream2> = Vec::new();
1638    let mut has_body = false;
1639    let mut body_ty: Option<Type> = None;
1640    let mut query_ty: Option<Type> = None;
1641
1642    for (i, input) in f.sig.inputs.iter_mut().enumerate() {
1643        let FnArg::Typed(pt) = input else {
1644            errors.push(syn::Error::new(input.span(), "handler must not take self"));
1645            continue;
1646        };
1647        let var = format_ident!("__arg_{i}");
1648        match classify_arg(pt) {
1649            Ok((kind, ty)) => {
1650                let stmt = emit_extractor(
1651                    &kind,
1652                    &ty,
1653                    &var,
1654                    &mut spec_params,
1655                    &mut has_body,
1656                    &mut body_ty,
1657                    &mut query_ty,
1658                );
1659                extract_stmts.push(stmt);
1660                call_args.push(quote! { #var });
1661            }
1662            Err(e) => errors.push(e),
1663        }
1664        pt.attrs.retain(|a| {
1665            let id = a
1666                .path()
1667                .get_ident()
1668                .map(|i| i.to_string())
1669                .unwrap_or_default();
1670            !matches!(id.as_str(), "Param" | "Query" | "Body" | "Header")
1671        });
1672    }
1673
1674    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
1675        a.combine(b);
1676        a
1677    }) {
1678        return err.to_compile_error().into();
1679    }
1680
1681    let guard_stmts: Vec<TokenStream2> = args
1682        .guards
1683        .iter()
1684        .map(|g| {
1685            quote! {
1686                <_ as ::arcly_http::__macro_support::Guard>::check(&#g, &ctx)?;
1687            }
1688        })
1689        .collect();
1690
1691    let inner = quote! {
1692        let __run = async move {
1693            #( #guard_stmts )*
1694            #( #extract_stmts )*
1695            ::core::result::Result::<_, ::arcly_http::__macro_support::Error>::Ok(
1696                #fn_name( #( #call_args ),* ).await
1697            )
1698        };
1699        match __run.await {
1700            ::core::result::Result::Ok(v)  => {
1701                ::arcly_http::__axum::response::IntoResponse::into_response(v)
1702            }
1703            ::core::result::Result::Err(e) => {
1704                ::arcly_http::__axum::response::IntoResponse::into_response(e)
1705            }
1706        }
1707    };
1708
1709    let thunk_body = wrap_interceptors(inner, &interceptors);
1710
1711    let fn_str = fn_name.to_string();
1712    let summary_str = args
1713        .summary
1714        .as_ref()
1715        .map(|s| s.value())
1716        .unwrap_or_else(|| fn_str.clone());
1717    let operation_id = args
1718        .operation_id
1719        .as_ref()
1720        .map(|s| s.value())
1721        .unwrap_or_else(|| fn_str.clone());
1722    let description_str = args.description.as_ref().map(|s| s.value()).unwrap_or(doc);
1723    let deprecated = args.deprecated;
1724    let tag_lits: Vec<TokenStream2> = args.tags.iter().map(|t| quote!(#t)).collect();
1725    let sec_lits: Vec<TokenStream2> = args.security.iter().map(|s| quote!(#s)).collect();
1726    let status_expr = match &args.status {
1727        Some(n) => quote! { ::core::option::Option::Some(#n as u16) },
1728        None => quote! { ::core::option::Option::None },
1729    };
1730    let body_schema_expr = schema_expr(&body_ty);
1731    // Free-fn routes always declare a JSON body; `#[Multipart]` is a
1732    // controller-method attribute.
1733    let consumes_lit = LitStr::new("application/json", Span::call_site());
1734    let query_schema_expr = schema_expr(&query_ty);
1735    let response_schema_expr = schema_expr(&extract_response_ty(&f.sig.output));
1736    let full_path_lit = LitStr::new(&full_path, Span::call_site());
1737
1738    quote! {
1739        #f
1740
1741        fn #thunk_name(ctx: ::arcly_http::__macro_support::RequestContext)
1742            -> ::arcly_http::futures::future::BoxFuture<'static, ::arcly_http::__axum::response::Response>
1743        {
1744            ::arcly_http::futures::FutureExt::boxed(async move { #thunk_body })
1745        }
1746
1747        #[allow(non_upper_case_globals)]
1748        static #spec_name: ::arcly_http::__macro_support::RouteSpec =
1749            ::arcly_http::__macro_support::RouteSpec {
1750                summary: #summary_str,
1751                description: #description_str,
1752                operation_id: #operation_id,
1753                tags: &[ #( #tag_lits ),* ],
1754                security: &[ #( #sec_lits ),* ],
1755                status_code: #status_expr,
1756                deprecated: #deprecated,
1757                params: &[ #( #spec_params ),* ],
1758                has_body: #has_body,
1759                body_schema: #body_schema_expr,
1760                consumes: #consumes_lit,
1761                query_schema: #query_schema_expr,
1762                response_schema: #response_schema_expr,
1763                cache_ttl_secs: #cache_ttl_secs,
1764                cache_key: #cache_key,
1765                api_version: "",
1766                sunset: "",
1767                idempotent_ttl_secs: 0,
1768                policies: &[],
1769                audit_action: "",
1770                audit_resource: "",
1771                timeout_ms: 0,
1772                transactional: false,
1773                mask_fields: &[],
1774            };
1775
1776        #[allow(non_upper_case_globals)]
1777        static #desc_name: ::arcly_http::__macro_support::RouteDescriptor =
1778            ::arcly_http::__macro_support::RouteDescriptor {
1779                method: ::arcly_http::__macro_support::HttpMethod::#method_ident,
1780                path: #full_path_lit,
1781                handler: #thunk_name,
1782                spec: &#spec_name,
1783                controller: "",
1784            };
1785
1786        ::arcly_http::inventory::submit! {
1787            &#desc_name
1788        }
1789    }
1790    .into()
1791}
1792
1793// ════════════════════════════════════════════════════════════════════════
1794//  Param classification + extractor emission (shared)
1795// ════════════════════════════════════════════════════════════════════════
1796enum ParamKind {
1797    Param(LitStr),
1798    Query,
1799    Body,
1800    Header(LitStr),
1801    Ctx,
1802    FromContext,
1803}
1804
1805fn classify_arg(arg: &PatType) -> syn::Result<(ParamKind, Type)> {
1806    for attr in &arg.attrs {
1807        let ident = attr
1808            .path()
1809            .get_ident()
1810            .map(|i| i.to_string())
1811            .unwrap_or_default();
1812        match ident.as_str() {
1813            "Param" => {
1814                let name: LitStr = attr.parse_args()?;
1815                return Ok((ParamKind::Param(name), (*arg.ty).clone()));
1816            }
1817            "Query" => return Ok((ParamKind::Query, (*arg.ty).clone())),
1818            "Body" => return Ok((ParamKind::Body, (*arg.ty).clone())),
1819            "Header" => {
1820                let name: LitStr = attr.parse_args()?;
1821                return Ok((ParamKind::Header(name), (*arg.ty).clone()));
1822            }
1823            _ => {}
1824        }
1825    }
1826    let ty_ref = &*arg.ty;
1827    let ty_str = quote!(#ty_ref).to_string();
1828    let ty = (*arg.ty).clone();
1829    let kind = if ty_str.contains("RequestContext") {
1830        ParamKind::Ctx
1831    } else {
1832        ParamKind::FromContext
1833    };
1834    Ok((kind, ty))
1835}
1836
1837fn emit_extractor(
1838    kind: &ParamKind,
1839    ty: &Type,
1840    var: &Ident,
1841    spec_params: &mut Vec<TokenStream2>,
1842    has_body: &mut bool,
1843    body_ty: &mut Option<Type>,
1844    query_ty: &mut Option<Type>,
1845) -> TokenStream2 {
1846    match kind {
1847        ParamKind::Param(name) => {
1848            spec_params.push(quote! {
1849                ::arcly_http::__macro_support::ParamSpec {
1850                    name: #name,
1851                    loc: ::arcly_http::__macro_support::ParamLoc::Path,
1852                    required: true,
1853                    schema: || ::arcly_http::__schema_for::<#ty>(),
1854                }
1855            });
1856            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_param(&ctx, #name)?; }
1857        }
1858        ParamKind::Query => {
1859            *query_ty = Some(ty.clone());
1860            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_query_validated(&ctx)?; }
1861        }
1862        ParamKind::Body => {
1863            *has_body = true;
1864            *body_ty = Some(ty.clone());
1865            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_body_validated(&ctx)?; }
1866        }
1867        ParamKind::Header(name) => {
1868            spec_params.push(quote! {
1869                ::arcly_http::__macro_support::ParamSpec {
1870                    name: #name,
1871                    loc: ::arcly_http::__macro_support::ParamLoc::Header,
1872                    required: true,
1873                    schema: || ::arcly_http::__schema_for::<#ty>(),
1874                }
1875            });
1876            quote! { let #var: #ty = ::arcly_http::__macro_support::extract_header(&ctx, #name)?.to_owned(); }
1877        }
1878        ParamKind::Ctx => quote! { let #var: #ty = ctx.clone(); },
1879        ParamKind::FromContext => quote! { let #var: #ty = <#ty>::from_ctx(&ctx); },
1880    }
1881}
1882
1883// ════════════════════════════════════════════════════════════════════════
1884//  Return-type walker — Json<T> / Result<X,_> / Created<T> / NoContent / Accepted<T>
1885// ════════════════════════════════════════════════════════════════════════
1886fn extract_response_ty(ret: &ReturnType) -> Option<Type> {
1887    let ty = match ret {
1888        ReturnType::Default => return None,
1889        ReturnType::Type(_, ty) => &**ty,
1890    };
1891    inner_payload_ty(ty).cloned()
1892}
1893
1894fn inner_payload_ty(ty: &Type) -> Option<&Type> {
1895    let Type::Path(tp) = ty else { return None };
1896    let seg = tp.path.segments.last()?;
1897    match seg.ident.to_string().as_str() {
1898        "Json" | "Created" | "Accepted" => first_generic(&seg.arguments),
1899        "Result" => {
1900            let ok = first_generic(&seg.arguments)?;
1901            inner_payload_ty(ok)
1902        }
1903        "NoContent" => None,
1904        _ => None,
1905    }
1906}
1907
1908fn first_generic(args: &PathArguments) -> Option<&Type> {
1909    let PathArguments::AngleBracketed(ab) = args else {
1910        return None;
1911    };
1912    ab.args.iter().find_map(|a| match a {
1913        GenericArgument::Type(t) => Some(t),
1914        _ => None,
1915    })
1916}
1917
1918fn collect_doc_comments(attrs: &[Attribute]) -> String {
1919    let mut out = String::new();
1920    for a in attrs {
1921        if !a.path().is_ident("doc") {
1922            continue;
1923        }
1924        if let Meta::NameValue(nv) = &a.meta {
1925            if let Expr::Lit(ExprLit {
1926                lit: Lit::Str(s), ..
1927            }) = &nv.value
1928            {
1929                let line = s.value();
1930                if !out.is_empty() {
1931                    out.push('\n');
1932                }
1933                out.push_str(line.trim_start());
1934            }
1935        }
1936    }
1937    out
1938}
1939
1940// ════════════════════════════════════════════════════════════════════════
1941//  #[Gateway("/path", tags(…))] — real-time WebSocket gateway (on `impl`)
1942// ════════════════════════════════════════════════════════════════════════
1943//
1944// Placed on the gateway's handler `impl` block (same rule/reason as
1945// #[Controller]). The struct itself carries #[Injectable] for field DI and a
1946// separate `impl ArclyGateway` for connection lifecycle. This macro walks the
1947// impl for #[Subscribe("event")] methods, builds an event→handler dispatch
1948// table, and emits a link-time GatewayDescriptor.
1949
1950struct GatewayArgs {
1951    path: LitStr,
1952    #[allow(dead_code)]
1953    tags: Vec<LitStr>,
1954}
1955
1956impl Parse for GatewayArgs {
1957    fn parse(input: ParseStream) -> syn::Result<Self> {
1958        let path: LitStr = input.parse()?;
1959        let mut tags: Vec<LitStr> = vec![];
1960        if input.peek(Token![,]) {
1961            let _: Token![,] = input.parse()?;
1962            if !input.is_empty() {
1963                let key: Ident = input.parse()?;
1964                if key != "tags" {
1965                    return Err(syn::Error::new(key.span(), "expected `tags(...)`"));
1966                }
1967                let content;
1968                syn::parenthesized!(content in input);
1969                let list: Punctuated<LitStr, Token![,]> =
1970                    content.parse_terminated(|s| s.parse::<LitStr>(), Token![,])?;
1971                tags.extend(list);
1972            }
1973        }
1974        Ok(Self { path, tags })
1975    }
1976}
1977
1978/// `#[Gateway("/path")]` — declare a WebSocket gateway.
1979///
1980/// Applied to an `impl` block, it mounts the handshake on the shared request
1981/// pipeline and exposes `#[Subscribe]` methods as message handlers with full
1982/// field DI.
1983#[proc_macro_attribute]
1984#[allow(non_snake_case)]
1985pub fn Gateway(attr: TokenStream, item: TokenStream) -> TokenStream {
1986    match syn::parse::<ItemImpl>(item) {
1987        Ok(imp) => gateway_on_impl(attr, imp),
1988        Err(_) => syn::Error::new(
1989            Span::call_site(),
1990            "#[Gateway(\"/path\")] must be placed on the gateway's `impl` block \
1991             (the struct uses #[Injectable]; lifecycle goes in `impl ArclyGateway`)",
1992        )
1993        .to_compile_error()
1994        .into(),
1995    }
1996}
1997
1998/// `#[Subscribe("event::name")]` — marker consumed by the enclosing `#[Gateway]`
1999/// walker. Pass-through so it also type-checks if a tool resolves it directly.
2000#[proc_macro_attribute]
2001#[allow(non_snake_case)]
2002pub fn Subscribe(_attr: TokenStream, item: TokenStream) -> TokenStream {
2003    item
2004}
2005
2006fn gateway_on_impl(attr: TokenStream, mut imp: ItemImpl) -> TokenStream {
2007    let GatewayArgs { path, .. } = parse_macro_input!(attr as GatewayArgs);
2008    let self_ty = (*imp.self_ty).clone();
2009    let name = match &self_ty {
2010        Type::Path(tp) => tp
2011            .path
2012            .segments
2013            .last()
2014            .map(|s| s.ident.to_string())
2015            .unwrap_or_default(),
2016        _ => String::new(),
2017    };
2018
2019    let mut dispatch_inserts: Vec<TokenStream2> = Vec::new();
2020    let mut errors: Vec<syn::Error> = Vec::new();
2021
2022    for item in imp.items.iter_mut() {
2023        let ImplItem::Fn(m) = item else { continue };
2024
2025        // Find a #[Subscribe("event")] marker on this method.
2026        let sub_idx = m.attrs.iter().position(|a| {
2027            a.path()
2028                .get_ident()
2029                .map(|i| i.to_string())
2030                .unwrap_or_default()
2031                == "Subscribe"
2032        });
2033        let Some(idx) = sub_idx else { continue };
2034
2035        let event: LitStr = match m.attrs[idx].parse_args() {
2036            Ok(e) => e,
2037            Err(e) => {
2038                errors.push(e);
2039                continue;
2040            }
2041        };
2042        m.attrs.remove(idx); // strip before re-emitting the impl
2043
2044        let fn_name = m.sig.ident.clone();
2045        match build_subscribe_insert(&self_ty, m, &event, &fn_name) {
2046            Ok(ts) => dispatch_inserts.push(ts),
2047            Err(e) => errors.push(e),
2048        }
2049    }
2050
2051    if let Some(err) = errors.into_iter().reduce(|mut a, b| {
2052        a.combine(b);
2053        a
2054    }) {
2055        return err.to_compile_error().into();
2056    }
2057
2058    let path_lit = LitStr::new(&path.value(), Span::call_site());
2059    let name_lit = LitStr::new(&name, Span::call_site());
2060    let build_ident = format_ident!("__arcly_build_gateway_{}", name.to_uppercase());
2061    let desc_ident = format_ident!("__ARCLY_GATEWAY_{}", name.to_uppercase());
2062
2063    quote! {
2064        #imp
2065
2066        #[doc(hidden)]
2067        #[allow(non_snake_case)]
2068        fn #build_ident(
2069            __container: ::std::sync::Arc<::arcly_http::__macro_support::FrozenDiContainer>,
2070        ) -> &'static ::arcly_http::__macro_support::GatewayRuntime
2071        {
2072            // Wire the gateway's Inject<T> fields via the #[Injectable]-generated
2073            // builder, then leak to &'static for the process lifetime.
2074            let __gw: &'static #self_ty = ::std::boxed::Box::leak(::std::boxed::Box::new(
2075                <#self_ty>::__arcly_build(&__container.resolver())
2076            ));
2077
2078            let mut __dispatch: ::std::collections::HashMap<
2079                &'static str,
2080                ::arcly_http::__macro_support::MessageHandler,
2081            > = ::std::collections::HashMap::new();
2082            #( #dispatch_inserts )*
2083
2084            ::std::boxed::Box::leak(::std::boxed::Box::new(
2085                ::arcly_http::__macro_support::GatewayRuntime {
2086                    path: #path_lit,
2087                    on_connect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
2088                        let __gw = __gw;
2089                        ::arcly_http::futures::FutureExt::boxed(async move {
2090                            <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_connect(__gw, __c).await
2091                        })
2092                    }),
2093                    on_disconnect: ::std::boxed::Box::new(move |__c: ::arcly_http::__macro_support::WsClient| {
2094                        let __gw = __gw;
2095                        ::arcly_http::futures::FutureExt::boxed(async move {
2096                            <#self_ty as ::arcly_http::__macro_support::ArclyGateway>::on_disconnect(__gw, __c).await
2097                        })
2098                    }),
2099                    dispatch: __dispatch,
2100                }
2101            ))
2102        }
2103
2104        #[allow(non_upper_case_globals)]
2105        static #desc_ident: ::arcly_http::__macro_support::GatewayDescriptor =
2106            ::arcly_http::__macro_support::GatewayDescriptor {
2107                name: #name_lit,
2108                path: #path_lit,
2109                build: #build_ident,
2110            };
2111
2112        ::arcly_http::inventory::submit! { &#desc_ident }
2113    }
2114    .into()
2115}
2116
2117/// Emit one `dispatch.insert("event", handler)` block. The handler closure
2118/// captures the `&'static` gateway, extracts each parameter (a `WsClient`
2119/// clone, or a `Json<T>` deserialized from the envelope `data`), then awaits
2120/// the user's `async fn`.
2121fn build_subscribe_insert(
2122    self_ty: &Type,
2123    m: &syn::ImplItemFn,
2124    event: &LitStr,
2125    fn_name: &Ident,
2126) -> syn::Result<TokenStream2> {
2127    let mut extract_stmts: Vec<TokenStream2> = Vec::new();
2128    let mut call_args: Vec<TokenStream2> = Vec::new();
2129
2130    for (i, input) in m.sig.inputs.iter().enumerate() {
2131        let pt = match input {
2132            FnArg::Receiver(_) => continue, // &self — supplied as __gw
2133            FnArg::Typed(pt) => pt,
2134        };
2135        let ty = (*pt.ty).clone();
2136        let var = format_ident!("__sub_arg_{i}");
2137
2138        if type_last_ident_is(&ty, "WsClient") {
2139            extract_stmts.push(
2140                quote! { let #var: ::arcly_http::__macro_support::WsClient = __client.clone(); },
2141            );
2142            call_args.push(quote! { #var });
2143        } else if let Some(inner) = json_inner_ty(&ty) {
2144            extract_stmts.push(quote! {
2145                let #var: ::arcly_http::__macro_support::Json<#inner> = ::arcly_http::__macro_support::Json(
2146                    ::arcly_http::serde_json::from_str::<#inner>(&__data)
2147                        .map_err(|_| ::arcly_http::__macro_support::Error::BadRequest("invalid websocket payload"))?
2148                );
2149            });
2150            call_args.push(quote! { #var });
2151        } else {
2152            return Err(syn::Error::new(
2153                pt.span(),
2154                "#[Subscribe] handler params must be `WsClient` or `Json<T>`",
2155            ));
2156        }
2157    }
2158
2159    Ok(quote! {
2160        {
2161            let __gw = __gw;
2162            let __handler: ::arcly_http::__macro_support::MessageHandler = ::std::sync::Arc::new(
2163                move |__client: ::arcly_http::__macro_support::WsClient, __data: ::std::sync::Arc<str>| {
2164                    let __gw = __gw;
2165                    ::arcly_http::futures::FutureExt::boxed(async move {
2166                        #( #extract_stmts )*
2167                        <#self_ty>::#fn_name(__gw, #( #call_args ),*).await
2168                    })
2169                }
2170            );
2171            __dispatch.insert(#event, __handler);
2172        }
2173    })
2174}
2175
2176fn type_last_ident_is(ty: &Type, name: &str) -> bool {
2177    matches!(ty, Type::Path(tp)
2178        if tp.path.segments.last().map(|s| s.ident == name).unwrap_or(false))
2179}
2180
2181fn json_inner_ty(ty: &Type) -> Option<Type> {
2182    let Type::Path(tp) = ty else { return None };
2183    let seg = tp.path.segments.last()?;
2184    if seg.ident != "Json" {
2185        return None;
2186    }
2187    first_generic(&seg.arguments).cloned()
2188}
2189
2190// ════════════════════════════════════════════════════════════════════════
2191//  #[circuit_breaker(threshold = N, cooldown = "Ns")]
2192// ════════════════════════════════════════════════════════════════════════
2193struct BreakerArgs {
2194    threshold: u32,
2195    cooldown_millis: u64,
2196}
2197
2198impl Parse for BreakerArgs {
2199    fn parse(input: ParseStream) -> syn::Result<Self> {
2200        let mut threshold: Option<u32> = None;
2201        let mut cooldown_millis: Option<u64> = None;
2202        while !input.is_empty() {
2203            let key: Ident = input.parse()?;
2204            let _: Token![=] = input.parse()?;
2205            match key.to_string().as_str() {
2206                "threshold" => {
2207                    let n: LitInt = input.parse()?;
2208                    threshold = Some(n.base10_parse()?);
2209                }
2210                "cooldown" => {
2211                    let s: LitStr = input.parse()?;
2212                    cooldown_millis = Some(parse_duration_ms(&s)?);
2213                }
2214                other => {
2215                    return Err(syn::Error::new(
2216                        key.span(),
2217                        format!("unknown circuit_breaker key `{other}`"),
2218                    ))
2219                }
2220            }
2221            let _ = input.parse::<Token![,]>();
2222        }
2223        Ok(Self {
2224            threshold: threshold
2225                .ok_or_else(|| syn::Error::new(input.span(), "missing `threshold = N`"))?,
2226            cooldown_millis: cooldown_millis
2227                .ok_or_else(|| syn::Error::new(input.span(), "missing `cooldown = \"…\"`"))?,
2228        })
2229    }
2230}
2231
2232fn parse_duration_ms(s: &LitStr) -> syn::Result<u64> {
2233    let raw = s.value();
2234    let r = raw.trim();
2235    let (num_s, unit) = match r.rfind(|c: char| c.is_ascii_digit()) {
2236        Some(i) => (&r[..=i], &r[i + 1..]),
2237        None => return Err(syn::Error::new(s.span(), "invalid duration")),
2238    };
2239    let n: u64 = num_s
2240        .parse()
2241        .map_err(|_| syn::Error::new(s.span(), "invalid duration number"))?;
2242    let mult = match unit.trim() {
2243        "ms" => 1,
2244        "s" | "" => 1_000,
2245        "m" => 60_000,
2246        "h" => 3_600_000,
2247        other => {
2248            return Err(syn::Error::new(
2249                s.span(),
2250                format!("unknown duration unit `{other}`"),
2251            ))
2252        }
2253    };
2254    Ok(n * mult)
2255}
2256
2257/// `#[circuit_breaker(..)]` — wrap a method in a circuit breaker.
2258///
2259/// Trips after the configured failure threshold and short-circuits calls
2260/// while open, protecting downstreams; recovers via a half-open probe.
2261#[proc_macro_attribute]
2262pub fn circuit_breaker(attr: TokenStream, item: TokenStream) -> TokenStream {
2263    let args = parse_macro_input!(attr as BreakerArgs);
2264    let mut f = parse_macro_input!(item as syn::ImplItemFn);
2265
2266    let threshold = args.threshold;
2267    let cooldown_ms = args.cooldown_millis;
2268
2269    let breaker_name = format_ident!("__BREAKER_{}", f.sig.ident.to_string().to_uppercase());
2270    let breaker_label = f.sig.ident.to_string();
2271
2272    // Replace the fn body with a breaker-wrapped version.
2273    let original_body = f.block.clone();
2274    let new_block: Block = parse_quote! {{
2275        static #breaker_name: ::arcly_http::__macro_support::CircuitBreaker =
2276            ::arcly_http::__macro_support::CircuitBreaker::const_named(
2277                #breaker_label, #threshold, #cooldown_ms,
2278            );
2279        match #breaker_name.execute(|| async move #original_body).await {
2280            ::core::result::Result::Ok(inner) => inner,
2281            ::core::result::Result::Err(_open) => ::core::result::Result::Err(
2282                <_ as ::core::convert::From<::arcly_http::__macro_support::BreakerOpen>>::from(_open),
2283            ),
2284        }
2285    }};
2286    f.block = new_block;
2287
2288    quote! { #f }.into()
2289}
2290
2291// ════════════════════════════════════════════════════════════════════════
2292//  #[EncryptFields] — field-level envelope encryption on a DTO
2293// ════════════════════════════════════════════════════════════════════════
2294
2295/// Arguments of `#[EncryptFields(key = "tenant:acme", fields("ssn", "card.number"))]`.
2296struct EncryptFieldsArgs {
2297    key: LitStr,
2298    fields: Vec<LitStr>,
2299}
2300
2301impl Parse for EncryptFieldsArgs {
2302    fn parse(input: ParseStream) -> syn::Result<Self> {
2303        let mut key: Option<LitStr> = None;
2304        let mut fields: Vec<LitStr> = Vec::new();
2305        while !input.is_empty() {
2306            let ident: Ident = input.parse()?;
2307            match ident.to_string().as_str() {
2308                "key" => {
2309                    input.parse::<Token![=]>()?;
2310                    key = Some(input.parse()?);
2311                }
2312                "fields" => {
2313                    let inner;
2314                    syn::parenthesized!(inner in input);
2315                    let lits: Punctuated<LitStr, Token![,]> =
2316                        inner.parse_terminated(|p: ParseStream| p.parse::<LitStr>(), Token![,])?;
2317                    fields.extend(lits);
2318                }
2319                other => {
2320                    return Err(syn::Error::new(
2321                        ident.span(),
2322                        format!(
2323                            "unknown EncryptFields argument `{other}` (expected `key` or `fields`)"
2324                        ),
2325                    ))
2326                }
2327            }
2328            if input.peek(Token![,]) {
2329                input.parse::<Token![,]>()?;
2330            }
2331        }
2332        let key = key.ok_or_else(|| {
2333            syn::Error::new(Span::call_site(), "EncryptFields requires `key = \"...\"`")
2334        })?;
2335        if fields.is_empty() {
2336            return Err(syn::Error::new(
2337                Span::call_site(),
2338                "EncryptFields requires at least one entry in `fields(...)`",
2339            ));
2340        }
2341        Ok(Self { key, fields })
2342    }
2343}
2344
2345/// `#[EncryptFields(key = "tenant:acme", fields("ssn", "items.*.diagnosis"))]`
2346/// on a `Serialize + Deserialize` struct implements `EncryptRecord`:
2347/// `record.seal(&vault)` returns a `serde_json::Value` with the declared
2348/// fields sealed (safe for any durable sink); `T::unseal(value, &vault)`
2349/// reverses it. Use `seal_with_key`/`KeyId::subject(...)` for per-subject
2350/// keys minted at runtime (crypto-shredding granularity).
2351#[proc_macro_attribute]
2352#[allow(non_snake_case)]
2353pub fn EncryptFields(attr: TokenStream, item: TokenStream) -> TokenStream {
2354    let args = parse_macro_input!(attr as EncryptFieldsArgs);
2355    let st = parse_macro_input!(item as ItemStruct);
2356    let name = &st.ident;
2357    let (impl_g, ty_g, where_c) = st.generics.split_for_impl();
2358    let key = &args.key;
2359    let fields = &args.fields;
2360
2361    quote! {
2362        #st
2363
2364        impl #impl_g ::arcly_http::__macro_support::EncryptRecord for #name #ty_g #where_c {
2365            const ENCRYPT_FIELDS: &'static [&'static str] = &[ #( #fields ),* ];
2366            const KEY_ID: &'static str = #key;
2367        }
2368    }
2369    .into()
2370}