1use std::{collections::BTreeSet, env, fs, path::PathBuf};
4
5use proc_macro::TokenStream;
6use proc_macro_crate::{FoundCrate, crate_name};
7use quote::{format_ident, quote};
8use serde_json::{Map, Value, json};
9use syn::{
10 Attribute, Data, DeriveInput, Expr, Fields, GenericArgument, Item, ItemFn, ItemImpl,
11 ItemStruct, LitStr, Path, PathArguments, Token, Type, parse_macro_input,
12 punctuated::Punctuated,
13};
14
15struct ModuleAttributes {
16 descriptor: Option<LitStr>,
17 configuration_schema: Option<LitStr>,
18 configuration_defaults: Option<LitStr>,
19 validate: Option<Path>,
20 prepare: Option<Path>,
21 activate: Option<Path>,
22 deactivate: Option<Path>,
23 lifecycle: bool,
24 consumer: bool,
25}
26
27impl syn::parse::Parse for ModuleAttributes {
28 fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
29 if input.is_empty() {
30 return Ok(Self {
31 descriptor: None,
32 configuration_schema: None,
33 configuration_defaults: None,
34 validate: None,
35 prepare: None,
36 activate: None,
37 deactivate: None,
38 lifecycle: false,
39 consumer: false,
40 });
41 }
42 let mut descriptor = None;
43 let mut configuration_schema = None;
44 let mut configuration_defaults = None;
45 let mut validate = None;
46 let mut prepare = None;
47 let mut activate = None;
48 let mut deactivate = None;
49 let mut lifecycle = false;
50 let mut consumer = false;
51 while !input.is_empty() {
52 let name: syn::Ident = input.parse()?;
53 if name == "lifecycle" {
54 if lifecycle {
55 return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
56 }
57 lifecycle = true;
58 if input.is_empty() {
59 break;
60 }
61 input.parse::<Token![,]>()?;
62 continue;
63 }
64 if name == "consumer" {
65 if consumer {
66 return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
67 }
68 consumer = true;
69 if input.is_empty() {
70 break;
71 }
72 input.parse::<Token![,]>()?;
73 continue;
74 }
75 input.parse::<Token![=]>()?;
76 match name.to_string().as_str() {
77 "descriptor" if descriptor.is_none() => descriptor = Some(input.parse()?),
78 "configuration_schema" if configuration_schema.is_none() => {
79 configuration_schema = Some(input.parse()?);
80 }
81 "configuration_defaults" if configuration_defaults.is_none() => {
82 configuration_defaults = Some(input.parse()?);
83 }
84 "validate" if validate.is_none() => validate = Some(input.parse()?),
85 "prepare" if prepare.is_none() => prepare = Some(input.parse()?),
86 "activate" if activate.is_none() => activate = Some(input.parse()?),
87 "deactivate" if deactivate.is_none() => deactivate = Some(input.parse()?),
88 "descriptor"
89 | "configuration_schema"
90 | "configuration_defaults"
91 | "validate"
92 | "prepare"
93 | "activate"
94 | "deactivate" => {
95 return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
96 }
97 _ => {
98 return Err(syn::Error::new(
99 name.span(),
100 "expected `descriptor`, `configuration_schema`, `configuration_defaults`, `validate`, `prepare`, `activate`, `deactivate`, `lifecycle`, or `consumer`",
101 ));
102 }
103 }
104 if input.is_empty() {
105 break;
106 }
107 input.parse::<Token![,]>()?;
108 }
109 Ok(Self {
110 descriptor,
111 configuration_schema,
112 configuration_defaults,
113 validate,
114 prepare,
115 activate,
116 deactivate,
117 lifecycle,
118 consumer,
119 })
120 }
121}
122
123#[proc_macro_attribute]
128pub fn module(attributes: TokenStream, item: TokenStream) -> TokenStream {
129 expand_authoring_item(attributes, item, "Module")
130}
131
132#[proc_macro_attribute]
141pub fn plugin(attributes: TokenStream, item: TokenStream) -> TokenStream {
142 expand_authoring_item(attributes, item, "Plugin")
143}
144
145fn expand_authoring_item(
146 attributes: TokenStream,
147 item: TokenStream,
148 authoring_unit: &str,
149) -> TokenStream {
150 let attributes = match syn::parse::<ModuleAttributes>(attributes) {
151 Ok(attributes) => attributes,
152 Err(error) => return authoring_error(&error, authoring_unit),
153 };
154 let item = match syn::parse::<Item>(item) {
155 Ok(item) => item,
156 Err(error) => return authoring_error(&error, authoring_unit),
157 };
158 let expanded = match item {
159 Item::Fn(function) => expand_module_function(&attributes, &function),
160 Item::Struct(module) => expand_module_struct(&attributes, module),
161 other => Err(syn::Error::new_spanned(
162 other,
163 format!(
164 "a native {authoring_unit} must be declared by a factory function or a named-field struct"
165 ),
166 )),
167 };
168 match expanded {
169 Ok(tokens) => tokens.into(),
170 Err(error) => authoring_error(&error, authoring_unit),
171 }
172}
173
174fn authoring_error(error: &syn::Error, authoring_unit: &str) -> TokenStream {
175 let message = if authoring_unit == "Plugin" {
176 error.to_string().replace("Module", "Plugin")
177 } else {
178 error.to_string()
179 };
180 syn::Error::new(error.span(), message)
181 .into_compile_error()
182 .into()
183}
184
185#[proc_macro_derive(ModuleConfig, attributes(lenso, serde))]
187pub fn module_config(item: TokenStream) -> TokenStream {
188 let input = parse_macro_input!(item as DeriveInput);
189 expand_module_config(&input)
190 .unwrap_or_else(syn::Error::into_compile_error)
191 .into()
192}
193
194#[proc_macro_derive(PluginConfig, attributes(lenso, serde))]
196pub fn plugin_config(item: TokenStream) -> TokenStream {
197 let input = match syn::parse::<DeriveInput>(item) {
198 Ok(input) => input,
199 Err(error) => return authoring_error(&error, "Plugin"),
200 };
201 match expand_module_config(&input) {
202 Ok(tokens) => tokens.into(),
203 Err(error) => authoring_error(&error, "Plugin"),
204 }
205}
206
207fn expand_module_config(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
208 let Data::Struct(data) = &input.data else {
209 return Err(syn::Error::new_spanned(
210 input,
211 "Module configuration must be a named-field struct",
212 ));
213 };
214 let Fields::Named(fields) = &data.fields else {
215 return Err(syn::Error::new_spanned(
216 &data.fields,
217 "Module configuration must use named fields",
218 ));
219 };
220 let mut properties = Map::new();
221 let mut defaults = Map::new();
222 let mut required = Vec::new();
223 for field in &fields.named {
224 let ident = field.ident.as_ref().expect("named fields have identifiers");
225 let name = serde_field_name(&field.attrs, ident)?;
226 let (schema, optional) = configuration_type_schema(&field.ty)?;
227 if let Some(default) = configuration_field_default(&field.attrs)? {
228 if !configuration_value_matches_schema(&default, &schema) {
229 return Err(syn::Error::new_spanned(
230 field,
231 "Module configuration default does not match the field type",
232 ));
233 }
234 defaults.insert(name.clone(), default);
235 }
236 properties.insert(name.clone(), schema);
237 if !optional {
238 required.push(Value::String(name));
239 }
240 }
241 let schema = canonical_json(&json!({
242 "$schema": "https://json-schema.org/draft/2020-12/schema",
243 "type": "object",
244 "additionalProperties": false,
245 "required": required,
246 "properties": properties,
247 }));
248 let defaults = canonical_json(&Value::Object(defaults));
249 let macro_name = format_ident!("__lenso_config_schema_{}", snake(&input.ident.to_string()));
250 let defaults_macro_name = format_ident!(
251 "__lenso_config_defaults_{}",
252 snake(&input.ident.to_string())
253 );
254 Ok(quote! {
255 #[doc(hidden)]
256 #[macro_export]
257 macro_rules! #macro_name {
258 () => { #schema };
259 }
260 #[doc(hidden)]
261 #[macro_export]
262 macro_rules! #defaults_macro_name {
263 () => { #defaults };
264 }
265 })
266}
267
268fn configuration_field_default(attributes: &[Attribute]) -> syn::Result<Option<Value>> {
269 let mut default = None;
270 for attribute in attributes {
271 if !attribute.path().is_ident("lenso") {
272 continue;
273 }
274 attribute.parse_nested_meta(|meta| {
275 if !meta.path.is_ident("default") {
276 return Err(meta.error("expected `default = <JSON literal>`"));
277 }
278 if default.is_some() {
279 return Err(meta.error("duplicate Module configuration default"));
280 }
281 let expression = meta.value()?.parse::<Expr>()?;
282 let encoded = quote!(#expression).to_string();
283 default = Some(serde_json::from_str(&encoded).map_err(|error| {
284 meta.error(format!(
285 "Module configuration default must be a JSON literal: {error}"
286 ))
287 })?);
288 Ok(())
289 })?;
290 }
291 Ok(default)
292}
293
294fn configuration_value_matches_schema(value: &Value, schema: &Value) -> bool {
295 match schema.get("type").and_then(Value::as_str) {
296 Some("array") => value.as_array().is_some_and(|items| {
297 schema.get("items").is_some_and(|schema| {
298 items
299 .iter()
300 .all(|item| configuration_value_matches_schema(item, schema))
301 })
302 }),
303 Some("boolean") => value.is_boolean(),
304 Some("integer") => value
305 .as_number()
306 .is_some_and(|number| number.is_i64() || number.is_u64()),
307 Some("number") => value.is_number(),
308 Some("string") => value.is_string(),
309 _ => false,
310 }
311}
312
313fn serde_field_name(attributes: &[Attribute], ident: &syn::Ident) -> syn::Result<String> {
314 let mut name = ident.to_string();
315 for attribute in attributes {
316 if !attribute.path().is_ident("serde") {
317 continue;
318 }
319 attribute.parse_nested_meta(|meta| {
320 if meta.path.is_ident("rename") {
321 name = meta.value()?.parse::<LitStr>()?.value();
322 }
323 Ok(())
324 })?;
325 }
326 Ok(name)
327}
328
329fn configuration_type_schema(ty: &Type) -> syn::Result<(Value, bool)> {
330 let Type::Path(path) = ty else {
331 return Err(syn::Error::new_spanned(
332 ty,
333 "Module configuration fields must use portable named types",
334 ));
335 };
336 let segment = path.path.segments.last().expect("type paths are non-empty");
337 let name = segment.ident.to_string();
338 if name == "Option" {
339 return Ok((configuration_inner_schema(segment, ty)?, true));
340 }
341 if name == "Vec" {
342 return Ok((
343 json!({"type": "array", "items": configuration_inner_schema(segment, ty)?}),
344 false,
345 ));
346 }
347 let schema = match name.as_str() {
348 "String" => json!({"type": "string"}),
349 "bool" => json!({"type": "boolean"}),
350 "f32" | "f64" => json!({"type": "number"}),
351 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
352 | "usize" => json!({"type": "integer"}),
353 _ => {
354 return Err(syn::Error::new_spanned(
355 ty,
356 "unsupported Module configuration field type; use String, bool, a number, Option<T>, or Vec<T>",
357 ));
358 }
359 };
360 Ok((schema, false))
361}
362
363fn configuration_inner_schema(segment: &syn::PathSegment, ty: &Type) -> syn::Result<Value> {
364 let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
365 return Err(syn::Error::new_spanned(
366 ty,
367 "configuration container requires one type",
368 ));
369 };
370 let [GenericArgument::Type(inner)] = arguments.args.iter().collect::<Vec<_>>().as_slice()
371 else {
372 return Err(syn::Error::new_spanned(
373 ty,
374 "configuration container requires one type",
375 ));
376 };
377 configuration_type_schema(inner).map(|(schema, _)| schema)
378}
379
380fn expand_module_function(
381 attributes: &ModuleAttributes,
382 function: &ItemFn,
383) -> syn::Result<proc_macro2::TokenStream> {
384 let sdk = authoring_crate();
385 if attributes.descriptor.is_none()
386 && (attributes.configuration_schema.is_some()
387 || attributes.configuration_defaults.is_some())
388 {
389 return Err(syn::Error::new_spanned(
390 function,
391 "`configuration_schema` and `configuration_defaults` require `descriptor` on a factory function",
392 ));
393 }
394 if attributes.validate.is_some()
395 || attributes.prepare.is_some()
396 || attributes.activate.is_some()
397 || attributes.deactivate.is_some()
398 || attributes.lifecycle
399 || attributes.consumer
400 {
401 return Err(syn::Error::new_spanned(
402 function,
403 "struct-level Module attributes are unavailable on factory functions",
404 ));
405 }
406 let package_id = package_id()?;
407 let descriptor_json = attributes
408 .descriptor
409 .as_ref()
410 .map(|descriptor| {
411 module_descriptor(
412 &package_id,
413 descriptor,
414 attributes.configuration_schema.as_ref(),
415 attributes.configuration_defaults.as_ref(),
416 )
417 })
418 .transpose()?;
419 let function_name = &function.sig.ident;
420 let generated_module = format_ident!("__lenso_module_{function_name}");
421 let descriptor_constant = descriptor_json.map(|descriptor| {
422 let artifact =
423 format!("LENSO_MODULE_DESCRIPTOR_V1\0{descriptor}\0END_LENSO_MODULE_DESCRIPTOR_V1");
424 let artifact_length = artifact.len();
425 let artifact = proc_macro2::Literal::byte_string(artifact.as_bytes());
426 let package_file_tracking = package_file_tracking([
427 attributes.configuration_schema.as_ref(),
428 attributes.configuration_defaults.as_ref(),
429 ]);
430 quote! {
431 pub const MODULE_DESCRIPTOR_JSON: &str = #descriptor;
433 #[doc(hidden)]
435 #[used]
436 pub static __LENSO_MODULE_DESCRIPTOR_ARTIFACT: [u8; #artifact_length] = *#artifact;
437 #(#package_file_tracking)*
438 }
439 });
440
441 Ok(quote! {
442 pub const PACKAGE_ID: &str = #package_id;
444 pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
446 pub const FACTORY_IDENTITY: &str = concat!(#package_id, "@", env!("CARGO_PKG_VERSION"));
448 #descriptor_constant
449
450 #function
451
452 #[doc(hidden)]
453 mod #generated_module {
454 #[derive(Clone, Copy, Debug, Default)]
455 struct Factory;
456
457 impl #sdk::__private::NativeModuleFactory for Factory {
458 fn package_id(&self) -> &'static str {
459 #package_id
460 }
461
462 fn package_version(&self) -> &'static str {
463 env!("CARGO_PKG_VERSION")
464 }
465
466 fn instantiate(
467 &self,
468 context: #sdk::__private::NativeModuleFactoryContext<'_>,
469 ) -> Result<
470 #sdk::__private::NativeModuleInstance,
471 #sdk::__private::RuntimeFailure,
472 > {
473 super::#function_name(context)
474 }
475 }
476
477 fn factory() -> std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
478 std::rc::Rc::new(Factory)
479 }
480
481 #sdk::__private::__inventory::submit! {
482 #sdk::__private::LinkedNativeModuleFactory::new(factory)
483 }
484
485 const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
487 }
488 })
489}
490
491#[proc_macro_attribute]
499pub fn provides(attributes: TokenStream, item: TokenStream) -> TokenStream {
500 let capabilities =
501 parse_macro_input!(attributes with Punctuated::<Path, Token![,]>::parse_terminated);
502 let implementation = parse_macro_input!(item as ItemImpl);
503 expand_provides(
504 &capabilities.into_iter().collect::<Vec<_>>(),
505 &implementation,
506 )
507 .unwrap_or_else(syn::Error::into_compile_error)
508 .into()
509}
510
511struct CapabilityContribution {
512 namespace: Path,
513 descriptor: syn::Ident,
514 endpoints: syn::Ident,
515 lower: syn::Ident,
516}
517
518fn capability_contributions(capabilities: &[Path]) -> syn::Result<Vec<CapabilityContribution>> {
519 let mut seen = BTreeSet::new();
520 capabilities
521 .iter()
522 .map(|capability| {
523 let path = quote!(#capability).to_string();
524 if !seen.insert(path) {
525 return Err(syn::Error::new_spanned(
526 capability,
527 "a Module cannot provide the same Capability more than once",
528 ));
529 }
530 let mut namespace = capability.clone();
531 let capability_ident = namespace
532 .segments
533 .pop()
534 .ok_or_else(|| syn::Error::new_spanned(capability, "Capability path is empty"))?
535 .into_value()
536 .ident;
537 namespace.segments.pop_punct();
538 if namespace.segments.is_empty() {
539 return Err(syn::Error::new_spanned(
540 capability,
541 "Capability must be namespace-qualified, for example `agent::Agent`",
542 ));
543 }
544 let capability_snake = snake(&capability_ident.to_string());
545 Ok(CapabilityContribution {
546 namespace,
547 descriptor: format_ident!("__lenso_provided_{capability_snake}"),
548 endpoints: format_ident!("__lenso_native_endpoints_{capability_snake}"),
549 lower: format_ident!("__lenso_native_lower_{capability_snake}"),
550 })
551 })
552 .collect()
553}
554
555fn provided_module(
556 capabilities: &[Path],
557 implementation: &ItemImpl,
558) -> syn::Result<(syn::Ident, bool)> {
559 if capabilities.is_empty() {
560 return Err(syn::Error::new_spanned(
561 implementation,
562 "`provides` requires at least one namespace-qualified Capability",
563 ));
564 }
565 if capabilities.len() > 1 && implementation.trait_.is_some() {
566 return Err(syn::Error::new_spanned(
567 implementation,
568 "multiple Capabilities require one inherent impl containing their domain methods",
569 ));
570 }
571 let Type::Path(module_type) = implementation.self_ty.as_ref() else {
572 return Err(syn::Error::new_spanned(
573 &implementation.self_ty,
574 "the Module provider type must be a path",
575 ));
576 };
577 let module_ident = module_type
578 .path
579 .segments
580 .last()
581 .ok_or_else(|| {
582 syn::Error::new_spanned(&module_type.path, "the Module provider type is empty")
583 })?
584 .ident
585 .clone();
586 Ok((module_ident, implementation.trait_.is_none()))
587}
588
589fn expand_provides(
590 capabilities: &[Path],
591 implementation: &ItemImpl,
592) -> syn::Result<proc_macro2::TokenStream> {
593 let sdk = authoring_crate();
594 let (module_ident, lowers_domain_methods) = provided_module(capabilities, implementation)?;
595 let contributions = capability_contributions(capabilities)?;
596 let provided_descriptors = contributions
597 .iter()
598 .map(|contribution| {
599 let namespace = &contribution.namespace;
600 let descriptor = &contribution.descriptor;
601 quote!(#namespace::#descriptor!())
602 })
603 .collect::<Vec<_>>();
604 let module_descriptor = format_ident!(
605 "__lenso_module_descriptor_{}",
606 snake(&module_ident.to_string())
607 );
608 let generated_module = format_ident!("__lenso_provider_{}", snake(&module_ident.to_string()));
609 let lifecycle = format_ident!("__LensoLifecycle{module_ident}");
610 let artifact = format_ident!("__LENSO_MODULE_DESCRIPTOR_ARTIFACT_{module_ident}");
611 let provider_implementations = if lowers_domain_methods {
612 contributions
613 .iter()
614 .map(|contribution| {
615 let namespace = &contribution.namespace;
616 let lower = &contribution.lower;
617 quote! { #namespace::#lower!(#module_ident, #sdk::__private); }
618 })
619 .collect::<Vec<_>>()
620 } else {
621 Vec::new()
622 };
623 let endpoint_contributions = contributions.iter().map(|contribution| {
624 let namespace = &contribution.namespace;
625 let endpoints = &contribution.endpoints;
626 quote! {
627 let (provided_requests, provided_streams, provided_events) =
628 super::#namespace::#endpoints!(module.clone(), #sdk::__private);
629 request_endpoints.extend(provided_requests);
630 stream_endpoints.extend(provided_streams);
631 event_endpoints.extend(provided_events);
632 }
633 });
634
635 let mut implementation = implementation.clone();
636 implementation
637 .attrs
638 .push(syn::parse_quote!(#[allow(clippy::unused_async, clippy::unused_async_trait_impl)]));
639
640 Ok(quote! {
641 #implementation
642 #(#provider_implementations)*
643
644 pub const MODULE_DESCRIPTOR_JSON: &str = #module_descriptor!(
646 #(#provided_descriptors),*
647 );
648 #[doc(hidden)]
649 const __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
650 "LENSO_MODULE_DESCRIPTOR_V1\0",
651 #module_descriptor!(#(#provided_descriptors),*),
652 "\0END_LENSO_MODULE_DESCRIPTOR_V1",
653 );
654 #[doc(hidden)]
656 #[used]
657 pub static #artifact: &[u8] = __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
658
659 #[doc(hidden)]
660 mod #generated_module {
661 #[derive(Clone, Copy, Debug, Default)]
662 struct Factory;
663
664 impl #sdk::__private::NativeModuleFactory for Factory {
665 fn package_id(&self) -> &'static str { super::PACKAGE_ID }
666 fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
667
668 fn instantiate(
669 &self,
670 context: #sdk::__private::NativeModuleFactoryContext<'_>,
671 ) -> Result<
672 #sdk::__private::NativeModuleInstance,
673 #sdk::__private::RuntimeFailure,
674 > {
675 let module = super::#module_ident::__lenso_construct(context)?;
676 let lifecycle = super::#lifecycle { module: module.clone() };
677 let mut request_endpoints = Vec::new();
678 let mut stream_endpoints = Vec::new();
679 let mut event_endpoints = Vec::new();
680 #(#endpoint_contributions)*
681 Ok(#sdk::__private::NativeModuleInstance::with_all_endpoints(
682 request_endpoints,
683 stream_endpoints,
684 event_endpoints,
685 lifecycle,
686 ))
687 }
688 }
689
690 fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
691 ::std::rc::Rc::new(Factory)
692 }
693
694 #sdk::__private::__inventory::submit! {
695 #sdk::__private::LinkedNativeModuleFactory::new(factory)
696 }
697 }
698 })
699}
700
701#[allow(clippy::too_many_lines)]
702fn expand_module_struct(
703 attributes: &ModuleAttributes,
704 mut module: ItemStruct,
705) -> syn::Result<proc_macro2::TokenStream> {
706 let sdk = authoring_crate();
707 if attributes.descriptor.is_some() {
708 return Err(syn::Error::new_spanned(
709 &module.ident,
710 "struct-level Modules derive their Descriptor; remove `descriptor`",
711 ));
712 }
713 let package_id = package_id()?;
714 let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
715 syn::Error::new_spanned(
716 &module.ident,
717 "CARGO_PKG_VERSION is unavailable while deriving Module Descriptor",
718 )
719 })?;
720 let StructFields {
721 config_type,
722 ports,
723 tasks,
724 initializers,
725 } = analyze_struct_fields(&mut module)?;
726 let schema = configuration_schema_tokens(
727 attributes.configuration_schema.as_ref(),
728 config_type.as_ref(),
729 )?;
730 let configuration_defaults = configuration_defaults_tokens(
731 attributes.configuration_schema.as_ref(),
732 attributes.configuration_defaults.as_ref(),
733 config_type.as_ref(),
734 )?;
735 let name = &module.ident;
736 let lifecycle_name = format_ident!("__LensoLifecycle{name}");
737 let descriptor_macro = format_ident!("__lenso_module_descriptor_{}", snake(&name.to_string()));
738 let requirement_macros = ports
739 .iter()
740 .map(|(_, client, cardinality)| requirement_macro(client, *cardinality))
741 .collect::<syn::Result<Vec<_>>>()?;
742 let connect_ports = ports.iter().map(|(field, _, _)| {
743 quote! { self.module.#field.connect(context.dependencies())?; }
744 });
745 let connect_tasks = task_connectors(&tasks);
746 let requirement_parts = intersperse_commas(requirement_macros);
747 let (prefix, after_schema, suffix, defaults) =
748 descriptor_affixes(&package_id, &package_version);
749 let construct_configuration = if let Some(config_type) = &config_type {
750 let validate = attributes
751 .validate
752 .as_ref()
753 .map(|path| quote!(#path(&configuration)?;));
754 quote! {
755 let configuration = #sdk::__private::serde_json::from_str::<#config_type>(context.configuration())
756 .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
757 detail: format!("invalid {} configuration: {error}", #package_id),
758 })?;
759 #validate
760 }
761 } else {
762 if attributes.configuration_schema.is_some() {
763 return Err(syn::Error::new_spanned(
764 &module.ident,
765 "`configuration_schema` requires a `#[config]` field",
766 ));
767 }
768 if attributes.validate.is_some() {
769 return Err(syn::Error::new_spanned(
770 &module.ident,
771 "`validate` requires a `#[config]` field",
772 ));
773 }
774 if attributes.configuration_defaults.is_some() {
775 return Err(syn::Error::new_spanned(
776 &module.ident,
777 "`configuration_defaults` requires a `#[config]` field",
778 ));
779 }
780 quote! {
781 let configuration = #sdk::__private::serde_json::from_str::<#sdk::__private::serde_json::Value>(context.configuration())
782 .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
783 detail: format!("invalid {} configuration: {error}", #package_id),
784 })?;
785 if !configuration.as_object().is_some_and(|object| object.is_empty()) {
786 return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
787 detail: format!("{} does not accept configuration", #package_id),
788 });
789 }
790 }
791 };
792 if attributes.lifecycle
793 && (attributes.prepare.is_some()
794 || attributes.activate.is_some()
795 || attributes.deactivate.is_some())
796 {
797 return Err(syn::Error::new_spanned(
798 &module.ident,
799 "`lifecycle` replaces the `prepare`, `activate`, and `deactivate` function attributes",
800 ));
801 }
802 let prepare = if attributes.lifecycle {
803 quote! {
804 let module = self.module.clone();
805 Box::pin(async move { #sdk::Lifecycle::prepare(&module, context).await })
806 }
807 } else {
808 hook(attributes.prepare.as_ref(), &sdk)
809 };
810 let activate_hook = if attributes.lifecycle {
811 quote! {
812 let module = self.module.clone();
813 Box::pin(async move { #sdk::Lifecycle::activate(&module, context).await })
814 }
815 } else {
816 hook(attributes.activate.as_ref(), &sdk)
817 };
818 let deactivate_hook = if attributes.lifecycle {
819 quote! {
820 let module = self.module.clone();
821 Box::pin(async move { #sdk::Lifecycle::deactivate(&module, context).await })
822 }
823 } else {
824 hook(attributes.deactivate.as_ref(), &sdk)
825 };
826 let disconnect_tasks = task_disconnectors(&tasks);
827 let activate = if tasks.is_empty() {
828 activate_hook
829 } else {
830 quote! {
831 let module = self.module.clone();
832 let activation = { #activate_hook };
833 Box::pin(async move {
834 let result = activation.await;
835 if result.is_err() {
836 #(#disconnect_tasks)*
837 }
838 result
839 })
840 }
841 };
842 let disconnect_tasks = task_disconnectors(&tasks);
843 let deactivate = if tasks.is_empty() {
844 deactivate_hook
845 } else {
846 quote! {
847 let module = self.module.clone();
848 #(#disconnect_tasks)*
849 let deactivation = { #deactivate_hook };
850 deactivation
851 }
852 };
853 let package_file_tracking = package_file_tracking([
854 attributes.configuration_schema.as_ref(),
855 attributes.configuration_defaults.as_ref(),
856 ]);
857 let consumer_finalizer = if attributes.consumer {
858 let generated_module = format_ident!("__lenso_consumer_{}", snake(&name.to_string()));
859 let artifact = format_ident!("__LENSO_MODULE_DESCRIPTOR_ARTIFACT_{name}");
860 Some(quote! {
861 pub const MODULE_DESCRIPTOR_JSON: &str = #descriptor_macro!();
863 #[doc(hidden)]
864 const __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
865 "LENSO_MODULE_DESCRIPTOR_V1\0",
866 #descriptor_macro!(),
867 "\0END_LENSO_MODULE_DESCRIPTOR_V1",
868 );
869 #[doc(hidden)]
871 #[used]
872 pub static #artifact: &[u8] = __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
873
874 #[doc(hidden)]
875 mod #generated_module {
876 #[derive(Clone, Copy, Debug, Default)]
877 struct Factory;
878
879 impl #sdk::__private::NativeModuleFactory for Factory {
880 fn package_id(&self) -> &'static str { super::PACKAGE_ID }
881 fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
882
883 fn instantiate(
884 &self,
885 context: #sdk::__private::NativeModuleFactoryContext<'_>,
886 ) -> Result<
887 #sdk::__private::NativeModuleInstance,
888 #sdk::__private::RuntimeFailure,
889 > {
890 let module = super::#name::__lenso_construct(context)?;
891 let lifecycle = super::#lifecycle_name { module };
892 Ok(#sdk::__private::NativeModuleInstance::with_lifecycle(
893 Vec::new(),
894 lifecycle,
895 ))
896 }
897 }
898
899 fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
900 ::std::rc::Rc::new(Factory)
901 }
902
903 #sdk::__private::__inventory::submit! {
904 #sdk::__private::LinkedNativeModuleFactory::new(factory)
905 }
906 }
907 })
908 } else {
909 None
910 };
911
912 Ok(quote! {
913 pub const PACKAGE_ID: &str = #package_id;
915 pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
917 pub const FACTORY_IDENTITY: &str = concat!(#package_id, "@", env!("CARGO_PKG_VERSION"));
919
920 #module
921
922 #[doc(hidden)]
923 macro_rules! #descriptor_macro {
924 () => {
925 concat!(#prefix, #schema, ",\"configuration_defaults\":", #configuration_defaults, #after_schema, #suffix #(, #requirement_parts)*, #defaults)
926 };
927 ($first:expr $(, $rest:expr)*) => {
928 concat!(#prefix, #schema, ",\"configuration_defaults\":", #configuration_defaults, #after_schema, $first $(, ",", $rest)*, #suffix #(, #requirement_parts)*, #defaults)
929 };
930 }
931
932 impl #name {
933 #[doc(hidden)]
934 fn __lenso_construct(
935 context: #sdk::__private::NativeModuleFactoryContext<'_>,
936 ) -> Result<Self, #sdk::__private::RuntimeFailure> {
937 if context.entrypoint() != "default" {
938 return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
939 detail: format!("unsupported {} entrypoint {}", #package_id, context.entrypoint()),
940 });
941 }
942 #construct_configuration
943 Ok(Self { #(#initializers),* })
944 }
945 }
946
947 #[doc(hidden)]
948 #[derive(Clone, Debug)]
949 struct #lifecycle_name {
950 module: #name,
951 }
952
953 impl #sdk::__private::ModuleLifecycle for #lifecycle_name {
954 fn prepare(&self, context: #sdk::__private::PrepareContext) -> #sdk::__private::ModuleFuture {
955 #prepare
956 }
957
958 fn activate(&self, context: #sdk::__private::ActivateContext) -> #sdk::__private::ModuleFuture {
959 let connected = (|| -> Result<(), #sdk::__private::RuntimeFailure> {
960 #(#connect_ports)*
961 #(#connect_tasks)*
962 Ok(())
963 })();
964 if let Err(error) = connected {
965 return Box::pin(#sdk::__private::futures::future::ready(Err(error)));
966 }
967 #activate
968 }
969
970 fn deactivate(&self, context: #sdk::__private::DeactivateContext) -> #sdk::__private::ModuleFuture {
971 #deactivate
972 }
973 }
974
975 const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
976 #(#package_file_tracking)*
977
978 #consumer_finalizer
979 })
980}
981
982fn descriptor_affixes(
983 package_id: &str,
984 package_version: &str,
985) -> (String, &'static str, &'static str, &'static str) {
986 let prefix = format!(
987 "{{\"package_id\":{},\"package_revision\":{},\"entrypoint\":\"default\",\"configuration_schema\":",
988 serde_json::to_string(package_id).expect("package ID serializes"),
989 serde_json::to_string(package_version).expect("package version serializes"),
990 );
991 let after_schema = ",\"provided_capabilities\":[";
992 let suffix = "],\"required_capabilities\":[";
993 let defaults = "],\"execution_class\":\"lenso.native-rust@1\",\"restart_policy\":{\"mode\":\"never\",\"max_attempts\":0,\"window\":{\"secs\":0,\"nanos\":0},\"backoff\":{\"secs\":0,\"nanos\":0},\"stability\":{\"secs\":0,\"nanos\":0},\"jitter\":{\"secs\":0,\"nanos\":0}},\"criticality\":\"non_critical\"}";
994 (prefix, after_schema, suffix, defaults)
995}
996
997fn package_file_tracking<'a>(
998 paths: impl IntoIterator<Item = Option<&'a LitStr>>,
999) -> Vec<proc_macro2::TokenStream> {
1000 paths
1001 .into_iter()
1002 .flatten()
1003 .map(|path| {
1004 quote!(
1005 const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #path));
1006 )
1007 })
1008 .collect()
1009}
1010
1011fn configuration_schema_tokens(
1012 schema_path: Option<&LitStr>,
1013 config_type: Option<&Type>,
1014) -> syn::Result<proc_macro2::TokenStream> {
1015 if let Some(path) = schema_path {
1016 let schema = canonical_json(&read_configuration_schema(path)?);
1017 return Ok(quote!(#schema));
1018 }
1019 let Some(config_type) = config_type else {
1020 let schema = canonical_json(&json!({
1021 "$schema": "https://json-schema.org/draft/2020-12/schema",
1022 "type": "object",
1023 "additionalProperties": false,
1024 "required": [],
1025 "properties": {},
1026 }));
1027 return Ok(quote!(#schema));
1028 };
1029 let Type::Path(config) = config_type else {
1030 return Err(syn::Error::new_spanned(
1031 config_type,
1032 "the `#[config]` field type must be a path",
1033 ));
1034 };
1035 let mut namespace = config.path.clone();
1036 let config_name = namespace
1037 .segments
1038 .pop()
1039 .expect("type paths are non-empty")
1040 .into_value()
1041 .ident;
1042 namespace.segments.pop_punct();
1043 let macro_name = format_ident!("__lenso_config_schema_{}", snake(&config_name.to_string()));
1044 if namespace.segments.is_empty() {
1045 Ok(quote!(#macro_name!()))
1046 } else {
1047 Ok(quote!(#namespace::#macro_name!()))
1048 }
1049}
1050
1051fn configuration_defaults_tokens(
1052 schema_path: Option<&LitStr>,
1053 defaults_path: Option<&LitStr>,
1054 config_type: Option<&Type>,
1055) -> syn::Result<proc_macro2::TokenStream> {
1056 if let Some(path) = defaults_path {
1057 if schema_path.is_none() {
1058 return Err(syn::Error::new(
1059 path.span(),
1060 "`configuration_defaults` requires an explicit `configuration_schema`",
1061 ));
1062 }
1063 let defaults = read_configuration_defaults(path)?;
1064 let schema = read_configuration_schema(schema_path.expect("checked above"))?;
1065 validate_configuration_defaults(&defaults, &schema).map_err(|detail| {
1066 syn::Error::new(
1067 path.span(),
1068 format!("invalid package configuration defaults: {detail}"),
1069 )
1070 })?;
1071 let defaults = canonical_json(&defaults);
1072 return Ok(quote!(#defaults));
1073 }
1074 if schema_path.is_some() || config_type.is_none() {
1075 let defaults = canonical_json(&json!({}));
1076 return Ok(quote!(#defaults));
1077 }
1078 let config_type = config_type.expect("checked above");
1079 let Type::Path(config) = config_type else {
1080 return Err(syn::Error::new_spanned(
1081 config_type,
1082 "the `#[config]` field type must be a path",
1083 ));
1084 };
1085 let mut namespace = config.path.clone();
1086 let config_name = namespace
1087 .segments
1088 .pop()
1089 .expect("type paths are non-empty")
1090 .into_value()
1091 .ident;
1092 namespace.segments.pop_punct();
1093 let macro_name = format_ident!(
1094 "__lenso_config_defaults_{}",
1095 snake(&config_name.to_string())
1096 );
1097 if namespace.segments.is_empty() {
1098 Ok(quote!(#macro_name!()))
1099 } else {
1100 Ok(quote!(#namespace::#macro_name!()))
1101 }
1102}
1103
1104struct StructFields {
1105 config_type: Option<Type>,
1106 ports: Vec<(syn::Ident, Path, PortCardinality)>,
1107 tasks: Vec<syn::Ident>,
1108 initializers: Vec<proc_macro2::TokenStream>,
1109}
1110
1111#[derive(Clone, Copy)]
1112enum PortCardinality {
1113 One,
1114 Many,
1115}
1116
1117fn analyze_struct_fields(module: &mut ItemStruct) -> syn::Result<StructFields> {
1118 let Fields::Named(fields) = &mut module.fields else {
1119 return Err(syn::Error::new_spanned(
1120 &module.fields,
1121 "a struct-level Module requires named fields",
1122 ));
1123 };
1124 let mut config = None;
1125 let mut ports = Vec::new();
1126 let mut tasks = Vec::new();
1127 let mut initializers = Vec::new();
1128 for field in &mut fields.named {
1129 let name = field.ident.as_ref().expect("named fields have identifiers");
1130 let is_config = take_marker(&mut field.attrs, "config");
1131 let is_tasks = take_marker(&mut field.attrs, "tasks");
1132 if is_config && is_tasks {
1133 return Err(syn::Error::new_spanned(
1134 field,
1135 "a Module field cannot be both `#[config]` and `#[tasks]`",
1136 ));
1137 }
1138 if is_config {
1139 if config.replace(field.ty.clone()).is_some() {
1140 return Err(syn::Error::new_spanned(
1141 field,
1142 "a Module has exactly one `#[config]` field",
1143 ));
1144 }
1145 initializers.push(quote!(#name: configuration));
1146 } else if is_tasks {
1147 if !is_named_type(&field.ty, "ManagedTasks") {
1148 return Err(syn::Error::new_spanned(
1149 &field.ty,
1150 "a `#[tasks]` field must have type `ManagedTasks`",
1151 ));
1152 }
1153 if !tasks.is_empty() {
1154 return Err(syn::Error::new_spanned(
1155 field,
1156 "a Module has at most one `#[tasks]` field",
1157 ));
1158 }
1159 tasks.push(name.clone());
1160 initializers.push(quote!(#name: ::core::default::Default::default()));
1161 } else if let Some((client, cardinality)) = port_client(&field.ty)? {
1162 ports.push((name.clone(), client, cardinality));
1163 initializers.push(quote!(#name: ::core::default::Default::default()));
1164 } else {
1165 initializers.push(quote!(#name: ::core::default::Default::default()));
1166 }
1167 }
1168 Ok(StructFields {
1169 config_type: config,
1170 ports,
1171 tasks,
1172 initializers,
1173 })
1174}
1175
1176fn is_named_type(ty: &Type, expected: &str) -> bool {
1177 let Type::Path(path) = ty else {
1178 return false;
1179 };
1180 path.path
1181 .segments
1182 .last()
1183 .is_some_and(|segment| segment.ident == expected && segment.arguments.is_empty())
1184}
1185
1186fn take_marker(attributes: &mut Vec<Attribute>, name: &str) -> bool {
1187 let present = attributes
1188 .iter()
1189 .any(|attribute| attribute.path().is_ident(name));
1190 attributes.retain(|attribute| !attribute.path().is_ident(name));
1191 present
1192}
1193
1194fn task_connectors(tasks: &[syn::Ident]) -> Vec<proc_macro2::TokenStream> {
1195 tasks
1196 .iter()
1197 .map(|field| {
1198 quote! { self.module.#field.__lenso_connect(context.tasks().clone())?; }
1199 })
1200 .collect()
1201}
1202
1203fn task_disconnectors(tasks: &[syn::Ident]) -> Vec<proc_macro2::TokenStream> {
1204 tasks
1205 .iter()
1206 .map(|field| quote! { module.#field.__lenso_disconnect(); })
1207 .collect()
1208}
1209
1210fn port_client(ty: &Type) -> syn::Result<Option<(Path, PortCardinality)>> {
1211 let Type::Path(path) = ty else {
1212 return Ok(None);
1213 };
1214 let Some(segment) = path.path.segments.last() else {
1215 return Ok(None);
1216 };
1217 let cardinality = if segment.ident == "Port" {
1218 PortCardinality::One
1219 } else if segment.ident == "ManyPort" {
1220 PortCardinality::Many
1221 } else {
1222 return Ok(None);
1223 };
1224 let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
1225 return Err(syn::Error::new_spanned(
1226 ty,
1227 "Port or ManyPort requires one Capability client type",
1228 ));
1229 };
1230 let Some(syn::GenericArgument::Type(Type::Path(client))) = arguments.args.first() else {
1231 return Err(syn::Error::new_spanned(
1232 ty,
1233 "Port or ManyPort requires one Capability client type",
1234 ));
1235 };
1236 if arguments.args.len() != 1 {
1237 return Err(syn::Error::new_spanned(
1238 ty,
1239 "Port or ManyPort requires one Capability client type",
1240 ));
1241 }
1242 Ok(Some((client.path.clone(), cardinality)))
1243}
1244
1245fn requirement_macro(
1246 client: &Path,
1247 cardinality: PortCardinality,
1248) -> syn::Result<proc_macro2::TokenStream> {
1249 if client.segments.len() < 2 {
1250 return Err(syn::Error::new_spanned(
1251 client,
1252 "a Port client must be namespace-qualified, for example `model::ModelClient`",
1253 ));
1254 }
1255 let mut namespace = client.clone();
1256 let client_name = namespace
1257 .segments
1258 .pop()
1259 .expect("checked length")
1260 .into_value()
1261 .ident;
1262 namespace.segments.pop_punct();
1263 let prefix = match cardinality {
1264 PortCardinality::One => "__lenso_required_",
1265 PortCardinality::Many => "__lenso_required_many_",
1266 };
1267 let macro_name = format_ident!("{}{}", prefix, snake(&client_name.to_string()));
1268 Ok(quote!(#namespace::#macro_name!()))
1269}
1270
1271fn intersperse_commas(values: Vec<proc_macro2::TokenStream>) -> Vec<proc_macro2::TokenStream> {
1272 values
1273 .into_iter()
1274 .enumerate()
1275 .flat_map(|(index, value)| {
1276 if index == 0 {
1277 vec![value]
1278 } else {
1279 vec![quote!(","), value]
1280 }
1281 })
1282 .collect()
1283}
1284
1285fn hook(path: Option<&Path>, sdk: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
1286 path.map_or_else(
1287 || quote!(Box::pin(#sdk::__private::futures::future::ready(Ok(())))),
1288 |path| quote!(#path(&self.module, &context)),
1289 )
1290}
1291
1292fn canonical_json(value: &Value) -> String {
1293 serde_json::to_string(value).expect("JSON values serialize")
1294}
1295
1296fn authoring_crate() -> proc_macro2::TokenStream {
1297 for package in ["lenso", "lenso-native-adapter"] {
1298 match crate_name(package) {
1299 Ok(FoundCrate::Itself) => {
1300 let ident = format_ident!("{}", package.replace('-', "_"));
1301 return quote!(::#ident);
1302 }
1303 Ok(FoundCrate::Name(name)) => {
1304 let ident = format_ident!("{name}");
1305 return quote!(::#ident);
1306 }
1307 Err(_) => {}
1308 }
1309 }
1310 quote!(::lenso_native_adapter)
1311}
1312
1313fn snake(value: &str) -> String {
1314 let mut output = String::new();
1315 for (index, character) in value.chars().enumerate() {
1316 if character.is_ascii_uppercase() && index > 0 {
1317 output.push('_');
1318 }
1319 output.push(character.to_ascii_lowercase());
1320 }
1321 output
1322}
1323
1324fn module_descriptor(
1325 package_id: &str,
1326 descriptor: &LitStr,
1327 configuration_schema: Option<&LitStr>,
1328 configuration_defaults: Option<&LitStr>,
1329) -> syn::Result<String> {
1330 let supplied: Value = serde_json::from_str(&descriptor.value()).map_err(|error| {
1331 syn::Error::new(
1332 descriptor.span(),
1333 format!("Module Descriptor input is not valid JSON: {error}"),
1334 )
1335 })?;
1336 let mut supplied = supplied.as_object().cloned().ok_or_else(|| {
1337 syn::Error::new(
1338 descriptor.span(),
1339 "Module Descriptor input must be an object",
1340 )
1341 })?;
1342 if supplied.contains_key("configuration_schema") {
1343 return Err(syn::Error::new(
1344 descriptor.span(),
1345 "Module Descriptor input cannot contain `configuration_schema`; use the package-owned schema path attribute",
1346 ));
1347 }
1348 if supplied.contains_key("configuration_defaults") {
1349 return Err(syn::Error::new(
1350 descriptor.span(),
1351 "Module Descriptor input cannot contain `configuration_defaults`; use the package-owned defaults path attribute",
1352 ));
1353 }
1354 if let Some(schema_path) = configuration_schema {
1355 supplied.insert(
1356 "configuration_schema".to_owned(),
1357 read_configuration_schema(schema_path)?,
1358 );
1359 }
1360 if let Some(defaults_path) = configuration_defaults {
1361 if configuration_schema.is_none() {
1362 return Err(syn::Error::new(
1363 defaults_path.span(),
1364 "`configuration_defaults` requires `configuration_schema`",
1365 ));
1366 }
1367 let defaults = read_configuration_defaults(defaults_path)?;
1368 let schema = supplied
1369 .get("configuration_schema")
1370 .expect("explicit configuration Schema was inserted above");
1371 validate_configuration_defaults(&defaults, schema).map_err(|detail| {
1372 syn::Error::new(
1373 defaults_path.span(),
1374 format!("invalid package configuration defaults: {detail}"),
1375 )
1376 })?;
1377 supplied.insert("configuration_defaults".to_owned(), defaults);
1378 }
1379 for owned in [
1380 "package_id",
1381 "package_revision",
1382 "entrypoint",
1383 "execution_class",
1384 "restart_policy",
1385 "criticality",
1386 ] {
1387 if supplied.contains_key(owned) {
1388 return Err(syn::Error::new(
1389 descriptor.span(),
1390 format!("Module Descriptor input cannot override generated field `{owned}`"),
1391 ));
1392 }
1393 }
1394 let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
1395 syn::Error::new(
1396 descriptor.span(),
1397 "CARGO_PKG_VERSION is unavailable while deriving Module Descriptor",
1398 )
1399 })?;
1400 Ok(complete_module_descriptor(
1401 package_id,
1402 &package_version,
1403 supplied,
1404 ))
1405}
1406
1407fn read_configuration_schema(schema_path: &LitStr) -> syn::Result<Value> {
1408 let schema = read_package_json(schema_path, "configuration Schema")?;
1409 if !schema.is_object() {
1410 return Err(syn::Error::new(
1411 schema_path.span(),
1412 "configuration Schema must be a JSON object",
1413 ));
1414 }
1415 Ok(schema)
1416}
1417
1418fn read_configuration_defaults(defaults_path: &LitStr) -> syn::Result<Value> {
1419 let defaults = read_package_json(defaults_path, "configuration defaults")?;
1420 if !defaults.is_object() {
1421 return Err(syn::Error::new(
1422 defaults_path.span(),
1423 "configuration defaults must be a JSON object",
1424 ));
1425 }
1426 Ok(defaults)
1427}
1428
1429fn read_package_json(path: &LitStr, label: &str) -> syn::Result<Value> {
1430 let relative = PathBuf::from(path.value());
1431 if relative.is_absolute()
1432 || relative
1433 .components()
1434 .any(|component| !matches!(component, std::path::Component::Normal(_)))
1435 {
1436 return Err(syn::Error::new(
1437 path.span(),
1438 format!("{label} path must stay inside the Module package"),
1439 ));
1440 }
1441 let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
1442 syn::Error::new(
1443 path.span(),
1444 format!("CARGO_MANIFEST_DIR is unavailable while deriving {label}"),
1445 )
1446 })?;
1447 let full_path = PathBuf::from(manifest_dir).join(relative);
1448 let bytes = fs::read(&full_path).map_err(|error| {
1449 syn::Error::new(
1450 path.span(),
1451 format!("failed to read {label} {}: {error}", full_path.display()),
1452 )
1453 })?;
1454 serde_json::from_slice(&bytes).map_err(|error| {
1455 syn::Error::new(
1456 path.span(),
1457 format!("{label} {} is invalid JSON: {error}", full_path.display()),
1458 )
1459 })
1460}
1461
1462fn validate_configuration_defaults(defaults: &Value, schema: &Value) -> Result<(), String> {
1463 if !defaults.is_object() {
1464 return Err("$: defaults must be an object".to_owned());
1465 }
1466 validate_default_value(defaults, schema, "$")
1467}
1468
1469fn validate_default_value(value: &Value, schema: &Value, path: &str) -> Result<(), String> {
1470 let schema = schema
1471 .as_object()
1472 .ok_or_else(|| format!("{path}: configuration Schema must be an object"))?;
1473 if schema
1474 .get("x-lenso-sensitive")
1475 .and_then(Value::as_bool)
1476 .unwrap_or(false)
1477 {
1478 return Err(format!(
1479 "{path}: sensitive configuration cannot have a package default"
1480 ));
1481 }
1482 if let Some(expected) = schema.get("type").and_then(Value::as_str) {
1483 let valid = match expected {
1484 "array" => value.is_array(),
1485 "boolean" => value.is_boolean(),
1486 "integer" => value
1487 .as_number()
1488 .is_some_and(|number| number.is_i64() || number.is_u64()),
1489 "null" => value.is_null(),
1490 "number" => value.is_number(),
1491 "object" => value.is_object(),
1492 "string" => value.is_string(),
1493 _ => false,
1494 };
1495 if !valid {
1496 return Err(format!(
1497 "{path}: default does not match Schema type `{expected}`"
1498 ));
1499 }
1500 }
1501 if let (Some(minimum), Some(number)) = (schema.get("minimum"), value.as_f64()) {
1502 let minimum = minimum
1503 .as_f64()
1504 .ok_or_else(|| format!("{path}: Schema minimum must be a number"))?;
1505 if number < minimum {
1506 return Err(format!(
1507 "{path}: default must be greater than or equal to {minimum}"
1508 ));
1509 }
1510 }
1511 if let Some(expected) = schema.get("const")
1512 && value != expected
1513 {
1514 return Err(format!("{path}: default does not match Schema const"));
1515 }
1516 if let Some(allowed) = schema.get("enum") {
1517 let allowed = allowed
1518 .as_array()
1519 .ok_or_else(|| format!("{path}: Schema enum must be an array"))?;
1520 if !allowed.contains(value) {
1521 return Err(format!("{path}: default is not in Schema enum"));
1522 }
1523 }
1524 validate_default_object(value, schema, path)?;
1525 validate_default_array(value, schema, path)
1526}
1527
1528fn validate_default_object(
1529 value: &Value,
1530 schema: &Map<String, Value>,
1531 path: &str,
1532) -> Result<(), String> {
1533 let Some(object) = value.as_object() else {
1534 return Ok(());
1535 };
1536 let empty = Map::new();
1537 let properties = schema.get("properties").map_or(Ok(&empty), |properties| {
1538 properties
1539 .as_object()
1540 .ok_or_else(|| format!("{path}: Schema properties must be an object"))
1541 })?;
1542 for (name, child) in object {
1543 if let Some(child_schema) = properties.get(name) {
1544 validate_default_value(child, child_schema, &format!("{path}.{name}"))?;
1545 continue;
1546 }
1547 match schema.get("additionalProperties") {
1548 Some(Value::Bool(false)) => {
1549 return Err(format!("{path}.{name}: additional property is not allowed"));
1550 }
1551 Some(Value::Object(additional_schema)) => validate_default_value(
1552 child,
1553 &Value::Object(additional_schema.clone()),
1554 &format!("{path}.{name}"),
1555 )?,
1556 _ => {}
1557 }
1558 }
1559 Ok(())
1560}
1561
1562fn validate_default_array(
1563 value: &Value,
1564 schema: &Map<String, Value>,
1565 path: &str,
1566) -> Result<(), String> {
1567 let (Some(items), Some(item_schema)) = (value.as_array(), schema.get("items")) else {
1568 return Ok(());
1569 };
1570 for (index, item) in items.iter().enumerate() {
1571 validate_default_value(item, item_schema, &format!("{path}[{index}]"))?;
1572 }
1573 Ok(())
1574}
1575
1576fn complete_module_descriptor(
1577 package_id: &str,
1578 package_version: &str,
1579 mut supplied: Map<String, Value>,
1580) -> String {
1581 let mut generated = Map::new();
1582 generated.insert("package_id".to_owned(), json!(package_id));
1583 generated.insert("package_revision".to_owned(), json!(package_version));
1584 generated.insert("entrypoint".to_owned(), json!("default"));
1585 for (key, value) in std::mem::take(&mut supplied) {
1586 generated.insert(key, value);
1587 }
1588 generated.insert("execution_class".to_owned(), json!("lenso.native-rust@1"));
1589 generated.insert(
1590 "restart_policy".to_owned(),
1591 json!({
1592 "mode": "never",
1593 "max_attempts": 0,
1594 "window": {"secs": 0, "nanos": 0},
1595 "backoff": {"secs": 0, "nanos": 0},
1596 "stability": {"secs": 0, "nanos": 0},
1597 "jitter": {"secs": 0, "nanos": 0}
1598 }),
1599 );
1600 generated.insert("criticality".to_owned(), json!("non_critical"));
1601 serde_json::to_string(&Value::Object(generated))
1602 .expect("generated Module Descriptor values must serialize")
1603}
1604
1605fn package_id() -> syn::Result<String> {
1606 let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
1607 syn::Error::new(
1608 proc_macro2::Span::call_site(),
1609 "CARGO_MANIFEST_DIR is unavailable",
1610 )
1611 })?;
1612 let manifest_path = PathBuf::from(manifest_dir).join("Cargo.toml");
1613 let manifest = fs::read_to_string(&manifest_path).map_err(|error| {
1614 syn::Error::new(
1615 proc_macro2::Span::call_site(),
1616 format!("failed to read {}: {error}", manifest_path.display()),
1617 )
1618 })?;
1619 let manifest: toml::Value = toml::from_str(&manifest).map_err(|error| {
1620 syn::Error::new(
1621 proc_macro2::Span::call_site(),
1622 format!("failed to parse {}: {error}", manifest_path.display()),
1623 )
1624 })?;
1625 manifest
1626 .get("package")
1627 .and_then(|package| package.get("metadata"))
1628 .and_then(|metadata| metadata.get("lenso"))
1629 .and_then(|lenso| lenso.get("package-id"))
1630 .and_then(toml::Value::as_str)
1631 .map(str::to_owned)
1632 .ok_or_else(|| {
1633 syn::Error::new(
1634 proc_macro2::Span::call_site(),
1635 "missing `[package.metadata.lenso] package-id = \"...\"` in Cargo.toml",
1636 )
1637 })
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642 use super::*;
1643 use syn::parse_quote;
1644
1645 #[test]
1646 fn generated_descriptor_owns_identity_and_execution_defaults() {
1647 let supplied = serde_json::from_value::<Map<String, Value>>(json!({
1648 "provided_capabilities": [],
1649 "required_capabilities": []
1650 }))
1651 .unwrap();
1652 let descriptor = complete_module_descriptor("example.tool", "1.2.3", supplied);
1653 let descriptor: Value = serde_json::from_str(&descriptor).unwrap();
1654
1655 assert_eq!(descriptor["package_id"], "example.tool");
1656 assert_eq!(descriptor["package_revision"], "1.2.3");
1657 assert_eq!(descriptor["entrypoint"], "default");
1658 assert_eq!(descriptor["execution_class"], "lenso.native-rust@1");
1659 assert_eq!(descriptor["restart_policy"]["mode"], "never");
1660 assert_eq!(descriptor["criticality"], "non_critical");
1661 }
1662
1663 #[test]
1664 fn package_schema_is_embedded_as_descriptor_data() {
1665 let path = LitStr::new(
1666 "tests/fixtures/config.schema.json",
1667 proc_macro2::Span::call_site(),
1668 );
1669 let schema = read_configuration_schema(&path).unwrap();
1670
1671 assert_eq!(schema["type"], "object");
1672 assert_eq!(schema["required"], json!(["name", "retries"]));
1673 }
1674
1675 #[test]
1676 fn package_defaults_are_embedded_as_descriptor_data() {
1677 let path = LitStr::new(
1678 "tests/fixtures/config.defaults.json",
1679 proc_macro2::Span::call_site(),
1680 );
1681 let defaults = read_configuration_defaults(&path).unwrap();
1682
1683 assert_eq!(defaults, json!({"name": "fixture", "retries": 3}));
1684 }
1685
1686 #[test]
1687 fn factory_function_descriptor_embeds_package_defaults() {
1688 let descriptor = LitStr::new(
1689 r#"{"provided_capabilities":[],"required_capabilities":[]}"#,
1690 proc_macro2::Span::call_site(),
1691 );
1692 let schema = LitStr::new(
1693 "tests/fixtures/config.schema.json",
1694 proc_macro2::Span::call_site(),
1695 );
1696 let defaults = LitStr::new(
1697 "tests/fixtures/config.defaults.json",
1698 proc_macro2::Span::call_site(),
1699 );
1700
1701 let generated =
1702 module_descriptor("example.tool", &descriptor, Some(&schema), Some(&defaults)).unwrap();
1703 let generated: Value = serde_json::from_str(&generated).unwrap();
1704 assert_eq!(
1705 generated["configuration_defaults"],
1706 json!({"name": "fixture", "retries": 3})
1707 );
1708 }
1709
1710 #[test]
1711 fn typed_configuration_defaults_must_match_the_field_type() {
1712 let input: DeriveInput = parse_quote! {
1713 struct InvalidConfig {
1714 #[lenso(default = 3)]
1715 name: String,
1716 }
1717 };
1718
1719 let error = expand_module_config(&input).unwrap_err();
1720 assert!(error.to_string().contains("does not match the field type"));
1721 }
1722
1723 #[test]
1724 fn package_defaults_fail_closed_against_schema_constraints() {
1725 let schema = json!({
1726 "type": "object",
1727 "properties": {
1728 "retries": {"type": "integer", "minimum": 1},
1729 "token": {"x-lenso-sensitive": true}
1730 },
1731 "additionalProperties": false
1732 });
1733
1734 assert_eq!(
1735 validate_configuration_defaults(&json!({"retries": 0}), &schema),
1736 Err("$.retries: default must be greater than or equal to 1".to_owned())
1737 );
1738 assert_eq!(
1739 validate_configuration_defaults(&json!({"token": {"secret_ref": "TOKEN"}}), &schema),
1740 Err("$.token: sensitive configuration cannot have a package default".to_owned())
1741 );
1742 }
1743
1744 #[test]
1745 fn typed_ports_preserve_client_paths_and_cardinality() {
1746 let one: Type = parse_quote!(Port<secrets::SecretsClient>);
1747 let many: Type = parse_quote!(ManyPort<auth::AuthClient>);
1748
1749 let (one_client, one_cardinality) = port_client(&one).unwrap().unwrap();
1750 let (many_client, many_cardinality) = port_client(&many).unwrap().unwrap();
1751
1752 assert_eq!(quote!(#one_client).to_string(), "secrets :: SecretsClient");
1753 assert!(matches!(one_cardinality, PortCardinality::One));
1754 assert_eq!(quote!(#many_client).to_string(), "auth :: AuthClient");
1755 assert!(matches!(many_cardinality, PortCardinality::Many));
1756 }
1757
1758 #[test]
1759 fn managed_tasks_fields_are_initialized_and_connected_on_activate() {
1760 let mut module: ItemStruct = parse_quote! {
1761 struct Worker {
1762 #[tasks]
1763 tasks: ManagedTasks,
1764 }
1765 };
1766 let fields = analyze_struct_fields(&mut module).unwrap();
1767
1768 let task_field: syn::Ident = parse_quote!(tasks);
1769 assert_eq!(fields.tasks, vec![task_field]);
1770 assert_eq!(
1771 fields.initializers[0].to_string(),
1772 "tasks : :: core :: default :: Default :: default ()"
1773 );
1774 assert_eq!(
1775 task_connectors(&fields.tasks)[0].to_string(),
1776 "self . module . tasks . __lenso_connect (context . tasks () . clone ()) ? ;"
1777 );
1778 assert_eq!(
1779 task_disconnectors(&fields.tasks)[0].to_string(),
1780 "module . tasks . __lenso_disconnect () ;"
1781 );
1782 assert!(module.fields.iter().next().unwrap().attrs.is_empty());
1783 }
1784
1785 #[test]
1786 fn multiple_capabilities_reject_trait_impls() {
1787 let implementation: ItemImpl = parse_quote! {
1788 impl fixture::Provider for ExampleModule {}
1789 };
1790 let error = expand_provides(
1791 &[parse_quote!(fixture::One), parse_quote!(fixture::Two)],
1792 &implementation,
1793 )
1794 .expect_err("multi-Capability authoring must have one inherent impl");
1795
1796 assert!(
1797 error
1798 .to_string()
1799 .contains("multiple Capabilities require one inherent impl")
1800 );
1801 }
1802
1803 #[test]
1804 fn duplicate_capabilities_are_rejected() {
1805 let implementation: ItemImpl = parse_quote! { impl ExampleModule {} };
1806 let error = expand_provides(
1807 &[parse_quote!(fixture::One), parse_quote!(fixture::One)],
1808 &implementation,
1809 )
1810 .expect_err("one Capability cannot be contributed twice");
1811
1812 assert!(error.to_string().contains("same Capability more than once"));
1813 }
1814
1815 #[test]
1816 fn capability_paths_must_be_namespace_qualified() {
1817 let implementation: ItemImpl = parse_quote! { impl ExampleModule {} };
1818 let error = expand_provides(&[parse_quote!(One)], &implementation)
1819 .expect_err("generated Capability macros live in their namespace");
1820
1821 assert!(error.to_string().contains("namespace-qualified"));
1822 }
1823}