Skip to main content

arcly_http_macros/
lib.rs

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