Skip to main content

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