Skip to main content

modkit_macros/
lib.rs

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