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