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