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