Skip to main content

lenso_native_adapter_macros/
lib.rs

1//! Derivation macros for statically linked native Lenso Modules.
2
3use 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, Fields, GenericArgument, Item, ItemFn, ItemImpl, ItemStruct,
11    LitStr, Path, PathArguments, Token, Type, parse_macro_input, punctuated::Punctuated,
12};
13
14struct ModuleAttributes {
15    descriptor: Option<LitStr>,
16    configuration_schema: Option<LitStr>,
17    validate: Option<Path>,
18    prepare: Option<Path>,
19    activate: Option<Path>,
20    deactivate: Option<Path>,
21    lifecycle: bool,
22    consumer: bool,
23}
24
25impl syn::parse::Parse for ModuleAttributes {
26    fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result<Self> {
27        if input.is_empty() {
28            return Ok(Self {
29                descriptor: None,
30                configuration_schema: None,
31                validate: None,
32                prepare: None,
33                activate: None,
34                deactivate: None,
35                lifecycle: false,
36                consumer: false,
37            });
38        }
39        let mut descriptor = None;
40        let mut configuration_schema = None;
41        let mut validate = None;
42        let mut prepare = None;
43        let mut activate = None;
44        let mut deactivate = None;
45        let mut lifecycle = false;
46        let mut consumer = false;
47        while !input.is_empty() {
48            let name: syn::Ident = input.parse()?;
49            if name == "lifecycle" {
50                if lifecycle {
51                    return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
52                }
53                lifecycle = true;
54                if input.is_empty() {
55                    break;
56                }
57                input.parse::<Token![,]>()?;
58                continue;
59            }
60            if name == "consumer" {
61                if consumer {
62                    return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
63                }
64                consumer = true;
65                if input.is_empty() {
66                    break;
67                }
68                input.parse::<Token![,]>()?;
69                continue;
70            }
71            input.parse::<Token![=]>()?;
72            match name.to_string().as_str() {
73                "descriptor" if descriptor.is_none() => descriptor = Some(input.parse()?),
74                "configuration_schema" if configuration_schema.is_none() => {
75                    configuration_schema = Some(input.parse()?);
76                }
77                "validate" if validate.is_none() => validate = Some(input.parse()?),
78                "prepare" if prepare.is_none() => prepare = Some(input.parse()?),
79                "activate" if activate.is_none() => activate = Some(input.parse()?),
80                "deactivate" if deactivate.is_none() => deactivate = Some(input.parse()?),
81                "descriptor"
82                | "configuration_schema"
83                | "validate"
84                | "prepare"
85                | "activate"
86                | "deactivate" => {
87                    return Err(syn::Error::new(name.span(), "duplicate Module attribute"));
88                }
89                _ => {
90                    return Err(syn::Error::new(
91                        name.span(),
92                        "expected `descriptor`, `configuration_schema`, `validate`, `prepare`, `activate`, `deactivate`, `lifecycle`, or `consumer`",
93                    ));
94                }
95            }
96            if input.is_empty() {
97                break;
98            }
99            input.parse::<Token![,]>()?;
100        }
101        Ok(Self {
102            descriptor,
103            configuration_schema,
104            validate,
105            prepare,
106            activate,
107            deactivate,
108            lifecycle,
109            consumer,
110        })
111    }
112}
113
114/// Derives native Module source and, for `consumer`, its factory registration.
115///
116/// The package identity comes from `[package.metadata.lenso].package-id` in the
117/// consuming crate's `Cargo.toml`; the package version remains Cargo-owned.
118#[proc_macro_attribute]
119pub fn module(attributes: TokenStream, item: TokenStream) -> TokenStream {
120    let attributes = parse_macro_input!(attributes as ModuleAttributes);
121    let item = parse_macro_input!(item as Item);
122    match item {
123        Item::Fn(function) => expand_module_function(&attributes, &function),
124        Item::Struct(module) => expand_module_struct(&attributes, module),
125        other => Err(syn::Error::new_spanned(
126            other,
127            "a native Module must be declared by a factory function or a named-field struct",
128        )),
129    }
130    .unwrap_or_else(syn::Error::into_compile_error)
131    .into()
132}
133
134/// Derives the locked JSON Schema fragment consumed by a struct-level Module.
135#[proc_macro_derive(ModuleConfig, attributes(serde))]
136pub fn module_config(item: TokenStream) -> TokenStream {
137    let input = parse_macro_input!(item as DeriveInput);
138    expand_module_config(&input)
139        .unwrap_or_else(syn::Error::into_compile_error)
140        .into()
141}
142
143fn expand_module_config(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
144    let Data::Struct(data) = &input.data else {
145        return Err(syn::Error::new_spanned(
146            input,
147            "Module configuration must be a named-field struct",
148        ));
149    };
150    let Fields::Named(fields) = &data.fields else {
151        return Err(syn::Error::new_spanned(
152            &data.fields,
153            "Module configuration must use named fields",
154        ));
155    };
156    let mut properties = Map::new();
157    let mut required = Vec::new();
158    for field in &fields.named {
159        let ident = field.ident.as_ref().expect("named fields have identifiers");
160        let name = serde_field_name(&field.attrs, ident)?;
161        let (schema, optional) = configuration_type_schema(&field.ty)?;
162        properties.insert(name.clone(), schema);
163        if !optional {
164            required.push(Value::String(name));
165        }
166    }
167    let schema = canonical_json(&json!({
168        "$schema": "https://json-schema.org/draft/2020-12/schema",
169        "type": "object",
170        "additionalProperties": false,
171        "required": required,
172        "properties": properties,
173    }));
174    let macro_name = format_ident!("__lenso_config_schema_{}", snake(&input.ident.to_string()));
175    Ok(quote! {
176        #[doc(hidden)]
177        #[macro_export]
178        macro_rules! #macro_name {
179            () => { #schema };
180        }
181    })
182}
183
184fn serde_field_name(attributes: &[Attribute], ident: &syn::Ident) -> syn::Result<String> {
185    let mut name = ident.to_string();
186    for attribute in attributes {
187        if !attribute.path().is_ident("serde") {
188            continue;
189        }
190        attribute.parse_nested_meta(|meta| {
191            if meta.path.is_ident("rename") {
192                name = meta.value()?.parse::<LitStr>()?.value();
193            }
194            Ok(())
195        })?;
196    }
197    Ok(name)
198}
199
200fn configuration_type_schema(ty: &Type) -> syn::Result<(Value, bool)> {
201    let Type::Path(path) = ty else {
202        return Err(syn::Error::new_spanned(
203            ty,
204            "Module configuration fields must use portable named types",
205        ));
206    };
207    let segment = path.path.segments.last().expect("type paths are non-empty");
208    let name = segment.ident.to_string();
209    if name == "Option" {
210        return Ok((configuration_inner_schema(segment, ty)?, true));
211    }
212    if name == "Vec" {
213        return Ok((
214            json!({"type": "array", "items": configuration_inner_schema(segment, ty)?}),
215            false,
216        ));
217    }
218    let schema = match name.as_str() {
219        "String" => json!({"type": "string"}),
220        "bool" => json!({"type": "boolean"}),
221        "f32" | "f64" => json!({"type": "number"}),
222        "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
223        | "usize" => json!({"type": "integer"}),
224        _ => {
225            return Err(syn::Error::new_spanned(
226                ty,
227                "unsupported Module configuration field type; use String, bool, a number, Option<T>, or Vec<T>",
228            ));
229        }
230    };
231    Ok((schema, false))
232}
233
234fn configuration_inner_schema(segment: &syn::PathSegment, ty: &Type) -> syn::Result<Value> {
235    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
236        return Err(syn::Error::new_spanned(
237            ty,
238            "configuration container requires one type",
239        ));
240    };
241    let [GenericArgument::Type(inner)] = arguments.args.iter().collect::<Vec<_>>().as_slice()
242    else {
243        return Err(syn::Error::new_spanned(
244            ty,
245            "configuration container requires one type",
246        ));
247    };
248    configuration_type_schema(inner).map(|(schema, _)| schema)
249}
250
251fn expand_module_function(
252    attributes: &ModuleAttributes,
253    function: &ItemFn,
254) -> syn::Result<proc_macro2::TokenStream> {
255    let sdk = authoring_crate();
256    if attributes.descriptor.is_none() && attributes.configuration_schema.is_some() {
257        return Err(syn::Error::new_spanned(
258            function,
259            "`configuration_schema` requires `descriptor` on a factory function",
260        ));
261    }
262    if attributes.validate.is_some()
263        || attributes.prepare.is_some()
264        || attributes.activate.is_some()
265        || attributes.deactivate.is_some()
266        || attributes.lifecycle
267        || attributes.consumer
268    {
269        return Err(syn::Error::new_spanned(
270            function,
271            "struct-level Module attributes are unavailable on factory functions",
272        ));
273    }
274    let package_id = package_id()?;
275    let descriptor_json = attributes
276        .descriptor
277        .as_ref()
278        .map(|descriptor| {
279            module_descriptor(
280                &package_id,
281                descriptor,
282                attributes.configuration_schema.as_ref(),
283            )
284        })
285        .transpose()?;
286    let function_name = &function.sig.ident;
287    let generated_module = format_ident!("__lenso_module_{function_name}");
288    let descriptor_constant = descriptor_json.map(|descriptor| {
289        let artifact =
290            format!("LENSO_MODULE_DESCRIPTOR_V1\0{descriptor}\0END_LENSO_MODULE_DESCRIPTOR_V1");
291        let artifact_length = artifact.len();
292        let artifact = proc_macro2::Literal::byte_string(artifact.as_bytes());
293        let schema_tracking = attributes.configuration_schema.as_ref().map(|schema| {
294            quote! {
295                const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #schema));
296            }
297        });
298        quote! {
299            /// Generated package-owned Module Descriptor bytes.
300            pub const MODULE_DESCRIPTOR_JSON: &str = #descriptor;
301            /// Linker-retained descriptor artifact consumed without executing package code.
302            #[doc(hidden)]
303            #[used]
304            pub static __LENSO_MODULE_DESCRIPTOR_ARTIFACT: [u8; #artifact_length] = *#artifact;
305            #schema_tracking
306        }
307    });
308
309    Ok(quote! {
310        /// Runtime package identity derived from Cargo package metadata.
311        pub const PACKAGE_ID: &str = #package_id;
312        /// Exact linked Cargo package version.
313        pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
314        /// Exact Host Build identity for this linked package.
315        pub const FACTORY_IDENTITY: &str = concat!(#package_id, "@", env!("CARGO_PKG_VERSION"));
316        #descriptor_constant
317
318        #function
319
320        #[doc(hidden)]
321        mod #generated_module {
322            #[derive(Clone, Copy, Debug, Default)]
323            struct Factory;
324
325            impl #sdk::__private::NativeModuleFactory for Factory {
326                fn package_id(&self) -> &'static str {
327                    #package_id
328                }
329
330                fn package_version(&self) -> &'static str {
331                    env!("CARGO_PKG_VERSION")
332                }
333
334                fn instantiate(
335                    &self,
336                    context: #sdk::__private::NativeModuleFactoryContext<'_>,
337                ) -> Result<
338                    #sdk::__private::NativeModuleInstance,
339                    #sdk::__private::RuntimeFailure,
340                > {
341                    super::#function_name(context)
342                }
343            }
344
345            fn factory() -> std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
346                std::rc::Rc::new(Factory)
347            }
348
349            #sdk::__private::__inventory::submit! {
350                #sdk::__private::LinkedNativeModuleFactory::new(factory)
351            }
352
353            // Make Cargo track the manifest that supplied the generated identity.
354            const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
355        }
356    })
357}
358
359/// Derives one or more provided Capability endpoints, one native factory, and static registration.
360///
361/// Apply this to one inherent implementation containing the Capabilities' domain methods
362/// for a struct already annotated with [`module`]. Generated bindings lower the
363/// methods into the Adapter-facing Provider trait. Existing explicit Provider
364/// trait implementations remain supported as a single-Capability compatibility
365/// escape hatch. Multi-Capability Modules must use one inherent implementation.
366#[proc_macro_attribute]
367pub fn provides(attributes: TokenStream, item: TokenStream) -> TokenStream {
368    let capabilities =
369        parse_macro_input!(attributes with Punctuated::<Path, Token![,]>::parse_terminated);
370    let implementation = parse_macro_input!(item as ItemImpl);
371    expand_provides(
372        &capabilities.into_iter().collect::<Vec<_>>(),
373        &implementation,
374    )
375    .unwrap_or_else(syn::Error::into_compile_error)
376    .into()
377}
378
379struct CapabilityContribution {
380    namespace: Path,
381    descriptor: syn::Ident,
382    endpoints: syn::Ident,
383    lower: syn::Ident,
384}
385
386fn capability_contributions(capabilities: &[Path]) -> syn::Result<Vec<CapabilityContribution>> {
387    let mut seen = BTreeSet::new();
388    capabilities
389        .iter()
390        .map(|capability| {
391            let path = quote!(#capability).to_string();
392            if !seen.insert(path) {
393                return Err(syn::Error::new_spanned(
394                    capability,
395                    "a Module cannot provide the same Capability more than once",
396                ));
397            }
398            let mut namespace = capability.clone();
399            let capability_ident = namespace
400                .segments
401                .pop()
402                .ok_or_else(|| syn::Error::new_spanned(capability, "Capability path is empty"))?
403                .into_value()
404                .ident;
405            namespace.segments.pop_punct();
406            if namespace.segments.is_empty() {
407                return Err(syn::Error::new_spanned(
408                    capability,
409                    "Capability must be namespace-qualified, for example `agent::Agent`",
410                ));
411            }
412            let capability_snake = snake(&capability_ident.to_string());
413            Ok(CapabilityContribution {
414                namespace,
415                descriptor: format_ident!("__lenso_provided_{capability_snake}"),
416                endpoints: format_ident!("__lenso_native_endpoints_{capability_snake}"),
417                lower: format_ident!("__lenso_native_lower_{capability_snake}"),
418            })
419        })
420        .collect()
421}
422
423fn provided_module(
424    capabilities: &[Path],
425    implementation: &ItemImpl,
426) -> syn::Result<(syn::Ident, bool)> {
427    if capabilities.is_empty() {
428        return Err(syn::Error::new_spanned(
429            implementation,
430            "`provides` requires at least one namespace-qualified Capability",
431        ));
432    }
433    if capabilities.len() > 1 && implementation.trait_.is_some() {
434        return Err(syn::Error::new_spanned(
435            implementation,
436            "multiple Capabilities require one inherent impl containing their domain methods",
437        ));
438    }
439    let Type::Path(module_type) = implementation.self_ty.as_ref() else {
440        return Err(syn::Error::new_spanned(
441            &implementation.self_ty,
442            "the Module provider type must be a path",
443        ));
444    };
445    let module_ident = module_type
446        .path
447        .segments
448        .last()
449        .ok_or_else(|| {
450            syn::Error::new_spanned(&module_type.path, "the Module provider type is empty")
451        })?
452        .ident
453        .clone();
454    Ok((module_ident, implementation.trait_.is_none()))
455}
456
457fn expand_provides(
458    capabilities: &[Path],
459    implementation: &ItemImpl,
460) -> syn::Result<proc_macro2::TokenStream> {
461    let sdk = authoring_crate();
462    let (module_ident, lowers_domain_methods) = provided_module(capabilities, implementation)?;
463    let contributions = capability_contributions(capabilities)?;
464    let provided_descriptors = contributions
465        .iter()
466        .map(|contribution| {
467            let namespace = &contribution.namespace;
468            let descriptor = &contribution.descriptor;
469            quote!(#namespace::#descriptor!())
470        })
471        .collect::<Vec<_>>();
472    let module_descriptor = format_ident!(
473        "__lenso_module_descriptor_{}",
474        snake(&module_ident.to_string())
475    );
476    let generated_module = format_ident!("__lenso_provider_{}", snake(&module_ident.to_string()));
477    let lifecycle = format_ident!("__LensoLifecycle{module_ident}");
478    let artifact = format_ident!("__LENSO_MODULE_DESCRIPTOR_ARTIFACT_{module_ident}");
479    let provider_implementations = if lowers_domain_methods {
480        contributions
481            .iter()
482            .map(|contribution| {
483                let namespace = &contribution.namespace;
484                let lower = &contribution.lower;
485                quote! { #namespace::#lower!(#module_ident, #sdk::__private); }
486            })
487            .collect::<Vec<_>>()
488    } else {
489        Vec::new()
490    };
491    let endpoint_contributions = contributions.iter().map(|contribution| {
492        let namespace = &contribution.namespace;
493        let endpoints = &contribution.endpoints;
494        quote! {
495            let (provided_requests, provided_streams, provided_events) =
496                super::#namespace::#endpoints!(module.clone(), #sdk::__private);
497            request_endpoints.extend(provided_requests);
498            stream_endpoints.extend(provided_streams);
499            event_endpoints.extend(provided_events);
500        }
501    });
502
503    let mut implementation = implementation.clone();
504    implementation
505        .attrs
506        .push(syn::parse_quote!(#[allow(clippy::unused_async, clippy::unused_async_trait_impl)]));
507
508    Ok(quote! {
509        #implementation
510        #(#provider_implementations)*
511
512        /// Generated package-owned Module Descriptor bytes.
513        pub const MODULE_DESCRIPTOR_JSON: &str = #module_descriptor!(
514            #(#provided_descriptors),*
515        );
516        #[doc(hidden)]
517        const __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
518            "LENSO_MODULE_DESCRIPTOR_V1\0",
519            #module_descriptor!(#(#provided_descriptors),*),
520            "\0END_LENSO_MODULE_DESCRIPTOR_V1",
521        );
522        /// Linker-retained descriptor artifact consumed without executing package code.
523        #[doc(hidden)]
524        #[used]
525        pub static #artifact: &[u8] = __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
526
527        #[doc(hidden)]
528        mod #generated_module {
529            #[derive(Clone, Copy, Debug, Default)]
530            struct Factory;
531
532            impl #sdk::__private::NativeModuleFactory for Factory {
533                fn package_id(&self) -> &'static str { super::PACKAGE_ID }
534                fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
535
536                fn instantiate(
537                    &self,
538                    context: #sdk::__private::NativeModuleFactoryContext<'_>,
539                ) -> Result<
540                    #sdk::__private::NativeModuleInstance,
541                    #sdk::__private::RuntimeFailure,
542                > {
543                    let module = super::#module_ident::__lenso_construct(context)?;
544                    let lifecycle = super::#lifecycle { module: module.clone() };
545                    let mut request_endpoints = Vec::new();
546                    let mut stream_endpoints = Vec::new();
547                    let mut event_endpoints = Vec::new();
548                    #(#endpoint_contributions)*
549                    Ok(#sdk::__private::NativeModuleInstance::with_all_endpoints(
550                        request_endpoints,
551                        stream_endpoints,
552                        event_endpoints,
553                        lifecycle,
554                    ))
555                }
556            }
557
558            fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
559                ::std::rc::Rc::new(Factory)
560            }
561
562            #sdk::__private::__inventory::submit! {
563                #sdk::__private::LinkedNativeModuleFactory::new(factory)
564            }
565        }
566    })
567}
568
569#[allow(clippy::too_many_lines)]
570fn expand_module_struct(
571    attributes: &ModuleAttributes,
572    mut module: ItemStruct,
573) -> syn::Result<proc_macro2::TokenStream> {
574    let sdk = authoring_crate();
575    if attributes.descriptor.is_some() {
576        return Err(syn::Error::new_spanned(
577            &module.ident,
578            "struct-level Modules derive their Descriptor; remove `descriptor`",
579        ));
580    }
581    let package_id = package_id()?;
582    let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
583        syn::Error::new_spanned(
584            &module.ident,
585            "CARGO_PKG_VERSION is unavailable while deriving Module Descriptor",
586        )
587    })?;
588    let StructFields {
589        config_type,
590        ports,
591        initializers,
592    } = analyze_struct_fields(&mut module)?;
593    let schema = configuration_schema_tokens(
594        attributes.configuration_schema.as_ref(),
595        config_type.as_ref(),
596    )?;
597    let name = &module.ident;
598    let lifecycle_name = format_ident!("__LensoLifecycle{name}");
599    let descriptor_macro = format_ident!("__lenso_module_descriptor_{}", snake(&name.to_string()));
600    let requirement_macros = ports
601        .iter()
602        .map(|(_, client, cardinality)| requirement_macro(client, *cardinality))
603        .collect::<syn::Result<Vec<_>>>()?;
604    let connect_ports = ports.iter().map(|(field, _, _)| {
605        quote! { self.module.#field.connect(context.dependencies())?; }
606    });
607    let requirement_parts = intersperse_commas(requirement_macros);
608    let (prefix, after_schema, suffix, defaults) =
609        descriptor_affixes(&package_id, &package_version);
610    let construct_configuration = if let Some(config_type) = &config_type {
611        let validate = attributes
612            .validate
613            .as_ref()
614            .map(|path| quote!(#path(&configuration)?;));
615        quote! {
616            let configuration = #sdk::__private::serde_json::from_str::<#config_type>(context.configuration())
617                .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
618                    detail: format!("invalid {} configuration: {error}", #package_id),
619                })?;
620            #validate
621        }
622    } else {
623        if attributes.configuration_schema.is_some() {
624            return Err(syn::Error::new_spanned(
625                &module.ident,
626                "`configuration_schema` requires a `#[config]` field",
627            ));
628        }
629        if attributes.validate.is_some() {
630            return Err(syn::Error::new_spanned(
631                &module.ident,
632                "`validate` requires a `#[config]` field",
633            ));
634        }
635        quote! {
636            let configuration = #sdk::__private::serde_json::from_str::<#sdk::__private::serde_json::Value>(context.configuration())
637                .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
638                    detail: format!("invalid {} configuration: {error}", #package_id),
639                })?;
640            if !configuration.as_object().is_some_and(|object| object.is_empty()) {
641                return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
642                    detail: format!("{} does not accept configuration", #package_id),
643                });
644            }
645        }
646    };
647    if attributes.lifecycle
648        && (attributes.prepare.is_some()
649            || attributes.activate.is_some()
650            || attributes.deactivate.is_some())
651    {
652        return Err(syn::Error::new_spanned(
653            &module.ident,
654            "`lifecycle` replaces the `prepare`, `activate`, and `deactivate` function attributes",
655        ));
656    }
657    let prepare = if attributes.lifecycle {
658        quote! {
659            let module = self.module.clone();
660            Box::pin(async move { #sdk::Lifecycle::prepare(&module, context).await })
661        }
662    } else {
663        hook(attributes.prepare.as_ref(), &sdk)
664    };
665    let activate = if attributes.lifecycle {
666        quote! {
667            let module = self.module.clone();
668            Box::pin(async move { #sdk::Lifecycle::activate(&module, context).await })
669        }
670    } else {
671        hook(attributes.activate.as_ref(), &sdk)
672    };
673    let deactivate = if attributes.lifecycle {
674        quote! {
675            let module = self.module.clone();
676            Box::pin(async move { #sdk::Lifecycle::deactivate(&module, context).await })
677        }
678    } else {
679        hook(attributes.deactivate.as_ref(), &sdk)
680    };
681    let schema_tracking = schema_tracking(attributes.configuration_schema.as_ref());
682    let consumer_finalizer = if attributes.consumer {
683        let generated_module = format_ident!("__lenso_consumer_{}", snake(&name.to_string()));
684        let artifact = format_ident!("__LENSO_MODULE_DESCRIPTOR_ARTIFACT_{name}");
685        Some(quote! {
686            /// Generated package-owned Module Descriptor bytes.
687            pub const MODULE_DESCRIPTOR_JSON: &str = #descriptor_macro!();
688            #[doc(hidden)]
689            const __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
690                "LENSO_MODULE_DESCRIPTOR_V1\0",
691                #descriptor_macro!(),
692                "\0END_LENSO_MODULE_DESCRIPTOR_V1",
693            );
694            /// Linker-retained descriptor artifact consumed without executing package code.
695            #[doc(hidden)]
696            #[used]
697            pub static #artifact: &[u8] = __LENSO_MODULE_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
698
699            #[doc(hidden)]
700            mod #generated_module {
701                #[derive(Clone, Copy, Debug, Default)]
702                struct Factory;
703
704                impl #sdk::__private::NativeModuleFactory for Factory {
705                    fn package_id(&self) -> &'static str { super::PACKAGE_ID }
706                    fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
707
708                    fn instantiate(
709                        &self,
710                        context: #sdk::__private::NativeModuleFactoryContext<'_>,
711                    ) -> Result<
712                        #sdk::__private::NativeModuleInstance,
713                        #sdk::__private::RuntimeFailure,
714                    > {
715                        let module = super::#name::__lenso_construct(context)?;
716                        let lifecycle = super::#lifecycle_name { module };
717                        Ok(#sdk::__private::NativeModuleInstance::with_lifecycle(
718                            Vec::new(),
719                            lifecycle,
720                        ))
721                    }
722                }
723
724                fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativeModuleFactory> {
725                    ::std::rc::Rc::new(Factory)
726                }
727
728                #sdk::__private::__inventory::submit! {
729                    #sdk::__private::LinkedNativeModuleFactory::new(factory)
730                }
731            }
732        })
733    } else {
734        None
735    };
736
737    Ok(quote! {
738        /// Runtime package identity derived from Cargo package metadata.
739        pub const PACKAGE_ID: &str = #package_id;
740        /// Exact linked Cargo package version.
741        pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
742        /// Exact Host Build identity for this linked package.
743        pub const FACTORY_IDENTITY: &str = concat!(#package_id, "@", env!("CARGO_PKG_VERSION"));
744
745        #module
746
747        #[doc(hidden)]
748        macro_rules! #descriptor_macro {
749            () => {
750                concat!(#prefix, #schema, #after_schema, #suffix #(, #requirement_parts)*, #defaults)
751            };
752            ($first:expr $(, $rest:expr)*) => {
753                concat!(#prefix, #schema, #after_schema, $first $(, ",", $rest)*, #suffix #(, #requirement_parts)*, #defaults)
754            };
755        }
756
757        impl #name {
758            #[doc(hidden)]
759            fn __lenso_construct(
760                context: #sdk::__private::NativeModuleFactoryContext<'_>,
761            ) -> Result<Self, #sdk::__private::RuntimeFailure> {
762                if context.entrypoint() != "default" {
763                    return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
764                        detail: format!("unsupported {} entrypoint {}", #package_id, context.entrypoint()),
765                    });
766                }
767                #construct_configuration
768                Ok(Self { #(#initializers),* })
769            }
770        }
771
772        #[doc(hidden)]
773        #[derive(Clone, Debug)]
774        struct #lifecycle_name {
775            module: #name,
776        }
777
778        impl #sdk::__private::ModuleLifecycle for #lifecycle_name {
779            fn prepare(&self, context: #sdk::__private::PrepareContext) -> #sdk::__private::ModuleFuture {
780                #prepare
781            }
782
783            fn activate(&self, context: #sdk::__private::ActivateContext) -> #sdk::__private::ModuleFuture {
784                let connected = (|| -> Result<(), #sdk::__private::RuntimeFailure> {
785                    #(#connect_ports)*
786                    Ok(())
787                })();
788                if let Err(error) = connected {
789                    return Box::pin(#sdk::__private::futures::future::ready(Err(error)));
790                }
791                #activate
792            }
793
794            fn deactivate(&self, context: #sdk::__private::DeactivateContext) -> #sdk::__private::ModuleFuture {
795                #deactivate
796            }
797        }
798
799        const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
800        #schema_tracking
801
802        #consumer_finalizer
803    })
804}
805
806fn descriptor_affixes(
807    package_id: &str,
808    package_version: &str,
809) -> (String, &'static str, &'static str, &'static str) {
810    let prefix = format!(
811        "{{\"package_id\":{},\"package_revision\":{},\"entrypoint\":\"default\",\"configuration_schema\":",
812        serde_json::to_string(package_id).expect("package ID serializes"),
813        serde_json::to_string(package_version).expect("package version serializes"),
814    );
815    let after_schema = ",\"provided_capabilities\":[";
816    let suffix = "],\"required_capabilities\":[";
817    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\"}";
818    (prefix, after_schema, suffix, defaults)
819}
820
821fn schema_tracking(path: Option<&LitStr>) -> Option<proc_macro2::TokenStream> {
822    path.map(|path| {
823        quote!(
824            const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #path));
825        )
826    })
827}
828
829fn configuration_schema_tokens(
830    schema_path: Option<&LitStr>,
831    config_type: Option<&Type>,
832) -> syn::Result<proc_macro2::TokenStream> {
833    if let Some(path) = schema_path {
834        let schema = canonical_json(&read_configuration_schema(path)?);
835        return Ok(quote!(#schema));
836    }
837    let Some(config_type) = config_type else {
838        let schema = canonical_json(&json!({
839            "$schema": "https://json-schema.org/draft/2020-12/schema",
840            "type": "object",
841            "additionalProperties": false,
842            "required": [],
843            "properties": {},
844        }));
845        return Ok(quote!(#schema));
846    };
847    let Type::Path(config) = config_type else {
848        return Err(syn::Error::new_spanned(
849            config_type,
850            "the `#[config]` field type must be a path",
851        ));
852    };
853    let mut namespace = config.path.clone();
854    let config_name = namespace
855        .segments
856        .pop()
857        .expect("type paths are non-empty")
858        .into_value()
859        .ident;
860    namespace.segments.pop_punct();
861    let macro_name = format_ident!("__lenso_config_schema_{}", snake(&config_name.to_string()));
862    if namespace.segments.is_empty() {
863        Ok(quote!(#macro_name!()))
864    } else {
865        Ok(quote!(#namespace::#macro_name!()))
866    }
867}
868
869struct StructFields {
870    config_type: Option<Type>,
871    ports: Vec<(syn::Ident, Path, PortCardinality)>,
872    initializers: Vec<proc_macro2::TokenStream>,
873}
874
875#[derive(Clone, Copy)]
876enum PortCardinality {
877    One,
878    Many,
879}
880
881fn analyze_struct_fields(module: &mut ItemStruct) -> syn::Result<StructFields> {
882    let Fields::Named(fields) = &mut module.fields else {
883        return Err(syn::Error::new_spanned(
884            &module.fields,
885            "a struct-level Module requires named fields",
886        ));
887    };
888    let mut config = None;
889    let mut ports = Vec::new();
890    let mut initializers = Vec::new();
891    for field in &mut fields.named {
892        let name = field.ident.as_ref().expect("named fields have identifiers");
893        if take_marker(&mut field.attrs, "config") {
894            if config.replace(field.ty.clone()).is_some() {
895                return Err(syn::Error::new_spanned(
896                    field,
897                    "a Module has exactly one `#[config]` field",
898                ));
899            }
900            initializers.push(quote!(#name: configuration));
901        } else if let Some((client, cardinality)) = port_client(&field.ty)? {
902            ports.push((name.clone(), client, cardinality));
903            initializers.push(quote!(#name: ::core::default::Default::default()));
904        } else {
905            initializers.push(quote!(#name: ::core::default::Default::default()));
906        }
907    }
908    Ok(StructFields {
909        config_type: config,
910        ports,
911        initializers,
912    })
913}
914
915fn take_marker(attributes: &mut Vec<Attribute>, name: &str) -> bool {
916    let present = attributes
917        .iter()
918        .any(|attribute| attribute.path().is_ident(name));
919    attributes.retain(|attribute| !attribute.path().is_ident(name));
920    present
921}
922
923fn port_client(ty: &Type) -> syn::Result<Option<(Path, PortCardinality)>> {
924    let Type::Path(path) = ty else {
925        return Ok(None);
926    };
927    let Some(segment) = path.path.segments.last() else {
928        return Ok(None);
929    };
930    let cardinality = if segment.ident == "Port" {
931        PortCardinality::One
932    } else if segment.ident == "ManyPort" {
933        PortCardinality::Many
934    } else {
935        return Ok(None);
936    };
937    let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
938        return Err(syn::Error::new_spanned(
939            ty,
940            "Port or ManyPort requires one Capability client type",
941        ));
942    };
943    let Some(syn::GenericArgument::Type(Type::Path(client))) = arguments.args.first() else {
944        return Err(syn::Error::new_spanned(
945            ty,
946            "Port or ManyPort requires one Capability client type",
947        ));
948    };
949    if arguments.args.len() != 1 {
950        return Err(syn::Error::new_spanned(
951            ty,
952            "Port or ManyPort requires one Capability client type",
953        ));
954    }
955    Ok(Some((client.path.clone(), cardinality)))
956}
957
958fn requirement_macro(
959    client: &Path,
960    cardinality: PortCardinality,
961) -> syn::Result<proc_macro2::TokenStream> {
962    if client.segments.len() < 2 {
963        return Err(syn::Error::new_spanned(
964            client,
965            "a Port client must be namespace-qualified, for example `model::ModelClient`",
966        ));
967    }
968    let mut namespace = client.clone();
969    let client_name = namespace
970        .segments
971        .pop()
972        .expect("checked length")
973        .into_value()
974        .ident;
975    namespace.segments.pop_punct();
976    let prefix = match cardinality {
977        PortCardinality::One => "__lenso_required_",
978        PortCardinality::Many => "__lenso_required_many_",
979    };
980    let macro_name = format_ident!("{}{}", prefix, snake(&client_name.to_string()));
981    Ok(quote!(#namespace::#macro_name!()))
982}
983
984fn intersperse_commas(values: Vec<proc_macro2::TokenStream>) -> Vec<proc_macro2::TokenStream> {
985    values
986        .into_iter()
987        .enumerate()
988        .flat_map(|(index, value)| {
989            if index == 0 {
990                vec![value]
991            } else {
992                vec![quote!(","), value]
993            }
994        })
995        .collect()
996}
997
998fn hook(path: Option<&Path>, sdk: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
999    path.map_or_else(
1000        || quote!(Box::pin(#sdk::__private::futures::future::ready(Ok(())))),
1001        |path| quote!(#path(&self.module, &context)),
1002    )
1003}
1004
1005fn canonical_json(value: &Value) -> String {
1006    serde_json::to_string(value).expect("JSON values serialize")
1007}
1008
1009fn authoring_crate() -> proc_macro2::TokenStream {
1010    for package in ["lenso", "lenso-native-adapter"] {
1011        match crate_name(package) {
1012            Ok(FoundCrate::Itself) => {
1013                let ident = format_ident!("{}", package.replace('-', "_"));
1014                return quote!(::#ident);
1015            }
1016            Ok(FoundCrate::Name(name)) => {
1017                let ident = format_ident!("{name}");
1018                return quote!(::#ident);
1019            }
1020            Err(_) => {}
1021        }
1022    }
1023    quote!(::lenso_native_adapter)
1024}
1025
1026fn snake(value: &str) -> String {
1027    let mut output = String::new();
1028    for (index, character) in value.chars().enumerate() {
1029        if character.is_ascii_uppercase() && index > 0 {
1030            output.push('_');
1031        }
1032        output.push(character.to_ascii_lowercase());
1033    }
1034    output
1035}
1036
1037fn module_descriptor(
1038    package_id: &str,
1039    descriptor: &LitStr,
1040    configuration_schema: Option<&LitStr>,
1041) -> syn::Result<String> {
1042    let supplied: Value = serde_json::from_str(&descriptor.value()).map_err(|error| {
1043        syn::Error::new(
1044            descriptor.span(),
1045            format!("Module Descriptor input is not valid JSON: {error}"),
1046        )
1047    })?;
1048    let mut supplied = supplied.as_object().cloned().ok_or_else(|| {
1049        syn::Error::new(
1050            descriptor.span(),
1051            "Module Descriptor input must be an object",
1052        )
1053    })?;
1054    if supplied.contains_key("configuration_schema") {
1055        return Err(syn::Error::new(
1056            descriptor.span(),
1057            "Module Descriptor input cannot contain `configuration_schema`; use the package-owned schema path attribute",
1058        ));
1059    }
1060    if let Some(schema_path) = configuration_schema {
1061        supplied.insert(
1062            "configuration_schema".to_owned(),
1063            read_configuration_schema(schema_path)?,
1064        );
1065    }
1066    for owned in [
1067        "package_id",
1068        "package_revision",
1069        "entrypoint",
1070        "execution_class",
1071        "restart_policy",
1072        "criticality",
1073    ] {
1074        if supplied.contains_key(owned) {
1075            return Err(syn::Error::new(
1076                descriptor.span(),
1077                format!("Module Descriptor input cannot override generated field `{owned}`"),
1078            ));
1079        }
1080    }
1081    let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
1082        syn::Error::new(
1083            descriptor.span(),
1084            "CARGO_PKG_VERSION is unavailable while deriving Module Descriptor",
1085        )
1086    })?;
1087    Ok(complete_module_descriptor(
1088        package_id,
1089        &package_version,
1090        supplied,
1091    ))
1092}
1093
1094fn read_configuration_schema(schema_path: &LitStr) -> syn::Result<Value> {
1095    let relative = PathBuf::from(schema_path.value());
1096    if relative.is_absolute()
1097        || relative
1098            .components()
1099            .any(|component| !matches!(component, std::path::Component::Normal(_)))
1100    {
1101        return Err(syn::Error::new(
1102            schema_path.span(),
1103            "configuration Schema path must stay inside the Module package",
1104        ));
1105    }
1106    let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
1107        syn::Error::new(
1108            schema_path.span(),
1109            "CARGO_MANIFEST_DIR is unavailable while deriving configuration Schema",
1110        )
1111    })?;
1112    let path = PathBuf::from(manifest_dir).join(relative);
1113    let bytes = fs::read(&path).map_err(|error| {
1114        syn::Error::new(
1115            schema_path.span(),
1116            format!(
1117                "failed to read configuration Schema {}: {error}",
1118                path.display()
1119            ),
1120        )
1121    })?;
1122    let schema: Value = serde_json::from_slice(&bytes).map_err(|error| {
1123        syn::Error::new(
1124            schema_path.span(),
1125            format!(
1126                "configuration Schema {} is invalid JSON: {error}",
1127                path.display()
1128            ),
1129        )
1130    })?;
1131    if !schema.is_object() {
1132        return Err(syn::Error::new(
1133            schema_path.span(),
1134            "configuration Schema must be a JSON object",
1135        ));
1136    }
1137    Ok(schema)
1138}
1139
1140fn complete_module_descriptor(
1141    package_id: &str,
1142    package_version: &str,
1143    mut supplied: Map<String, Value>,
1144) -> String {
1145    let mut generated = Map::new();
1146    generated.insert("package_id".to_owned(), json!(package_id));
1147    generated.insert("package_revision".to_owned(), json!(package_version));
1148    generated.insert("entrypoint".to_owned(), json!("default"));
1149    for (key, value) in std::mem::take(&mut supplied) {
1150        generated.insert(key, value);
1151    }
1152    generated.insert("execution_class".to_owned(), json!("lenso.native-rust@1"));
1153    generated.insert(
1154        "restart_policy".to_owned(),
1155        json!({
1156            "mode": "never",
1157            "max_attempts": 0,
1158            "window": {"secs": 0, "nanos": 0},
1159            "backoff": {"secs": 0, "nanos": 0},
1160            "stability": {"secs": 0, "nanos": 0},
1161            "jitter": {"secs": 0, "nanos": 0}
1162        }),
1163    );
1164    generated.insert("criticality".to_owned(), json!("non_critical"));
1165    serde_json::to_string(&Value::Object(generated))
1166        .expect("generated Module Descriptor values must serialize")
1167}
1168
1169fn package_id() -> syn::Result<String> {
1170    let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
1171        syn::Error::new(
1172            proc_macro2::Span::call_site(),
1173            "CARGO_MANIFEST_DIR is unavailable",
1174        )
1175    })?;
1176    let manifest_path = PathBuf::from(manifest_dir).join("Cargo.toml");
1177    let manifest = fs::read_to_string(&manifest_path).map_err(|error| {
1178        syn::Error::new(
1179            proc_macro2::Span::call_site(),
1180            format!("failed to read {}: {error}", manifest_path.display()),
1181        )
1182    })?;
1183    let manifest: toml::Value = toml::from_str(&manifest).map_err(|error| {
1184        syn::Error::new(
1185            proc_macro2::Span::call_site(),
1186            format!("failed to parse {}: {error}", manifest_path.display()),
1187        )
1188    })?;
1189    manifest
1190        .get("package")
1191        .and_then(|package| package.get("metadata"))
1192        .and_then(|metadata| metadata.get("lenso"))
1193        .and_then(|lenso| lenso.get("package-id"))
1194        .and_then(toml::Value::as_str)
1195        .map(str::to_owned)
1196        .ok_or_else(|| {
1197            syn::Error::new(
1198                proc_macro2::Span::call_site(),
1199                "missing `[package.metadata.lenso] package-id = \"...\"` in Cargo.toml",
1200            )
1201        })
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207    use syn::parse_quote;
1208
1209    #[test]
1210    fn generated_descriptor_owns_identity_and_execution_defaults() {
1211        let supplied = serde_json::from_value::<Map<String, Value>>(json!({
1212            "provided_capabilities": [],
1213            "required_capabilities": []
1214        }))
1215        .unwrap();
1216        let descriptor = complete_module_descriptor("example.tool", "1.2.3", supplied);
1217        let descriptor: Value = serde_json::from_str(&descriptor).unwrap();
1218
1219        assert_eq!(descriptor["package_id"], "example.tool");
1220        assert_eq!(descriptor["package_revision"], "1.2.3");
1221        assert_eq!(descriptor["entrypoint"], "default");
1222        assert_eq!(descriptor["execution_class"], "lenso.native-rust@1");
1223        assert_eq!(descriptor["restart_policy"]["mode"], "never");
1224        assert_eq!(descriptor["criticality"], "non_critical");
1225    }
1226
1227    #[test]
1228    fn package_schema_is_embedded_as_descriptor_data() {
1229        let path = LitStr::new(
1230            "tests/fixtures/config.schema.json",
1231            proc_macro2::Span::call_site(),
1232        );
1233        let schema = read_configuration_schema(&path).unwrap();
1234
1235        assert_eq!(schema["type"], "object");
1236        assert_eq!(schema["required"], json!(["name"]));
1237    }
1238
1239    #[test]
1240    fn typed_ports_preserve_client_paths_and_cardinality() {
1241        let one: Type = parse_quote!(Port<secrets::SecretsClient>);
1242        let many: Type = parse_quote!(ManyPort<auth::AuthClient>);
1243
1244        let (one_client, one_cardinality) = port_client(&one).unwrap().unwrap();
1245        let (many_client, many_cardinality) = port_client(&many).unwrap().unwrap();
1246
1247        assert_eq!(quote!(#one_client).to_string(), "secrets :: SecretsClient");
1248        assert!(matches!(one_cardinality, PortCardinality::One));
1249        assert_eq!(quote!(#many_client).to_string(), "auth :: AuthClient");
1250        assert!(matches!(many_cardinality, PortCardinality::Many));
1251    }
1252
1253    #[test]
1254    fn multiple_capabilities_reject_trait_impls() {
1255        let implementation: ItemImpl = parse_quote! {
1256            impl fixture::Provider for ExampleModule {}
1257        };
1258        let error = expand_provides(
1259            &[parse_quote!(fixture::One), parse_quote!(fixture::Two)],
1260            &implementation,
1261        )
1262        .expect_err("multi-Capability authoring must have one inherent impl");
1263
1264        assert!(
1265            error
1266                .to_string()
1267                .contains("multiple Capabilities require one inherent impl")
1268        );
1269    }
1270
1271    #[test]
1272    fn duplicate_capabilities_are_rejected() {
1273        let implementation: ItemImpl = parse_quote! { impl ExampleModule {} };
1274        let error = expand_provides(
1275            &[parse_quote!(fixture::One), parse_quote!(fixture::One)],
1276            &implementation,
1277        )
1278        .expect_err("one Capability cannot be contributed twice");
1279
1280        assert!(error.to_string().contains("same Capability more than once"));
1281    }
1282
1283    #[test]
1284    fn capability_paths_must_be_namespace_qualified() {
1285        let implementation: ItemImpl = parse_quote! { impl ExampleModule {} };
1286        let error = expand_provides(&[parse_quote!(One)], &implementation)
1287            .expect_err("generated Capability macros live in their namespace");
1288
1289        assert!(error.to_string().contains("namespace-qualified"));
1290    }
1291}