Skip to main content

toolkit_macros/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2use heck::ToSnakeCase;
3use proc_macro::TokenStream;
4use proc_macro2::Span;
5use quote::{format_ident, quote};
6use syn::{
7    DeriveInput, Expr, Ident, ImplItem, ItemImpl, Lit, LitBool, LitStr, Meta, MetaList,
8    MetaNameValue, Path, Token, TypePath, parse::Parse, parse::ParseStream, parse_macro_input,
9    punctuated::Punctuated,
10};
11
12mod api_dto;
13mod domain_model;
14mod expand_vars;
15mod grpc_client;
16mod utils;
17
18/// Configuration parsed from #[gear(...)] attribute
19struct GearConfig {
20    name: String,
21    deps: Vec<Ident>,
22    caps: Vec<Capability>,
23    ctor: Option<Expr>,           // arbitrary constructor expression
24    client: Option<Path>,         // trait path for client DX helpers
25    lifecycle: Option<LcGearCfg>, // optional lifecycle config (on type)
26}
27
28#[derive(Debug, PartialEq, Clone)]
29enum Capability {
30    Db,
31    Rest,
32    RestHost,
33    Stateful,
34    System,
35    GrpcHub,
36    Grpc,
37}
38
39impl Capability {
40    const VALID_CAPABILITIES: &'static [&'static str] = &[
41        "db",
42        "rest",
43        "rest_host",
44        "stateful",
45        "system",
46        "grpc_hub",
47        "grpc",
48    ];
49
50    fn suggest_similar(input: &str) -> Vec<&'static str> {
51        let mut suggestions: Vec<(&str, f64)> = Self::VALID_CAPABILITIES
52            .iter()
53            .map(|&cap| (cap, strsim::jaro_winkler(input, cap)))
54            .filter(|(_, score)| *score > 0.6) // Only suggest if reasonably similar
55            .collect();
56
57        suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
58        suggestions
59            .into_iter()
60            .take(2)
61            .map(|(cap, _)| cap)
62            .collect()
63    }
64
65    fn from_ident(ident: &Ident) -> syn::Result<Self> {
66        let input = ident.to_string();
67        match input.as_str() {
68            "db" => Ok(Capability::Db),
69            "rest" => Ok(Capability::Rest),
70            "rest_host" => Ok(Capability::RestHost),
71            "stateful" => Ok(Capability::Stateful),
72            "system" => Ok(Capability::System),
73            "grpc_hub" => Ok(Capability::GrpcHub),
74            "grpc" => Ok(Capability::Grpc),
75            other => {
76                let suggestions = Self::suggest_similar(other);
77                let error_msg = if suggestions.is_empty() {
78                    format!(
79                        "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
80                    )
81                } else {
82                    format!(
83                        "unknown capability '{other}'\n       = help: did you mean one of: {}?",
84                        suggestions.join(", ")
85                    )
86                };
87                Err(syn::Error::new_spanned(ident, error_msg))
88            }
89        }
90    }
91
92    fn from_str_lit(lit: &LitStr) -> syn::Result<Self> {
93        let input = lit.value();
94        match input.as_str() {
95            "db" => Ok(Capability::Db),
96            "rest" => Ok(Capability::Rest),
97            "rest_host" => Ok(Capability::RestHost),
98            "stateful" => Ok(Capability::Stateful),
99            "system" => Ok(Capability::System),
100            "grpc_hub" => Ok(Capability::GrpcHub),
101            "grpc" => Ok(Capability::Grpc),
102            other => {
103                let suggestions = Self::suggest_similar(other);
104                let error_msg = if suggestions.is_empty() {
105                    format!(
106                        "unknown capability '{other}', expected one of: db, rest, rest_host, stateful, system, grpc_hub, grpc"
107                    )
108                } else {
109                    format!(
110                        "unknown capability '{other}'\n       = help: did you mean one of: {}?",
111                        suggestions.join(", ")
112                    )
113                };
114                Err(syn::Error::new_spanned(lit, error_msg))
115            }
116        }
117    }
118}
119
120/// Validates that a gear name follows kebab-case naming convention.
121///
122/// # Rules
123/// - Must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)
124/// - Must start with a lowercase letter
125/// - Must not end with a hyphen
126/// - Must not contain consecutive hyphens
127/// - Must not contain underscores (use hyphens instead)
128///
129/// # Examples
130/// Valid: "file-parser", "api-gateway", "simple-user-settings", "types-registry"
131/// Invalid: "`file_parser`" (underscores), "`FileParser`" (uppercase), "-parser" (starts with hyphen)
132fn validate_kebab_case(name: &str) -> Result<(), String> {
133    if name.is_empty() {
134        return Err("gear name cannot be empty".to_owned());
135    }
136
137    // Check for underscores (common mistake)
138    if name.contains('_') {
139        let suggested = name.replace('_', "-");
140        return Err(format!(
141            "gear name must use kebab-case, not snake_case\n       = help: use '{suggested}' instead of '{name}'"
142        ));
143    }
144
145    // Must start with a lowercase letter
146    if let Some(first_char) = name.chars().next() {
147        if !first_char.is_ascii_lowercase() {
148            return Err(format!(
149                "gear name must start with a lowercase letter, found '{first_char}'"
150            ));
151        }
152    } else {
153        // This should never happen due to the empty check above
154        return Err("gear name cannot be empty".to_owned());
155    }
156
157    // Must not end with hyphen
158    if name.ends_with('-') {
159        return Err("gear name must not end with a hyphen".to_owned());
160    }
161
162    // Check for invalid characters and consecutive hyphens
163    let mut prev_was_hyphen = false;
164    for ch in name.chars() {
165        if ch == '-' {
166            if prev_was_hyphen {
167                return Err("gear name must not contain consecutive hyphens".to_owned());
168            }
169            prev_was_hyphen = true;
170        } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
171            prev_was_hyphen = false;
172        } else {
173            return Err(format!(
174                "gear name must contain only lowercase letters, digits, and hyphens, found '{ch}'"
175            ));
176        }
177    }
178
179    Ok(())
180}
181
182#[derive(Debug, Clone)]
183struct LcGearCfg {
184    entry: String,        // entry method name (e.g., "serve")
185    stop_timeout: String, // human duration (e.g., "30s")
186    await_ready: bool,    // require ReadySignal gating
187}
188
189impl Default for LcGearCfg {
190    fn default() -> Self {
191        Self {
192            entry: "serve".to_owned(),
193            stop_timeout: "30s".to_owned(),
194            await_ready: false,
195        }
196    }
197}
198
199impl Parse for GearConfig {
200    #[allow(clippy::too_many_lines)]
201    fn parse(input: ParseStream) -> syn::Result<Self> {
202        let mut name: Option<String> = None;
203        let mut deps: Vec<Ident> = Vec::new();
204        let mut caps: Vec<Capability> = Vec::new();
205        let mut ctor: Option<Expr> = None;
206        let mut client: Option<Path> = None;
207        let mut lifecycle: Option<LcGearCfg> = None;
208
209        let mut seen_name = false;
210        let mut seen_deps = false;
211        let mut seen_caps = false;
212        let mut seen_ctor = false;
213        let mut seen_client = false;
214        let mut seen_lifecycle = false;
215
216        let punctuated: Punctuated<Meta, Token![,]> =
217            input.parse_terminated(Meta::parse, Token![,])?;
218
219        for meta in punctuated {
220            match meta {
221                Meta::NameValue(nv) if nv.path.is_ident("name") => {
222                    if seen_name {
223                        return Err(syn::Error::new_spanned(
224                            nv.path,
225                            "duplicate `name` parameter",
226                        ));
227                    }
228                    seen_name = true;
229                    match nv.value {
230                        Expr::Lit(syn::ExprLit {
231                            lit: Lit::Str(s), ..
232                        }) => {
233                            let gear_name = s.value();
234                            // Validate kebab-case format
235                            if let Err(err) = validate_kebab_case(&gear_name) {
236                                return Err(syn::Error::new_spanned(s, err));
237                            }
238                            name = Some(gear_name);
239                        }
240                        other => {
241                            return Err(syn::Error::new_spanned(
242                                other,
243                                "name must be a string literal, e.g. name = \"my-gear\"",
244                            ));
245                        }
246                    }
247                }
248                Meta::NameValue(nv) if nv.path.is_ident("ctor") => {
249                    if seen_ctor {
250                        return Err(syn::Error::new_spanned(
251                            nv.path,
252                            "duplicate `ctor` parameter",
253                        ));
254                    }
255                    seen_ctor = true;
256
257                    // Reject string literals with a clear message.
258                    match &nv.value {
259                        Expr::Lit(syn::ExprLit {
260                            lit: Lit::Str(s), ..
261                        }) => {
262                            return Err(syn::Error::new_spanned(
263                                s,
264                                "ctor must be a Rust expression, not a string literal. \
265                 Use: ctor = MyType::new()  (with parentheses), \
266                 or:  ctor = Default::default()",
267                            ));
268                        }
269                        _ => {
270                            ctor = Some(nv.value.clone());
271                        }
272                    }
273                }
274                Meta::NameValue(nv) if nv.path.is_ident("client") => {
275                    if seen_client {
276                        return Err(syn::Error::new_spanned(
277                            nv.path,
278                            "duplicate `client` parameter",
279                        ));
280                    }
281                    seen_client = true;
282                    let value = nv.value.clone();
283                    match value {
284                        Expr::Path(ep) => {
285                            client = Some(ep.path);
286                        }
287                        other => {
288                            return Err(syn::Error::new_spanned(
289                                other,
290                                "client must be a trait path, e.g. client = crate::api::MyClient",
291                            ));
292                        }
293                    }
294                }
295                Meta::NameValue(nv) if nv.path.is_ident("deps") => {
296                    if seen_deps {
297                        return Err(syn::Error::new_spanned(
298                            nv.path,
299                            "duplicate `deps` parameter",
300                        ));
301                    }
302                    seen_deps = true;
303                    let value = nv.value.clone();
304                    match value {
305                        Expr::Array(arr) => {
306                            for elem in arr.elems {
307                                match elem {
308                                    Expr::Path(ref path) => {
309                                        if let Some(ident) = path.path.get_ident() {
310                                            deps.push(ident.clone());
311                                        } else {
312                                            return Err(syn::Error::new_spanned(
313                                                path,
314                                                "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
315                                            ));
316                                        }
317                                    }
318                                    other => {
319                                        return Err(syn::Error::new_spanned(
320                                            other,
321                                            "deps must be crate identifiers, e.g. deps = [authn_resolver, types_registry]",
322                                        ));
323                                    }
324                                }
325                            }
326                        }
327                        other => {
328                            return Err(syn::Error::new_spanned(
329                                other,
330                                "deps must be an array, e.g. deps = [authn_resolver, types_registry]",
331                            ));
332                        }
333                    }
334                }
335                Meta::NameValue(nv) if nv.path.is_ident("capabilities") => {
336                    if seen_caps {
337                        return Err(syn::Error::new_spanned(
338                            nv.path,
339                            "duplicate `capabilities` parameter",
340                        ));
341                    }
342                    seen_caps = true;
343                    let value = nv.value.clone();
344                    match value {
345                        Expr::Array(arr) => {
346                            for elem in arr.elems {
347                                match elem {
348                                    Expr::Path(ref path) => {
349                                        if let Some(ident) = path.path.get_ident() {
350                                            caps.push(Capability::from_ident(ident)?);
351                                        } else {
352                                            return Err(syn::Error::new_spanned(
353                                                path,
354                                                "capability must be a simple identifier (db, rest, rest_host, stateful)",
355                                            ));
356                                        }
357                                    }
358                                    Expr::Lit(syn::ExprLit {
359                                        lit: Lit::Str(s), ..
360                                    }) => {
361                                        caps.push(Capability::from_str_lit(&s)?);
362                                    }
363                                    other => {
364                                        return Err(syn::Error::new_spanned(
365                                            other,
366                                            "capability must be an identifier or string literal (\"db\", \"rest\", \"rest_host\", \"stateful\")",
367                                        ));
368                                    }
369                                }
370                            }
371                        }
372                        other => {
373                            return Err(syn::Error::new_spanned(
374                                other,
375                                "capabilities must be an array, e.g. capabilities = [db, rest]",
376                            ));
377                        }
378                    }
379                }
380                // Accept `lifecycle(...)` and also namespaced like `toolkit::gear::lifecycle(...)`
381                Meta::List(list) if path_last_is(&list.path, "lifecycle") => {
382                    if seen_lifecycle {
383                        return Err(syn::Error::new_spanned(
384                            list.path,
385                            "duplicate `lifecycle(...)` parameter",
386                        ));
387                    }
388                    seen_lifecycle = true;
389                    lifecycle = Some(parse_lifecycle_list(&list)?);
390                }
391                other => {
392                    return Err(syn::Error::new_spanned(
393                        other,
394                        "unknown attribute parameter",
395                    ));
396                }
397            }
398        }
399
400        let name = name.ok_or_else(|| {
401            syn::Error::new(
402                Span::call_site(),
403                "name parameter is required, e.g. #[gear(name = \"my-gear\", ...)]",
404            )
405        })?;
406
407        Ok(GearConfig {
408            name,
409            deps,
410            caps,
411            ctor,
412            client,
413            lifecycle,
414        })
415    }
416}
417
418fn parse_lifecycle_list(list: &MetaList) -> syn::Result<LcGearCfg> {
419    let mut cfg = LcGearCfg::default();
420
421    let inner: Punctuated<Meta, Token![,]> =
422        list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
423
424    for m in inner {
425        match m {
426            Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("entry") => {
427                if let Expr::Lit(syn::ExprLit {
428                    lit: Lit::Str(s), ..
429                }) = value
430                {
431                    cfg.entry = s.value();
432                } else {
433                    return Err(syn::Error::new_spanned(
434                        value,
435                        "entry must be a string literal, e.g. entry = \"serve\"",
436                    ));
437                }
438            }
439            Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("stop_timeout") => {
440                if let Expr::Lit(syn::ExprLit {
441                    lit: Lit::Str(s), ..
442                }) = value
443                {
444                    cfg.stop_timeout = s.value();
445                } else {
446                    return Err(syn::Error::new_spanned(
447                        value,
448                        "stop_timeout must be a string literal like \"45s\"",
449                    ));
450                }
451            }
452            Meta::Path(p) if p.is_ident("await_ready") => {
453                cfg.await_ready = true;
454            }
455            Meta::NameValue(MetaNameValue { path, value, .. }) if path.is_ident("await_ready") => {
456                if let Expr::Lit(syn::ExprLit {
457                    lit: Lit::Bool(LitBool { value: b, .. }),
458                    ..
459                }) = value
460                {
461                    cfg.await_ready = b;
462                } else {
463                    return Err(syn::Error::new_spanned(
464                        value,
465                        "await_ready must be a bool literal (true/false) or a bare flag",
466                    ));
467                }
468            }
469            other => {
470                return Err(syn::Error::new_spanned(
471                    other,
472                    "expected lifecycle args: entry=\"...\", stop_timeout=\"...\", await_ready[=true|false]",
473                ));
474            }
475        }
476    }
477
478    Ok(cfg)
479}
480
481/// Main #[gear] attribute macro
482///
483/// `ctor` must be a Rust expression that evaluates to the gear instance,
484/// e.g. `ctor = MyGear::new()` or `ctor = Default::default()`.
485///
486/// # Gear dependencies (`deps`)
487///
488/// `deps` lists the **crate identifiers** (`snake_case`) of gears that this gear
489/// depends on.  The runtime gear name is derived by replacing underscores with
490/// hyphens (`authn_resolver` → `"authn-resolver"`), which matches the universal
491/// convention that `lib-name == gear-name` across the workspace.
492///
493/// The macro generates hidden `pub use` re-exports so the linker keeps each
494/// dependency's `inventory::submit!` registration alive.  Because these
495/// re-exports only exist in macro-expanded code, `cargo-shear` cannot see
496/// them and will flag the dependency crates as unused.
497///
498/// **When adding a new gear dependency** you must also add the crate name to
499/// the `[workspace.metadata.cargo-shear] ignored` list in the workspace
500/// `Cargo.toml`, otherwise the `shear` CI job will fail.
501#[proc_macro_attribute]
502#[allow(clippy::too_many_lines)]
503pub fn gear(attr: TokenStream, item: TokenStream) -> TokenStream {
504    let config = parse_macro_input!(attr as GearConfig);
505    let input = parse_macro_input!(item as DeriveInput);
506
507    // --- Clone all needed pieces early to avoid use-after-move issues ---
508    let struct_ident = input.ident.clone();
509    let generics_clone = input.generics.clone();
510    let (impl_generics, ty_generics, where_clause) = generics_clone.split_for_impl();
511
512    let name_owned: String = config.name.clone();
513    let deps_idents: Vec<Ident> = config.deps.clone();
514    let caps_for_asserts: Vec<Capability> = config.caps.clone();
515    let caps_for_regs: Vec<Capability> = config.caps.clone();
516    let ctor_expr_opt: Option<Expr> = config.ctor.clone();
517    let client_trait_opt: Option<Path> = config.client.clone();
518    let lifecycle_cfg_opt: Option<LcGearCfg> = config.lifecycle;
519
520    // Prepare string literals for name/deps
521    let name_lit = LitStr::new(&name_owned, Span::call_site());
522    // Derive runtime dep names from crate identifiers: authn_resolver -> "authn-resolver"
523    let deps_lits: Vec<LitStr> = deps_idents
524        .iter()
525        .map(|ident| LitStr::new(&ident.to_string().replace('_', "-"), Span::call_site()))
526        .collect();
527
528    // Constructor expression (provided or Default::default())
529    let constructor = if let Some(expr) = &ctor_expr_opt {
530        quote! { #expr }
531    } else {
532        // Use `<T as Default>::default()` so generics/where-clause are honored.
533        quote! { <#struct_ident #ty_generics as ::core::default::Default>::default() }
534    };
535
536    // Compile-time capability assertions (no calls in consts)
537    let mut cap_asserts = Vec::new();
538
539    // Always assert Gear is implemented
540    cap_asserts.push(quote! {
541        const _: () = {
542            #[allow(dead_code)]
543            fn __toolkit_require_Gear_impl()
544            where
545                #struct_ident #ty_generics: ::toolkit::contracts::Gear,
546            {}
547        };
548    });
549
550    for cap in &caps_for_asserts {
551        let q = match cap {
552            Capability::Db => quote! {
553                const _: () = {
554                    #[allow(dead_code)]
555                    fn __toolkit_require_DatabaseCapability_impl()
556                    where
557                        #struct_ident #ty_generics: ::toolkit::contracts::DatabaseCapability,
558                    {}
559                };
560            },
561            Capability::Rest => quote! {
562                const _: () = {
563                    #[allow(dead_code)]
564                    fn __toolkit_require_RestApiCapability_impl()
565                    where
566                        #struct_ident #ty_generics: ::toolkit::contracts::RestApiCapability,
567                    {}
568                };
569            },
570            Capability::RestHost => quote! {
571                const _: () = {
572                    #[allow(dead_code)]
573                    fn __toolkit_require_ApiGatewayCapability_impl()
574                    where
575                        #struct_ident #ty_generics: ::toolkit::contracts::ApiGatewayCapability,
576                    {}
577                };
578            },
579            Capability::Stateful => {
580                if lifecycle_cfg_opt.is_none() {
581                    // Only require direct RunnableCapability impl when lifecycle(...) is NOT used.
582                    quote! {
583                        const _: () = {
584                            #[allow(dead_code)]
585                            fn __toolkit_require_RunnableCapability_impl()
586                            where
587                                #struct_ident #ty_generics: ::toolkit::contracts::RunnableCapability,
588                            {}
589                        };
590                    }
591                } else {
592                    quote! {}
593                }
594            }
595            Capability::System => {
596                // System is a flag, no trait required
597                quote! {}
598            }
599            Capability::GrpcHub => quote! {
600                const _: () = {
601                    #[allow(dead_code)]
602                    fn __toolkit_require_GrpcHubCapability_impl()
603                    where
604                        #struct_ident #ty_generics: ::toolkit::contracts::GrpcHubCapability,
605                    {}
606                };
607            },
608            Capability::Grpc => quote! {
609                const _: () = {
610                    #[allow(dead_code)]
611                    fn __toolkit_require_GrpcServiceCapability_impl()
612                    where
613                        #struct_ident #ty_generics: ::toolkit::contracts::GrpcServiceCapability,
614                    {}
615                };
616            },
617        };
618        cap_asserts.push(q);
619    }
620
621    // Registrator name (avoid lowercasing to reduce collisions)
622    let struct_name_snake = struct_ident.to_string().to_snake_case();
623    let registrator_name = format_ident!("__{}_registrator", struct_name_snake);
624
625    // === Top-level extras (impl Runnable + optional ready shim) ===
626    let mut extra_top_level = proc_macro2::TokenStream::new();
627
628    if let Some(lc) = &lifecycle_cfg_opt {
629        // If the type declares lifecycle(...), we generate Runnable at top-level.
630        let entry_ident = format_ident!("{}", lc.entry);
631        let timeout_ts =
632            parse_duration_tokens(&lc.stop_timeout).unwrap_or_else(|e| e.to_compile_error());
633        let await_ready_bool = lc.await_ready;
634
635        if await_ready_bool {
636            let ready_shim_ident =
637                format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
638
639            // Runnable calls entry(cancel, ready). Shim is used by WithLifecycle in ready mode.
640            extra_top_level.extend(quote! {
641                #[::async_trait::async_trait]
642                impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
643                    async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
644                        let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
645                        let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
646                        self.#entry_ident(cancel, ready).await
647                    }
648                }
649
650                #[doc(hidden)]
651                #[allow(dead_code, non_snake_case)]
652                fn #ready_shim_ident(
653                    this: ::std::sync::Arc<#struct_ident #ty_generics>,
654                    cancel: ::tokio_util::sync::CancellationToken,
655                    ready: ::toolkit::lifecycle::ReadySignal,
656                ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
657                    Box::pin(async move { this.#entry_ident(cancel, ready).await })
658                }
659            });
660
661            // Convenience `into_gear()` API.
662            extra_top_level.extend(quote! {
663                impl #impl_generics #struct_ident #ty_generics #where_clause {
664                    /// Wrap this instance into a stateful gear with lifecycle configuration.
665                    pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
666                        ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
667                            .with_stop_timeout(#timeout_ts)
668                            .with_ready_mode(true, true, Some(#ready_shim_ident))
669                    }
670                }
671            });
672        } else {
673            // No ready gating: Runnable calls entry(cancel).
674            extra_top_level.extend(quote! {
675                #[::async_trait::async_trait]
676                impl #impl_generics ::toolkit::lifecycle::Runnable for #struct_ident #ty_generics #where_clause {
677                    async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
678                        self.#entry_ident(cancel).await
679                    }
680                }
681
682                impl #impl_generics #struct_ident #ty_generics #where_clause {
683                    /// Wrap this instance into a stateful gear with lifecycle configuration.
684                    pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
685                        ::toolkit::lifecycle::WithLifecycle::new_with_name(self, #name_lit)
686                            .with_stop_timeout(#timeout_ts)
687                            .with_ready_mode(false, false, None)
688                    }
689                }
690            });
691        }
692    }
693
694    // Capability registrations (builder API), with special handling for stateful + lifecycle
695    let capability_registrations = caps_for_regs.iter().map(|cap| {
696        match cap {
697            Capability::Db => quote! {
698                b.register_db_with_meta(#name_lit,
699                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::DatabaseCapability>);
700            },
701            Capability::Rest => quote! {
702                b.register_rest_with_meta(#name_lit,
703                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RestApiCapability>);
704            },
705            Capability::RestHost => quote! {
706                b.register_rest_host_with_meta(#name_lit,
707                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::ApiGatewayCapability>);
708            },
709            Capability::Stateful => {
710                if let Some(lc) = &lifecycle_cfg_opt {
711                    let timeout_ts = parse_duration_tokens(&lc.stop_timeout)
712                        .unwrap_or_else(|e| e.to_compile_error());
713                    let await_ready_bool = lc.await_ready;
714                    let ready_shim_ident =
715                        format_ident!("__toolkit_run_ready_shim_for_{}", struct_name_snake);
716
717                    if await_ready_bool {
718                        quote! {
719                            let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
720                                    gear.clone(),
721                                    #name_lit,
722                                )
723                                .with_stop_timeout(#timeout_ts)
724                                .with_ready_mode(true, true, Some(#ready_shim_ident));
725
726                            b.register_stateful_with_meta(
727                                #name_lit,
728                                ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
729                            );
730                        }
731                    } else {
732                        quote! {
733                            let wl = ::toolkit::lifecycle::WithLifecycle::from_arc_with_name(
734                                    gear.clone(),
735                                    #name_lit,
736                                )
737                                .with_stop_timeout(#timeout_ts)
738                                .with_ready_mode(false, false, None);
739
740                            b.register_stateful_with_meta(
741                                #name_lit,
742                                ::std::sync::Arc::new(wl) as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>
743                            );
744                        }
745                    }
746                } else {
747                    // Alternative path: the type itself must implement RunnableCapability
748                    quote! {
749                        b.register_stateful_with_meta(#name_lit,
750                            gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::RunnableCapability>);
751                    }
752                }
753            },
754            Capability::System => quote! {
755                b.register_system_with_meta(#name_lit,
756                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::SystemCapability>);
757            },
758            Capability::GrpcHub => quote! {
759                b.register_grpc_hub_with_meta(#name_lit,
760                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcHubCapability>);
761            },
762            Capability::Grpc => quote! {
763                b.register_grpc_service_with_meta(#name_lit,
764                    gear.clone() as ::std::sync::Arc<dyn ::toolkit::contracts::GrpcServiceCapability>);
765            },
766        }
767    });
768
769    // ClientHub DX helpers (optional)
770    // Note: The `client` parameter now only triggers compile-time trait checks.
771    // For client registration/access, use `hub.register::<dyn Trait>(client)` and
772    // `hub.get::<dyn Trait>()` directly, or provide helpers in your *-sdk crate.
773    let client_code = if let Some(client_trait_path) = &client_trait_opt {
774        quote! {
775            // Compile-time trait checks: object-safe + Send + Sync + 'static
776            const _: () = {
777                fn __toolkit_obj_safety<T: ?Sized + ::core::marker::Send + ::core::marker::Sync + 'static>() {}
778                let _ = __toolkit_obj_safety::<dyn #client_trait_path> as fn();
779            };
780
781            impl #impl_generics #struct_ident #ty_generics #where_clause {
782                pub const MODULE_NAME: &'static str = #name_lit;
783            }
784        }
785    } else {
786        // Even without a client trait, expose MODULE_NAME for ergonomics.
787        quote! {
788            impl #impl_generics #struct_ident #ty_generics #where_clause {
789                pub const MODULE_NAME: &'static str = #name_lit;
790            }
791        }
792    };
793
794    // Generate re-exports for gear dependencies so their `inventory::submit!`
795    // registrations survive linking when this gear is pulled in transitively.
796    let dep_reexports: Vec<_> = deps_idents
797        .iter()
798        .map(|crate_ident| {
799            let alias_ident = format_ident!("_gear_dep_{}", crate_ident);
800            quote! {
801                #[cfg(not(test))]
802                #[doc(hidden)]
803                pub use ::#crate_ident as #alias_ident;
804            }
805        })
806        .collect();
807
808    // Final expansion:
809    let expanded = quote! {
810        #input
811
812        // Compile-time capability assertions (better errors if trait impls are missing)
813        #(#cap_asserts)*
814
815        // Re-export gear dependencies to force-link their inventory registrations
816        #(#dep_reexports)*
817
818        // Registrator that targets the *builder*, not the final registry
819        #[doc(hidden)]
820        fn #registrator_name(b: &mut ::toolkit::registry::RegistryBuilder) {
821            use ::std::sync::Arc;
822
823            let gear: Arc<#struct_ident #ty_generics> = Arc::new(#constructor);
824
825            // register core with metadata (name + deps)
826            b.register_core_with_meta(
827                #name_lit,
828                &[#(#deps_lits),*],
829                gear.clone() as Arc<dyn ::toolkit::contracts::Gear>
830            );
831
832            // capabilities
833            #(#capability_registrations)*
834        }
835
836        ::toolkit::inventory::submit! {
837            ::toolkit::registry::Registrator(#registrator_name)
838        }
839
840        #client_code
841
842        // Top-level extras for lifecycle-enabled types (impl Runnable, ready shim, into_gear)
843        #extra_top_level
844    };
845
846    TokenStream::from(expanded)
847}
848
849// ============================================================================
850// Lifecycle Macro (impl-block attribute) — still supported for opt-in usage
851// ============================================================================
852
853#[derive(Debug)]
854struct LcCfg {
855    method: String,
856    stop_timeout: String,
857    await_ready: bool,
858}
859
860#[proc_macro_attribute]
861pub fn lifecycle(attr: TokenStream, item: TokenStream) -> TokenStream {
862    let args = parse_macro_input!(attr with Punctuated::<Meta, Token![,]>::parse_terminated);
863    let impl_item = parse_macro_input!(item as ItemImpl);
864
865    let cfg = match parse_lifecycle_args(args) {
866        Ok(c) => c,
867        Err(e) => return e.to_compile_error().into(),
868    };
869
870    // Extract impl type ident
871    let ty = match &*impl_item.self_ty {
872        syn::Type::Path(TypePath { path, .. }) => path.clone(),
873        other => {
874            return syn::Error::new_spanned(other, "unsupported impl target")
875                .to_compile_error()
876                .into();
877        }
878    };
879
880    let runner_ident = format_ident!("{}", cfg.method);
881    let mut has_runner = false;
882    let mut takes_ready_signal = false;
883    for it in &impl_item.items {
884        if let ImplItem::Fn(f) = it
885            && f.sig.ident == runner_ident
886        {
887            has_runner = true;
888            if f.sig.asyncness.is_none() {
889                return syn::Error::new_spanned(f.sig.fn_token, "runner must be async")
890                    .to_compile_error()
891                    .into();
892            }
893            let input_count = f.sig.inputs.len();
894            match input_count {
895                2 => {}
896                3 => {
897                    if let Some(syn::FnArg::Typed(pat_ty)) = f.sig.inputs.iter().nth(2) {
898                        match &*pat_ty.ty {
899                            syn::Type::Path(tp) => {
900                                if let Some(seg) = tp.path.segments.last() {
901                                    if seg.ident == "ReadySignal" {
902                                        takes_ready_signal = true;
903                                    } else {
904                                        return syn::Error::new_spanned(
905                                            &pat_ty.ty,
906                                            "third parameter must be ReadySignal when await_ready=true",
907                                        )
908                                            .to_compile_error()
909                                            .into();
910                                    }
911                                }
912                            }
913                            other => {
914                                return syn::Error::new_spanned(
915                                    other,
916                                    "third parameter must be ReadySignal when await_ready=true",
917                                )
918                                .to_compile_error()
919                                .into();
920                            }
921                        }
922                    }
923                }
924                _ => {
925                    return syn::Error::new_spanned(
926                        f.sig.inputs.clone(),
927                        "invalid runner signature; expected (&self, CancellationToken) or (&self, CancellationToken, ReadySignal)",
928                    )
929                        .to_compile_error()
930                        .into();
931                }
932            }
933        }
934    }
935    if !has_runner {
936        return syn::Error::new(
937            Span::call_site(),
938            format!("runner method `{}` not found in impl", cfg.method),
939        )
940        .to_compile_error()
941        .into();
942    }
943
944    // Duration literal token
945    let timeout_ts = match parse_duration_tokens(&cfg.stop_timeout) {
946        Ok(ts) => ts,
947        Err(e) => return e.to_compile_error().into(),
948    };
949
950    // Generated additions (outside of impl-block)
951    let ty_ident = match ty.segments.last() {
952        Some(seg) => seg.ident.clone(),
953        None => {
954            return syn::Error::new_spanned(
955                &ty,
956                "unsupported impl target: expected a concrete type path",
957            )
958            .to_compile_error()
959            .into();
960        }
961    };
962    let ty_snake = ty_ident.to_string().to_snake_case();
963
964    let ready_shim_ident = format_ident!("__toolkit_run_ready_shim{ty_snake}");
965    let await_ready_bool = cfg.await_ready;
966
967    let extra = if takes_ready_signal {
968        quote! {
969            #[async_trait::async_trait]
970            impl ::toolkit::lifecycle::Runnable for #ty {
971                async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
972                    let (_tx, _rx) = ::toolkit::tokio::sync::oneshot::channel::<()>();
973                    let ready = ::toolkit::lifecycle::ReadySignal::from_sender(_tx);
974                    self.#runner_ident(cancel, ready).await
975                }
976            }
977
978            #[doc(hidden)]
979            #[allow(non_snake_case, dead_code)]
980            fn #ready_shim_ident(
981                this: ::std::sync::Arc<#ty>,
982                cancel: ::tokio_util::sync::CancellationToken,
983                ready: ::toolkit::lifecycle::ReadySignal,
984            ) -> ::core::pin::Pin<Box<dyn ::core::future::Future<Output = ::anyhow::Result<()>> + Send>> {
985                Box::pin(async move { this.#runner_ident(cancel, ready).await })
986            }
987
988            impl #ty {
989                /// Converts this value into a stateful gear wrapper with configured stop-timeout.
990                pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
991                    ::toolkit::lifecycle::WithLifecycle::new(self)
992                        .with_stop_timeout(#timeout_ts)
993                        .with_ready_mode(#await_ready_bool, true, Some(#ready_shim_ident))
994                }
995            }
996        }
997    } else {
998        quote! {
999            #[async_trait::async_trait]
1000            impl ::toolkit::lifecycle::Runnable for #ty {
1001                async fn run(self: ::std::sync::Arc<Self>, cancel: ::tokio_util::sync::CancellationToken) -> ::anyhow::Result<()> {
1002                    self.#runner_ident(cancel).await
1003                }
1004            }
1005
1006            impl #ty {
1007                /// Converts this value into a stateful gear wrapper with configured stop-timeout.
1008                pub fn into_gear(self) -> ::toolkit::lifecycle::WithLifecycle<Self> {
1009                    ::toolkit::lifecycle::WithLifecycle::new(self)
1010                        .with_stop_timeout(#timeout_ts)
1011                        .with_ready_mode(#await_ready_bool, false, None)
1012                }
1013            }
1014        }
1015    };
1016
1017    let out = quote! {
1018        #impl_item
1019        #extra
1020    };
1021    out.into()
1022}
1023
1024fn parse_lifecycle_args(args: Punctuated<Meta, Token![,]>) -> syn::Result<LcCfg> {
1025    let mut method: Option<String> = None;
1026    let mut stop_timeout = "30s".to_owned();
1027    let mut await_ready = false;
1028
1029    for m in args {
1030        match m {
1031            Meta::NameValue(nv) if nv.path.is_ident("method") => {
1032                if let Expr::Lit(el) = nv.value {
1033                    if let Lit::Str(s) = el.lit {
1034                        method = Some(s.value());
1035                    } else {
1036                        return Err(syn::Error::new_spanned(
1037                            el,
1038                            "method must be a string literal",
1039                        ));
1040                    }
1041                } else {
1042                    return Err(syn::Error::new_spanned(
1043                        nv,
1044                        "method must be a string literal",
1045                    ));
1046                }
1047            }
1048            Meta::NameValue(nv) if nv.path.is_ident("stop_timeout") => {
1049                if let Expr::Lit(el) = nv.value {
1050                    if let Lit::Str(s) = el.lit {
1051                        stop_timeout = s.value();
1052                    } else {
1053                        return Err(syn::Error::new_spanned(
1054                            el,
1055                            "stop_timeout must be a string literal like \"45s\"",
1056                        ));
1057                    }
1058                } else {
1059                    return Err(syn::Error::new_spanned(
1060                        nv,
1061                        "stop_timeout must be a string literal like \"45s\"",
1062                    ));
1063                }
1064            }
1065            Meta::NameValue(nv) if nv.path.is_ident("await_ready") => {
1066                if let Expr::Lit(el) = nv.value {
1067                    if let Lit::Bool(b) = el.lit {
1068                        await_ready = b.value();
1069                    } else {
1070                        return Err(syn::Error::new_spanned(
1071                            el,
1072                            "await_ready must be a bool literal (true/false)",
1073                        ));
1074                    }
1075                } else {
1076                    return Err(syn::Error::new_spanned(
1077                        nv,
1078                        "await_ready must be a bool literal (true/false)",
1079                    ));
1080                }
1081            }
1082            Meta::Path(p) if p.is_ident("await_ready") => {
1083                await_ready = true;
1084            }
1085            other => {
1086                return Err(syn::Error::new_spanned(
1087                    other,
1088                    "expected named args: method=\"...\", stop_timeout=\"...\", await_ready=true|false",
1089                ));
1090            }
1091        }
1092    }
1093
1094    let method = method.ok_or_else(|| {
1095        syn::Error::new(
1096            Span::call_site(),
1097            "missing required arg: method=\"runner_name\"",
1098        )
1099    })?;
1100    Ok(LcCfg {
1101        method,
1102        stop_timeout,
1103        await_ready,
1104    })
1105}
1106
1107fn parse_duration_tokens(s: &str) -> syn::Result<proc_macro2::TokenStream> {
1108    let err = || {
1109        syn::Error::new(
1110            Span::call_site(),
1111            format!("invalid duration: {s}. Use e.g. \"500ms\", \"45s\", \"2m\", \"1h\""),
1112        )
1113    };
1114    if let Some(stripped) = s.strip_suffix("ms") {
1115        let v: u64 = stripped.parse().map_err(|_| err())?;
1116        Ok(quote! { ::std::time::Duration::from_millis(#v) })
1117    } else if let Some(stripped) = s.strip_suffix('s') {
1118        let v: u64 = stripped.parse().map_err(|_| err())?;
1119        Ok(quote! { ::std::time::Duration::from_secs(#v) })
1120    } else if let Some(stripped) = s.strip_suffix('m') {
1121        let v: u64 = stripped.parse().map_err(|_| err())?;
1122        Ok(quote! { ::std::time::Duration::from_secs(#v * 60) })
1123    } else if let Some(stripped) = s.strip_suffix('h') {
1124        let v: u64 = stripped.parse().map_err(|_| err())?;
1125        Ok(quote! { ::std::time::Duration::from_secs(#v * 3600) })
1126    } else {
1127        Err(err())
1128    }
1129}
1130
1131fn path_last_is(path: &syn::Path, want: &str) -> bool {
1132    path.segments.last().is_some_and(|s| s.ident == want)
1133}
1134
1135// ============================================================================
1136// Client Generation Macros
1137// ============================================================================
1138
1139/// Generate a gRPC client that wraps a tonic-generated service client
1140///
1141/// This macro generates a client struct that implements an API trait by delegating
1142/// to a tonic gRPC client, converting between domain types and protobuf messages.
1143///
1144/// # Example
1145///
1146/// ```ignore
1147/// #[toolkit::grpc_client(
1148///     api = "crate::contracts::UsersApi",
1149///     tonic = "toolkit_users_v1::users_service_client::UsersServiceClient<tonic::transport::Channel>",
1150///     package = "toolkit.users.v1"
1151/// )]
1152/// pub struct UsersGrpcClient;
1153/// ```
1154///
1155/// This generates:
1156/// - A struct wrapping the tonic client
1157/// - An async `connect(uri)` method
1158/// - A `from_channel(Channel)` constructor
1159/// - Validation that the client implements the API trait
1160///
1161/// Note: The actual trait implementation must be provided manually, as procedural
1162/// macros cannot introspect trait methods from external gears at compile time.
1163/// Each method should convert requests/responses using `.into()`.
1164#[proc_macro_attribute]
1165pub fn grpc_client(attr: TokenStream, item: TokenStream) -> TokenStream {
1166    let config = parse_macro_input!(attr as grpc_client::GrpcClientConfig);
1167    let input = parse_macro_input!(item as DeriveInput);
1168
1169    match grpc_client::expand_grpc_client(config, input) {
1170        Ok(expanded) => TokenStream::from(expanded),
1171        Err(e) => TokenStream::from(e.to_compile_error()),
1172    }
1173}
1174
1175/// Generates API DTO (Data Transfer Object) boilerplate for REST API types.
1176///
1177/// This macro automatically derives the necessary traits and attributes for types
1178/// used in REST API requests and responses, ensuring they follow API conventions.
1179///
1180/// # Arguments
1181///
1182/// - `request` - Marks the type as a request DTO (adds `Deserialize` and `RequestApiDto`)
1183/// - `response` - Marks the type as a response DTO (adds `Serialize` and `ResponseApiDto`)
1184///
1185/// At least one of `request` or `response` must be specified. Both can be used together
1186/// for types that serve as both request and response DTOs.
1187///
1188/// # Generated Code
1189///
1190/// The macro generates:
1191/// - `#[derive(serde::Serialize)]` if `response` is specified
1192/// - `#[derive(serde::Deserialize)]` if `request` is specified
1193/// - `#[derive(utoipa::ToSchema)]` for `OpenAPI` schema generation
1194/// - `#[serde(rename_all = "snake_case")]` to enforce `snake_case` field naming
1195/// - `impl RequestApiDto` if `request` is specified
1196/// - `impl ResponseApiDto` if `response` is specified
1197///
1198/// # Examples
1199///
1200/// ```ignore
1201/// // Request-only DTO
1202/// #[api_dto(request)]
1203/// pub struct CreateUserRequest {
1204///     pub user_name: String,
1205///     pub email: String,
1206/// }
1207///
1208/// // Response-only DTO
1209/// #[api_dto(response)]
1210/// pub struct UserResponse {
1211///     pub id: String,
1212///     pub user_name: String,
1213/// }
1214///
1215/// // Both request and response
1216/// #[api_dto(request, response)]
1217/// pub struct UserDto {
1218///     pub id: String,
1219///     pub user_name: String,
1220/// }
1221/// ```
1222///
1223/// # Field Naming
1224///
1225/// All fields are automatically converted to `snake_case` in JSON serialization,
1226/// regardless of the Rust field name.
1227#[proc_macro_attribute]
1228pub fn api_dto(attr: TokenStream, item: TokenStream) -> TokenStream {
1229    let attrs = parse_macro_input!(attr with Punctuated::<Ident, Token![,]>::parse_terminated);
1230    let input = parse_macro_input!(item as DeriveInput);
1231    TokenStream::from(api_dto::expand_api_dto(&attrs, &input))
1232}
1233
1234/// Marks a struct or enum as a domain model, enforcing DDD boundaries at compile time.
1235///
1236/// This macro:
1237/// - Implements `DomainModel` for the type
1238/// - Validates at compile-time that fields do not use forbidden infrastructure types
1239///
1240/// # Usage
1241///
1242/// ```ignore
1243/// // Note: This example requires `toolkit` crate which is not available in proc-macro doctest context
1244/// use toolkit_macros::domain_model;
1245///
1246/// #[domain_model]
1247/// pub struct User {
1248///     pub id: i64,
1249///     pub email: String,
1250///     pub active: bool,
1251/// }
1252/// ```
1253///
1254/// # Compile-Time Enforcement
1255///
1256/// If any field uses an infrastructure type (e.g., `http::StatusCode`, `sqlx::Pool`),
1257/// the code will fail to compile with a clear error message:
1258///
1259/// ```compile_fail
1260/// use toolkit_macros::domain_model;
1261///
1262/// #[domain_model]
1263/// pub struct BadModel {
1264///     pub status: http::StatusCode,  // ERROR: forbidden crate 'http'
1265/// }
1266/// ```
1267///
1268/// # Forbidden Types
1269///
1270/// The macro blocks types from infrastructure crates:
1271/// - Database: `sqlx::*`, `sea_orm::*`
1272/// - HTTP/Web: `http::*`, `axum::*`, `hyper::*`
1273/// - External clients: `reqwest::*`, `tonic::*`
1274/// - File system: `std::fs::*`, `tokio::fs::*`
1275/// - Database-specific names: `PgPool`, `MySqlPool`, `SqlitePool`, `DatabaseConnection`
1276#[proc_macro_attribute]
1277pub fn domain_model(_attr: TokenStream, item: TokenStream) -> TokenStream {
1278    let input = parse_macro_input!(item as DeriveInput);
1279    TokenStream::from(domain_model::expand_domain_model(&input))
1280}
1281
1282/// Derive macro that implements [`toolkit::var_expand::ExpandVars`].
1283///
1284/// Mark individual `String` or `Option<String>` fields with `#[expand_vars]`
1285/// to have `${VAR}` placeholders expanded from environment variables when
1286/// `expand_vars()` is called.
1287///
1288/// ```ignore
1289/// #[derive(Deserialize, Default, ExpandVars)]
1290/// pub struct MyConfig {
1291///     #[expand_vars]
1292///     pub api_key: String,
1293///     #[expand_vars]
1294///     pub endpoint: Option<String>,
1295///     pub retries: u32, // not expanded
1296/// }
1297/// ```
1298#[proc_macro_derive(ExpandVars, attributes(expand_vars))]
1299pub fn derive_expand_vars(input: TokenStream) -> TokenStream {
1300    let input = parse_macro_input!(input as DeriveInput);
1301    TokenStream::from(expand_vars::derive(&input))
1302}