Skip to main content

lenso_native_adapter_macros/
lib.rs

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