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