Skip to main content

modkit_macros/
lib.rs

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