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
136/// Binds an optional complete-object constructor and stop hook to a Plugin type.
137#[proc_macro_attribute]
138pub fn plugin_impl(attributes: TokenStream, item: TokenStream) -> TokenStream {
139    if !attributes.is_empty() {
140        return syn::Error::new(
141            proc_macro2::Span::call_site(),
142            "#[plugin_impl] does not accept arguments",
143        )
144        .into_compile_error()
145        .into();
146    }
147    let implementation = parse_macro_input!(item as ItemImpl);
148    expand_plugin_impl(implementation)
149        .unwrap_or_else(syn::Error::into_compile_error)
150        .into()
151}
152
153#[allow(clippy::too_many_lines)]
154fn expand_plugin_impl(mut implementation: ItemImpl) -> syn::Result<proc_macro2::TokenStream> {
155    if implementation.trait_.is_some() || !implementation.generics.params.is_empty() {
156        return Err(syn::Error::new_spanned(
157            &implementation,
158            "#[plugin_impl] requires a non-generic inherent impl",
159        ));
160    }
161    let Type::Path(plugin_path) = implementation.self_ty.as_ref() else {
162        return Err(syn::Error::new_spanned(
163            &implementation.self_ty,
164            "Plugin implementation type must be a path",
165        ));
166    };
167    let plugin_type = &plugin_path.path;
168    let plugin_ident = plugin_type
169        .segments
170        .last()
171        .expect("type paths are non-empty")
172        .ident
173        .clone();
174    let inputs_name = format_ident!("__LensoInputs{plugin_ident}");
175    let module_name = format_ident!(
176        "__lenso_custom_construction_{}",
177        snake(&plugin_ident.to_string())
178    );
179    let sdk = authoring_crate();
180    let mut create = None;
181    let mut stop = None;
182    for item in &mut implementation.items {
183        let syn::ImplItem::Fn(method) = item else {
184            continue;
185        };
186        let is_create = take_marker(&mut method.attrs, "create");
187        let is_stop = take_marker(&mut method.attrs, "stop");
188        let marked_method = method.clone();
189        for input in &mut method.sig.inputs {
190            if let syn::FnArg::Typed(argument) = input {
191                argument
192                    .attrs
193                    .retain(|attribute| !attribute.path().is_ident("lifecycle"));
194            }
195        }
196        if is_create && is_stop {
197            return Err(syn::Error::new_spanned(
198                method,
199                "one method cannot be both create and stop",
200            ));
201        }
202        if is_create && create.replace(marked_method.clone()).is_some() {
203            return Err(syn::Error::new_spanned(
204                method,
205                "duplicate #[create] method",
206            ));
207        }
208        if is_stop && stop.replace(marked_method).is_some() {
209            return Err(syn::Error::new_spanned(method, "duplicate #[stop] method"));
210        }
211    }
212    if create.is_none() && stop.is_none() {
213        return Ok(quote!(#implementation));
214    }
215
216    let construct_body = if let Some(create) = &create {
217        expand_create_call(create, plugin_type, &inputs_name, &sdk)?
218    } else {
219        quote!(super::#plugin_type::__lenso_auto_construct(context))
220    };
221    let (stop_function, stop_entry) = if let Some(stop) = &stop {
222        let body = expand_stop_call(stop, plugin_type, &sdk)?;
223        (
224            quote! {
225                fn stop(
226                    object: ::std::rc::Rc<dyn ::std::any::Any>,
227                    lifecycle: #sdk::__private::LifecycleContext,
228                ) -> #sdk::__private::PluginFuture {
229                    let object = match object.downcast::<super::#plugin_type>() {
230                        Ok(object) => object,
231                        Err(_) => return Box::pin(async {
232                            Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
233                                detail: "linked Plugin stop hook received the wrong type".to_owned(),
234                            })
235                        }),
236                    };
237                    #body
238                }
239            },
240            quote!(Some(stop)),
241        )
242    } else {
243        (quote!(), quote!(None))
244    };
245
246    Ok(quote! {
247        #implementation
248
249        #[doc(hidden)]
250        mod #module_name {
251            const _: () = assert!(
252                super::#plugin_type::__LENSO_AUTHORING_VERSION == 2,
253                "#[plugin_impl] create/stop hooks cannot be combined with legacy lifecycle, Port, resources, or tasks fields",
254            );
255
256            fn plugin_type() -> ::std::any::TypeId {
257                ::std::any::TypeId::of::<super::#plugin_type>()
258            }
259
260            fn construct(
261                context: #sdk::__private::ConstructionContext,
262            ) -> #sdk::__private::ErasedConstructionFuture {
263                #construct_body
264            }
265
266            #stop_function
267
268            #sdk::__private::__inventory::submit! {
269                #sdk::__private::LinkedPluginConstruction::new(
270                    plugin_type,
271                    true,
272                    construct,
273                    #stop_entry,
274                )
275            }
276        }
277    })
278}
279
280fn expand_create_call(
281    method: &syn::ImplItemFn,
282    plugin_type: &Path,
283    inputs_name: &syn::Ident,
284    sdk: &proc_macro2::TokenStream,
285) -> syn::Result<proc_macro2::TokenStream> {
286    let mut names = Vec::new();
287    let mut arguments = Vec::new();
288    for input in &method.sig.inputs {
289        let syn::FnArg::Typed(argument) = input else {
290            return Err(syn::Error::new_spanned(
291                input,
292                "#[create] is an associated function without a receiver",
293            ));
294        };
295        let syn::Pat::Ident(pattern) = argument.pat.as_ref() else {
296            return Err(syn::Error::new_spanned(
297                &argument.pat,
298                "#[create] inputs must be plain identifiers",
299            ));
300        };
301        let lifecycle = argument
302            .attrs
303            .iter()
304            .any(|attribute| attribute.path().is_ident("lifecycle"));
305        if lifecycle {
306            if !is_named_type(&argument.ty, "LifecycleContext") {
307                return Err(syn::Error::new_spanned(
308                    &argument.ty,
309                    "#[lifecycle] input must have type LifecycleContext",
310                ));
311            }
312            arguments.push(quote!(context.lifecycle().clone()));
313        } else {
314            names.push(pattern.ident.clone());
315            arguments.push(quote!(#pattern));
316        }
317    }
318    let method_name = &method.sig.ident;
319    let invoke = if method.sig.asyncness.is_some() {
320        quote!(super::#plugin_type::#method_name(#(#arguments),*).await)
321    } else {
322        quote!(super::#plugin_type::#method_name(#(#arguments),*))
323    };
324    let value = if returns_result(&method.sig.output) {
325        quote!(#invoke.map_err(|error| #sdk::__private::RuntimeFailure::PluginFailure {
326            detail: format!("Plugin construction failed: {error}"),
327        })?)
328    } else {
329        invoke
330    };
331    Ok(quote! {
332        Box::pin(async move {
333            let super::#inputs_name { #(#names),* } =
334                super::#plugin_type::__lenso_inputs(&context)?;
335            let plugin = #value;
336            Ok(::std::rc::Rc::new(plugin) as ::std::rc::Rc<dyn ::std::any::Any>)
337        })
338    })
339}
340
341fn expand_stop_call(
342    method: &syn::ImplItemFn,
343    plugin_type: &Path,
344    sdk: &proc_macro2::TokenStream,
345) -> syn::Result<proc_macro2::TokenStream> {
346    let mut inputs = method.sig.inputs.iter();
347    let Some(syn::FnArg::Receiver(receiver)) = inputs.next() else {
348        return Err(syn::Error::new_spanned(
349            &method.sig,
350            "#[stop] requires an &self receiver",
351        ));
352    };
353    if receiver.reference.is_none() || receiver.mutability.is_some() {
354        return Err(syn::Error::new_spanned(receiver, "#[stop] requires &self"));
355    }
356    let mut arguments = Vec::new();
357    for input in inputs {
358        let syn::FnArg::Typed(argument) = input else {
359            return Err(syn::Error::new_spanned(input, "invalid #[stop] input"));
360        };
361        if !argument
362            .attrs
363            .iter()
364            .any(|attribute| attribute.path().is_ident("lifecycle"))
365            || !is_named_type(&argument.ty, "LifecycleContext")
366        {
367            return Err(syn::Error::new_spanned(
368                input,
369                "#[stop] accepts only an optional #[lifecycle] LifecycleContext",
370            ));
371        }
372        arguments.push(quote!(lifecycle));
373    }
374    if arguments.len() > 1 {
375        return Err(syn::Error::new_spanned(
376            &method.sig,
377            "#[stop] accepts at most one lifecycle input",
378        ));
379    }
380    let method_name = &method.sig.ident;
381    let invoke = if method.sig.asyncness.is_some() {
382        quote!(super::#plugin_type::#method_name(object.as_ref(), #(#arguments),*).await)
383    } else {
384        quote!(super::#plugin_type::#method_name(object.as_ref(), #(#arguments),*))
385    };
386    let result = if returns_result(&method.sig.output) {
387        quote!(#invoke.map_err(|error| #sdk::__private::RuntimeFailure::PluginFailure {
388            detail: format!("Plugin stop failed: {error}"),
389        }))
390    } else {
391        quote!({ #invoke; Ok(()) })
392    };
393    Ok(quote!(Box::pin(async move { #result })))
394}
395
396fn returns_result(output: &syn::ReturnType) -> bool {
397    let syn::ReturnType::Type(_, ty) = output else {
398        return false;
399    };
400    let Type::Path(path) = ty.as_ref() else {
401        return false;
402    };
403    path.path
404        .segments
405        .last()
406        .is_some_and(|segment| segment.ident == "Result")
407}
408
409fn expand_authoring_item(attributes: TokenStream, item: TokenStream) -> TokenStream {
410    let attributes = match syn::parse::<PluginAttributes>(attributes) {
411        Ok(attributes) => attributes,
412        Err(error) => return authoring_error(&error),
413    };
414    let item = match syn::parse::<Item>(item) {
415        Ok(item) => item,
416        Err(error) => return authoring_error(&error),
417    };
418    let expanded = match item {
419        Item::Fn(function) => expand_plugin_function(&attributes, &function),
420        Item::Struct(plugin) => expand_plugin_struct(&attributes, plugin),
421        other => Err(syn::Error::new_spanned(
422            other,
423            "a native Plugin must be declared by a factory function or a named-field struct",
424        )),
425    };
426    match expanded {
427        Ok(tokens) => tokens.into(),
428        Err(error) => authoring_error(&error),
429    }
430}
431
432fn authoring_error(error: &syn::Error) -> TokenStream {
433    syn::Error::new(error.span(), error.to_string())
434        .into_compile_error()
435        .into()
436}
437
438/// Derives the locked JSON Schema fragment consumed by a struct-level Plugin.
439#[proc_macro_derive(PluginConfig, attributes(lenso, serde))]
440pub fn plugin_config(item: TokenStream) -> TokenStream {
441    let input = match syn::parse::<DeriveInput>(item) {
442        Ok(input) => input,
443        Err(error) => return authoring_error(&error),
444    };
445    match expand_plugin_config(&input) {
446        Ok(tokens) => tokens.into(),
447        Err(error) => authoring_error(&error),
448    }
449}
450
451fn expand_plugin_config(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
452    let Data::Struct(data) = &input.data else {
453        return Err(syn::Error::new_spanned(
454            input,
455            "Plugin configuration must be a named-field struct",
456        ));
457    };
458    let Fields::Named(fields) = &data.fields else {
459        return Err(syn::Error::new_spanned(
460            &data.fields,
461            "Plugin configuration must use named fields",
462        ));
463    };
464    let mut properties = Map::new();
465    let mut defaults = Map::new();
466    let mut required = Vec::new();
467    for field in &fields.named {
468        let ident = field.ident.as_ref().expect("named fields have identifiers");
469        let name = serde_field_name(&field.attrs, ident)?;
470        let (schema, optional) = configuration_type_schema(&field.ty)?;
471        if let Some(default) = configuration_field_default(&field.attrs)? {
472            if !configuration_value_matches_schema(&default, &schema) {
473                return Err(syn::Error::new_spanned(
474                    field,
475                    "Plugin configuration default does not match the field type",
476                ));
477            }
478            defaults.insert(name.clone(), default);
479        }
480        properties.insert(name.clone(), schema);
481        if !optional {
482            required.push(Value::String(name));
483        }
484    }
485    let schema = canonical_json(&json!({
486        "$schema": "https://json-schema.org/draft/2020-12/schema",
487        "type": "object",
488        "additionalProperties": false,
489        "required": required,
490        "properties": properties,
491    }));
492    let defaults = canonical_json(&Value::Object(defaults));
493    let macro_name = format_ident!("__lenso_config_schema_{}", snake(&input.ident.to_string()));
494    let defaults_macro_name = format_ident!(
495        "__lenso_config_defaults_{}",
496        snake(&input.ident.to_string())
497    );
498    Ok(quote! {
499        #[doc(hidden)]
500        #[macro_export]
501        macro_rules! #macro_name {
502            () => { #schema };
503        }
504        #[doc(hidden)]
505        #[macro_export]
506        macro_rules! #defaults_macro_name {
507            () => { #defaults };
508        }
509    })
510}
511
512fn configuration_field_default(attributes: &[Attribute]) -> syn::Result<Option<Value>> {
513    let mut default = None;
514    for attribute in attributes {
515        if !attribute.path().is_ident("lenso") {
516            continue;
517        }
518        attribute.parse_nested_meta(|meta| {
519            if !meta.path.is_ident("default") {
520                return Err(meta.error("expected `default = <JSON literal>`"));
521            }
522            if default.is_some() {
523                return Err(meta.error("duplicate Plugin configuration default"));
524            }
525            let expression = meta.value()?.parse::<Expr>()?;
526            let encoded = quote!(#expression).to_string();
527            default = Some(serde_json::from_str(&encoded).map_err(|error| {
528                meta.error(format!(
529                    "Plugin configuration default must be a JSON literal: {error}"
530                ))
531            })?);
532            Ok(())
533        })?;
534    }
535    Ok(default)
536}
537
538fn configuration_value_matches_schema(value: &Value, schema: &Value) -> bool {
539    match schema.get("type").and_then(Value::as_str) {
540        Some("array") => value.as_array().is_some_and(|items| {
541            schema.get("items").is_some_and(|schema| {
542                items
543                    .iter()
544                    .all(|item| configuration_value_matches_schema(item, schema))
545            })
546        }),
547        Some("boolean") => value.is_boolean(),
548        Some("integer") => value
549            .as_number()
550            .is_some_and(|number| number.is_i64() || number.is_u64()),
551        Some("number") => value.is_number(),
552        Some("string") => value.is_string(),
553        _ => false,
554    }
555}
556
557fn serde_field_name(attributes: &[Attribute], ident: &syn::Ident) -> syn::Result<String> {
558    let mut name = ident.to_string();
559    for attribute in attributes {
560        if !attribute.path().is_ident("serde") {
561            continue;
562        }
563        attribute.parse_nested_meta(|meta| {
564            if meta.path.is_ident("rename") {
565                name = meta.value()?.parse::<LitStr>()?.value();
566            }
567            Ok(())
568        })?;
569    }
570    Ok(name)
571}
572
573fn configuration_type_schema(ty: &Type) -> syn::Result<(Value, bool)> {
574    let Type::Path(path) = ty else {
575        return Err(syn::Error::new_spanned(
576            ty,
577            "Plugin configuration fields must use portable named types",
578        ));
579    };
580    let segment = path.path.segments.last().expect("type paths are non-empty");
581    let name = segment.ident.to_string();
582    if name == "Option" {
583        return Ok((configuration_inner_schema(segment, ty)?, true));
584    }
585    if name == "Vec" {
586        return Ok((
587            json!({"type": "array", "items": configuration_inner_schema(segment, ty)?}),
588            false,
589        ));
590    }
591    let schema = match name.as_str() {
592        "String" => json!({"type": "string"}),
593        "bool" => json!({"type": "boolean"}),
594        "f32" | "f64" => json!({"type": "number"}),
595        "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
596        | "usize" => json!({"type": "integer"}),
597        _ => {
598            return Err(syn::Error::new_spanned(
599                ty,
600                "unsupported Plugin configuration field type; use String, bool, a number, Option<T>, or Vec<T>",
601            ));
602        }
603    };
604    Ok((schema, false))
605}
606
607fn configuration_inner_schema(segment: &syn::PathSegment, ty: &Type) -> syn::Result<Value> {
608    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
609        return Err(syn::Error::new_spanned(
610            ty,
611            "configuration container requires one type",
612        ));
613    };
614    let [GenericArgument::Type(inner)] = arguments.args.iter().collect::<Vec<_>>().as_slice()
615    else {
616        return Err(syn::Error::new_spanned(
617            ty,
618            "configuration container requires one type",
619        ));
620    };
621    configuration_type_schema(inner).map(|(schema, _)| schema)
622}
623
624fn expand_plugin_function(
625    attributes: &PluginAttributes,
626    function: &ItemFn,
627) -> syn::Result<proc_macro2::TokenStream> {
628    let sdk = authoring_crate();
629    if attributes.descriptor.is_none()
630        && (attributes.configuration_schema.is_some()
631            || attributes.configuration_defaults.is_some())
632    {
633        return Err(syn::Error::new_spanned(
634            function,
635            "`configuration_schema` and `configuration_defaults` require `descriptor` on a factory function",
636        ));
637    }
638    if attributes.validate.is_some()
639        || attributes.prepare.is_some()
640        || attributes.activate.is_some()
641        || attributes.deactivate.is_some()
642        || attributes.lifecycle
643        || attributes.consumer
644    {
645        return Err(syn::Error::new_spanned(
646            function,
647            "struct-level Plugin attributes are unavailable on factory functions",
648        ));
649    }
650    let (plugin_id, root_slot) = plugin_metadata()?;
651    let descriptor_json = attributes
652        .descriptor
653        .as_ref()
654        .map(|descriptor| {
655            plugin_descriptor(
656                &plugin_id,
657                &root_slot,
658                descriptor,
659                attributes.configuration_schema.as_ref(),
660                attributes.configuration_defaults.as_ref(),
661            )
662        })
663        .transpose()?;
664    let function_name = &function.sig.ident;
665    let generated_plugin = format_ident!("__lenso_plugin_{function_name}");
666    let descriptor_constant = descriptor_json.map(|descriptor| {
667        let artifact =
668            format!("LENSO_PLUGIN_DESCRIPTOR_V1\0{descriptor}\0END_LENSO_PLUGIN_DESCRIPTOR_V1");
669        let artifact_length = artifact.len();
670        let artifact = proc_macro2::Literal::byte_string(artifact.as_bytes());
671        let package_file_tracking = package_file_tracking([
672            attributes.configuration_schema.as_ref(),
673            attributes.configuration_defaults.as_ref(),
674        ]);
675        quote! {
676            /// Generated package-owned Plugin Descriptor bytes.
677            pub const PLUGIN_DESCRIPTOR_JSON: &str = #descriptor;
678            /// Linker-retained descriptor artifact consumed without executing package code.
679            #[doc(hidden)]
680            #[used]
681            pub static __LENSO_PLUGIN_DESCRIPTOR_ARTIFACT: [u8; #artifact_length] = *#artifact;
682            #(#package_file_tracking)*
683        }
684    });
685
686    Ok(quote! {
687        /// Runtime package identity derived from Cargo package metadata.
688        pub const PACKAGE_ID: &str = #plugin_id;
689        /// Exact linked Cargo package version.
690        pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
691        /// Exact Host Build identity for this linked package.
692        pub const FACTORY_IDENTITY: &str = concat!(#plugin_id, "@", env!("CARGO_PKG_VERSION"));
693        #descriptor_constant
694
695        #function
696
697        #[doc(hidden)]
698        mod #generated_plugin {
699            #[derive(Clone, Copy, Debug, Default)]
700            struct Factory;
701
702            impl #sdk::__private::NativePluginFactory for Factory {
703                fn package_id(&self) -> &'static str {
704                    #plugin_id
705                }
706
707                fn package_version(&self) -> &'static str {
708                    env!("CARGO_PKG_VERSION")
709                }
710
711                fn instantiate(
712                    &self,
713                    context: #sdk::__private::NativePluginFactoryContext<'_>,
714                ) -> Result<
715                    #sdk::__private::NativePluginInstance,
716                    #sdk::__private::RuntimeFailure,
717                > {
718                    super::#function_name(context)
719                }
720            }
721
722            fn factory() -> std::rc::Rc<dyn #sdk::__private::NativePluginFactory> {
723                std::rc::Rc::new(Factory)
724            }
725
726            #sdk::__private::__inventory::submit! {
727                #sdk::__private::LinkedNativePluginFactory::new(
728                    factory,
729                    super::PLUGIN_DESCRIPTOR_JSON,
730                )
731            }
732
733            // Make Cargo track the manifest that supplied the generated identity.
734            const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
735        }
736    })
737}
738
739/// Derives one or more provided Capability endpoints, one native factory, and static registration.
740///
741/// Apply this to one inherent implementation containing the Capabilities' domain methods
742/// for a struct already annotated with [`plugin`]. Generated bindings lower the
743/// methods into the Adapter-facing Provider trait. Existing explicit Provider
744/// trait implementations remain supported as a single-Capability compatibility
745/// escape hatch. Multi-Capability Plugins must use one inherent implementation.
746#[proc_macro_attribute]
747pub fn provides(attributes: TokenStream, item: TokenStream) -> TokenStream {
748    let capabilities =
749        parse_macro_input!(attributes with Punctuated::<Path, Token![,]>::parse_terminated);
750    let implementation = parse_macro_input!(item as ItemImpl);
751    expand_provides(
752        &capabilities.into_iter().collect::<Vec<_>>(),
753        &implementation,
754    )
755    .unwrap_or_else(syn::Error::into_compile_error)
756    .into()
757}
758
759struct CapabilityContribution {
760    namespace: Path,
761    descriptor: syn::Ident,
762    endpoints: syn::Ident,
763    lower: syn::Ident,
764    object_lower: syn::Ident,
765    trait_object_lower: syn::Ident,
766}
767
768fn capability_contributions(capabilities: &[Path]) -> syn::Result<Vec<CapabilityContribution>> {
769    let mut seen = BTreeSet::new();
770    capabilities
771        .iter()
772        .map(|capability| {
773            let path = quote!(#capability).to_string();
774            if !seen.insert(path) {
775                return Err(syn::Error::new_spanned(
776                    capability,
777                    "a Plugin cannot provide the same Capability more than once",
778                ));
779            }
780            let mut namespace = capability.clone();
781            let capability_ident = namespace
782                .segments
783                .pop()
784                .ok_or_else(|| syn::Error::new_spanned(capability, "Capability path is empty"))?
785                .into_value()
786                .ident;
787            namespace.segments.pop_punct();
788            if namespace.segments.is_empty() {
789                return Err(syn::Error::new_spanned(
790                    capability,
791                    "Capability must be namespace-qualified, for example `agent::Agent`",
792                ));
793            }
794            let capability_snake = snake(&capability_ident.to_string());
795            Ok(CapabilityContribution {
796                namespace,
797                descriptor: format_ident!("__lenso_provided_{capability_snake}"),
798                endpoints: format_ident!("__lenso_native_endpoints_{capability_snake}"),
799                lower: format_ident!("__lenso_native_lower_{capability_snake}"),
800                object_lower: format_ident!("__lenso_native_lower_object_{capability_snake}"),
801                trait_object_lower: format_ident!(
802                    "__lenso_native_lower_trait_object_{capability_snake}"
803                ),
804            })
805        })
806        .collect()
807}
808
809fn provided_module(
810    capabilities: &[Path],
811    implementation: &ItemImpl,
812) -> syn::Result<(syn::Ident, bool)> {
813    if capabilities.is_empty() {
814        return Err(syn::Error::new_spanned(
815            implementation,
816            "`provides` requires at least one namespace-qualified Capability",
817        ));
818    }
819    if capabilities.len() > 1 && implementation.trait_.is_some() {
820        return Err(syn::Error::new_spanned(
821            implementation,
822            "multiple Capabilities require one inherent impl containing their domain methods",
823        ));
824    }
825    let Type::Path(plugin_type) = implementation.self_ty.as_ref() else {
826        return Err(syn::Error::new_spanned(
827            &implementation.self_ty,
828            "the Plugin provider type must be a path",
829        ));
830    };
831    let plugin_ident = plugin_type
832        .path
833        .segments
834        .last()
835        .ok_or_else(|| {
836            syn::Error::new_spanned(&plugin_type.path, "the Plugin provider type is empty")
837        })?
838        .ident
839        .clone();
840    Ok((plugin_ident, implementation.trait_.is_none()))
841}
842
843#[allow(clippy::too_many_lines)]
844fn expand_provides(
845    capabilities: &[Path],
846    implementation: &ItemImpl,
847) -> syn::Result<proc_macro2::TokenStream> {
848    let sdk = authoring_crate();
849    let (plugin_ident, lowers_domain_methods) = provided_module(capabilities, implementation)?;
850    let contributions = capability_contributions(capabilities)?;
851    let provided_descriptors = contributions
852        .iter()
853        .map(|contribution| {
854            let namespace = &contribution.namespace;
855            let descriptor = &contribution.descriptor;
856            quote!(#namespace::#descriptor!())
857        })
858        .collect::<Vec<_>>();
859    let plugin_descriptor = format_ident!(
860        "__lenso_plugin_descriptor_{}",
861        snake(&plugin_ident.to_string())
862    );
863    let generated_plugin = format_ident!("__lenso_provider_{}", snake(&plugin_ident.to_string()));
864    let lifecycle = format_ident!("__LensoLifecycle{plugin_ident}");
865    let artifact = format_ident!("__LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_{plugin_ident}");
866    let provider_implementations = contributions
867        .iter()
868        .flat_map(|contribution| {
869            let namespace = &contribution.namespace;
870            let lower = &contribution.lower;
871            let object_lower = if lowers_domain_methods {
872                &contribution.object_lower
873            } else {
874                &contribution.trait_object_lower
875            };
876            let mut implementations = Vec::new();
877            if lowers_domain_methods {
878                implementations.push(quote! {
879                    #namespace::#lower!(#plugin_ident, #sdk::__private);
880                });
881            }
882            implementations.push(quote! {
883                #namespace::#object_lower!(
884                    #sdk::__private::PluginObject<#plugin_ident>,
885                    #plugin_ident,
886                    #sdk::__private
887                );
888            });
889            implementations
890        })
891        .collect::<Vec<_>>();
892    let endpoint_contributions = contributions
893        .iter()
894        .map(|contribution| {
895            let namespace = &contribution.namespace;
896            let endpoints = &contribution.endpoints;
897            quote! {
898                let (provided_requests, provided_streams, provided_events) =
899                    super::#namespace::#endpoints!(plugin.clone(), #sdk::__private);
900                request_endpoints.extend(provided_requests);
901                stream_endpoints.extend(provided_streams);
902                event_endpoints.extend(provided_events);
903            }
904        })
905        .collect::<Vec<_>>();
906    let v2_endpoint_contributions = endpoint_contributions.clone();
907
908    let mut implementation = implementation.clone();
909    implementation
910        .attrs
911        .push(syn::parse_quote!(#[allow(clippy::unused_async, clippy::unused_async_trait_impl)]));
912
913    Ok(quote! {
914        #implementation
915        #(#provider_implementations)*
916
917        /// Generated package-owned Plugin Descriptor bytes.
918        pub const PLUGIN_DESCRIPTOR_JSON: &str = #plugin_descriptor!(
919            #(#provided_descriptors),*
920        );
921        #[doc(hidden)]
922        const __LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
923            "LENSO_PLUGIN_DESCRIPTOR_V1\0",
924            #plugin_descriptor!(#(#provided_descriptors),*),
925            "\0END_LENSO_PLUGIN_DESCRIPTOR_V1",
926        );
927        /// Linker-retained descriptor artifact consumed without executing package code.
928        #[doc(hidden)]
929        #[used]
930        pub static #artifact: &[u8] = __LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
931
932        #[doc(hidden)]
933        mod #generated_plugin {
934            #[derive(Clone, Copy, Debug, Default)]
935            struct Factory;
936
937            impl #sdk::__private::NativePluginFactory for Factory {
938                fn package_id(&self) -> &'static str { super::PACKAGE_ID }
939                fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
940                fn runtime_profile(&self) -> &'static str {
941                    super::#plugin_ident::__LENSO_RUNTIME_PROFILE
942                }
943
944                fn instantiate(
945                    &self,
946                    context: #sdk::__private::NativePluginFactoryContext<'_>,
947                ) -> Result<
948                    #sdk::__private::NativePluginInstance,
949                    #sdk::__private::RuntimeFailure,
950                > {
951                    if super::#plugin_ident::__LENSO_AUTHORING_VERSION == 2 {
952                        let plugin = #sdk::__private::PluginObject::<super::#plugin_ident>::empty();
953                        let lifecycle = #sdk::__private::CompleteObjectLifecycle::linked(
954                            plugin.clone(),
955                            context.configuration(),
956                        )?;
957                        let mut request_endpoints = Vec::new();
958                        let mut stream_endpoints = Vec::new();
959                        let mut event_endpoints = Vec::new();
960                        #(#v2_endpoint_contributions)*
961                        return Ok(#sdk::__private::NativePluginInstance::with_all_endpoints(
962                            request_endpoints,
963                            stream_endpoints,
964                            event_endpoints,
965                            lifecycle,
966                        ));
967                    }
968                    let plugin = ::std::rc::Rc::new(
969                        super::#plugin_ident::__lenso_construct(context)?,
970                    );
971                    let lifecycle = super::#lifecycle { plugin: plugin.clone() };
972                    let plugin = #sdk::__private::PluginObject::from_value(plugin);
973                    let mut request_endpoints = Vec::new();
974                    let mut stream_endpoints = Vec::new();
975                    let mut event_endpoints = Vec::new();
976                    #(#endpoint_contributions)*
977                    Ok(#sdk::__private::NativePluginInstance::with_all_endpoints(
978                        request_endpoints,
979                        stream_endpoints,
980                        event_endpoints,
981                        lifecycle,
982                    ))
983                }
984            }
985
986            fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativePluginFactory> {
987                ::std::rc::Rc::new(Factory)
988            }
989
990            #sdk::__private::__inventory::submit! {
991                #sdk::__private::LinkedNativePluginFactory::new(
992                    factory,
993                    super::PLUGIN_DESCRIPTOR_JSON,
994                )
995            }
996        }
997    })
998}
999
1000#[allow(clippy::too_many_lines)]
1001fn expand_plugin_struct(
1002    attributes: &PluginAttributes,
1003    mut plugin: ItemStruct,
1004) -> syn::Result<proc_macro2::TokenStream> {
1005    let sdk = authoring_crate();
1006    if attributes.descriptor.is_some() {
1007        return Err(syn::Error::new_spanned(
1008            &plugin.ident,
1009            "struct-level Plugins derive their Descriptor; remove `descriptor`",
1010        ));
1011    }
1012    let (plugin_id, root_slot) = plugin_metadata()?;
1013    let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
1014        syn::Error::new_spanned(
1015            &plugin.ident,
1016            "CARGO_PKG_VERSION is unavailable while deriving Plugin Descriptor",
1017        )
1018    })?;
1019    let StructFields {
1020        config_type,
1021        ports,
1022        tasks,
1023        initializers,
1024        construction_fields,
1025    } = analyze_struct_fields(&mut plugin, &sdk)?;
1026    let schema = configuration_schema_tokens(
1027        attributes.configuration_schema.as_ref(),
1028        config_type.as_ref(),
1029    )?;
1030    let configuration_defaults = configuration_defaults_tokens(
1031        attributes.configuration_schema.as_ref(),
1032        attributes.configuration_defaults.as_ref(),
1033        config_type.as_ref(),
1034    )?;
1035    let name = &plugin.ident;
1036    let inputs_name = format_ident!("__LensoInputs{name}");
1037    let input_fields = construction_fields
1038        .iter()
1039        .filter_map(|field| match field.kind {
1040            ConstructionFieldKind::Config | ConstructionFieldKind::Dependency { .. } => {
1041                let name = &field.name;
1042                let ty = &field.ty;
1043                Some(quote!(#name: #ty))
1044            }
1045            ConstructionFieldKind::Private | ConstructionFieldKind::Legacy => None,
1046        });
1047    let input_initializers = construction_fields
1048        .iter()
1049        .filter_map(|field| v2_input_initializer(field, &sdk));
1050    let construction_module = format_ident!("__lenso_construction_{}", snake(&name.to_string()));
1051    let v2_configuration = construct_v2_configuration(
1052        &plugin_id,
1053        config_type.as_ref(),
1054        attributes.validate.as_ref(),
1055        &sdk,
1056    );
1057    let v2_initializers = construction_fields
1058        .iter()
1059        .map(|field| v2_field_initializer(field, &sdk))
1060        .collect::<Vec<_>>();
1061    let uses_legacy_authoring = construction_fields
1062        .iter()
1063        .any(|field| matches!(field.kind, ConstructionFieldKind::Legacy))
1064        || attributes.lifecycle
1065        || attributes.prepare.is_some()
1066        || attributes.activate.is_some()
1067        || attributes.deactivate.is_some();
1068    let authoring_version = if uses_legacy_authoring { 1_u32 } else { 2_u32 };
1069    let runtime_profile = if uses_legacy_authoring {
1070        "lenso.native-authoring@1"
1071    } else {
1072        "lenso.native-authoring@2"
1073    };
1074    let v2_construct = if uses_legacy_authoring {
1075        quote! {
1076            Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1077                detail: "legacy Plugin fields cannot use authoring version 2".to_owned(),
1078            })
1079        }
1080    } else {
1081        quote! {
1082            #v2_configuration
1083            let plugin = Self { #(#v2_initializers),* };
1084            Ok(::std::rc::Rc::new(plugin) as ::std::rc::Rc<dyn ::std::any::Any>)
1085        }
1086    };
1087    let lifecycle_name = format_ident!("__LensoLifecycle{name}");
1088    let descriptor_macro = format_ident!("__lenso_plugin_descriptor_{}", snake(&name.to_string()));
1089    let requirement_macros = ports
1090        .iter()
1091        .map(|(_, client, cardinality)| requirement_macro(client, *cardinality))
1092        .collect::<syn::Result<Vec<_>>>()?;
1093    let dependency_requirement_macros = construction_fields
1094        .iter()
1095        .filter_map(|field| match &field.kind {
1096            ConstructionFieldKind::Dependency {
1097                id,
1098                client,
1099                cardinality,
1100            } => Some(named_requirement_macro(client, *cardinality, id)),
1101            ConstructionFieldKind::Config
1102            | ConstructionFieldKind::Private
1103            | ConstructionFieldKind::Legacy => None,
1104        })
1105        .collect::<syn::Result<Vec<_>>>()?;
1106    let connect_ports = ports.iter().map(|(field, _, _)| {
1107        quote! { self.plugin.#field.connect(context.dependencies())?; }
1108    });
1109    let connect_tasks = task_connectors(&tasks);
1110    let requirement_parts = intersperse_commas(
1111        requirement_macros
1112            .into_iter()
1113            .chain(dependency_requirement_macros)
1114            .collect(),
1115    );
1116    let (prefix, after_schema, suffix, defaults) = descriptor_affixes(
1117        &plugin_id,
1118        &package_version,
1119        &root_slot,
1120        authoring_version,
1121        runtime_profile,
1122    );
1123    let construct_configuration = if let Some(config_type) = &config_type {
1124        let validate = attributes
1125            .validate
1126            .as_ref()
1127            .map(|path| quote!(#path(&configuration)?;));
1128        quote! {
1129            let configuration = #sdk::__private::serde_json::from_str::<#config_type>(context.configuration())
1130                .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1131                    detail: format!("invalid {} configuration: {error}", #plugin_id),
1132                })?;
1133            #validate
1134        }
1135    } else {
1136        if attributes.configuration_schema.is_some() {
1137            return Err(syn::Error::new_spanned(
1138                &plugin.ident,
1139                "`configuration_schema` requires a `#[config]` field",
1140            ));
1141        }
1142        if attributes.validate.is_some() {
1143            return Err(syn::Error::new_spanned(
1144                &plugin.ident,
1145                "`validate` requires a `#[config]` field",
1146            ));
1147        }
1148        if attributes.configuration_defaults.is_some() {
1149            return Err(syn::Error::new_spanned(
1150                &plugin.ident,
1151                "`configuration_defaults` requires a `#[config]` field",
1152            ));
1153        }
1154        quote! {
1155            let configuration = #sdk::__private::serde_json::from_str::<#sdk::__private::serde_json::Value>(context.configuration())
1156                .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1157                    detail: format!("invalid {} configuration: {error}", #plugin_id),
1158                })?;
1159            if !configuration.as_object().is_some_and(|object| object.is_empty()) {
1160                return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1161                    detail: format!("{} does not accept configuration", #plugin_id),
1162                });
1163            }
1164        }
1165    };
1166    if attributes.lifecycle
1167        && (attributes.prepare.is_some()
1168            || attributes.activate.is_some()
1169            || attributes.deactivate.is_some())
1170    {
1171        return Err(syn::Error::new_spanned(
1172            &plugin.ident,
1173            "`lifecycle` replaces the `prepare`, `activate`, and `deactivate` function attributes",
1174        ));
1175    }
1176    let prepare = if attributes.lifecycle {
1177        quote! {
1178            let plugin = self.plugin.clone();
1179            Box::pin(async move { #sdk::Lifecycle::prepare(plugin.as_ref(), context).await })
1180        }
1181    } else {
1182        hook(attributes.prepare.as_ref(), &sdk)
1183    };
1184    let activate_hook = if attributes.lifecycle {
1185        quote! {
1186            let plugin = self.plugin.clone();
1187            Box::pin(async move { #sdk::Lifecycle::activate(plugin.as_ref(), context).await })
1188        }
1189    } else {
1190        hook(attributes.activate.as_ref(), &sdk)
1191    };
1192    let deactivate_hook = if attributes.lifecycle {
1193        quote! {
1194            let plugin = self.plugin.clone();
1195            Box::pin(async move { #sdk::Lifecycle::deactivate(plugin.as_ref(), context).await })
1196        }
1197    } else {
1198        hook(attributes.deactivate.as_ref(), &sdk)
1199    };
1200    let disconnect_tasks = task_disconnectors(&tasks);
1201    let activate = if tasks.is_empty() {
1202        activate_hook
1203    } else {
1204        quote! {
1205            let plugin = self.plugin.clone();
1206            let activation = { #activate_hook };
1207            Box::pin(async move {
1208                let result = activation.await;
1209                if result.is_err() {
1210                    #(#disconnect_tasks)*
1211                }
1212                result
1213            })
1214        }
1215    };
1216    let disconnect_tasks = task_disconnectors(&tasks);
1217    let deactivate = if tasks.is_empty() {
1218        deactivate_hook
1219    } else {
1220        quote! {
1221            let plugin = self.plugin.clone();
1222            #(#disconnect_tasks)*
1223            let deactivation = { #deactivate_hook };
1224            deactivation
1225        }
1226    };
1227    let package_file_tracking = package_file_tracking([
1228        attributes.configuration_schema.as_ref(),
1229        attributes.configuration_defaults.as_ref(),
1230    ]);
1231    let consumer_finalizer = if attributes.consumer {
1232        let generated_plugin = format_ident!("__lenso_consumer_{}", snake(&name.to_string()));
1233        let artifact = format_ident!("__LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_{name}");
1234        Some(quote! {
1235            /// Generated package-owned Plugin Descriptor bytes.
1236            pub const PLUGIN_DESCRIPTOR_JSON: &str = #descriptor_macro!();
1237            #[doc(hidden)]
1238            const __LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_TEXT: &str = concat!(
1239                "LENSO_PLUGIN_DESCRIPTOR_V1\0",
1240                #descriptor_macro!(),
1241                "\0END_LENSO_PLUGIN_DESCRIPTOR_V1",
1242            );
1243            /// Linker-retained descriptor artifact consumed without executing package code.
1244            #[doc(hidden)]
1245            #[used]
1246            pub static #artifact: &[u8] = __LENSO_PLUGIN_DESCRIPTOR_ARTIFACT_TEXT.as_bytes();
1247
1248            #[doc(hidden)]
1249            mod #generated_plugin {
1250                #[derive(Clone, Copy, Debug, Default)]
1251                struct Factory;
1252
1253                impl #sdk::__private::NativePluginFactory for Factory {
1254                    fn package_id(&self) -> &'static str { super::PACKAGE_ID }
1255                    fn package_version(&self) -> &'static str { super::PACKAGE_VERSION }
1256                    fn runtime_profile(&self) -> &'static str {
1257                        super::#name::__LENSO_RUNTIME_PROFILE
1258                    }
1259
1260                    fn instantiate(
1261                        &self,
1262                        context: #sdk::__private::NativePluginFactoryContext<'_>,
1263                    ) -> Result<
1264                        #sdk::__private::NativePluginInstance,
1265                        #sdk::__private::RuntimeFailure,
1266                    > {
1267                        if super::#name::__LENSO_AUTHORING_VERSION == 2 {
1268                            let object = #sdk::__private::PluginObject::<super::#name>::empty();
1269                            let lifecycle = #sdk::__private::CompleteObjectLifecycle::linked(
1270                                object,
1271                                context.configuration(),
1272                            )?;
1273                            return Ok(#sdk::__private::NativePluginInstance::with_lifecycle(
1274                                Vec::new(),
1275                                lifecycle,
1276                            ));
1277                        }
1278                        let plugin = ::std::rc::Rc::new(super::#name::__lenso_construct(context)?);
1279                        let lifecycle = super::#lifecycle_name { plugin };
1280                        Ok(#sdk::__private::NativePluginInstance::with_lifecycle(
1281                            Vec::new(),
1282                            lifecycle,
1283                        ))
1284                    }
1285                }
1286
1287                fn factory() -> ::std::rc::Rc<dyn #sdk::__private::NativePluginFactory> {
1288                    ::std::rc::Rc::new(Factory)
1289                }
1290
1291                #sdk::__private::__inventory::submit! {
1292                    #sdk::__private::LinkedNativePluginFactory::new(
1293                        factory,
1294                        super::PLUGIN_DESCRIPTOR_JSON,
1295                    )
1296                }
1297            }
1298        })
1299    } else {
1300        None
1301    };
1302
1303    Ok(quote! {
1304        /// Runtime package identity derived from Cargo package metadata.
1305        pub const PACKAGE_ID: &str = #plugin_id;
1306        /// Exact linked Cargo package version.
1307        pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
1308        /// Exact Host Build identity for this linked package.
1309        pub const FACTORY_IDENTITY: &str = concat!(#plugin_id, "@", env!("CARGO_PKG_VERSION"));
1310
1311        #plugin
1312
1313        #[doc(hidden)]
1314        struct #inputs_name {
1315            #(#input_fields),*
1316        }
1317
1318        #[doc(hidden)]
1319        macro_rules! #descriptor_macro {
1320            () => {
1321                concat!(#prefix, #schema, ",\"configuration_defaults\":", #configuration_defaults, #after_schema, #suffix #(, #requirement_parts)*, #defaults)
1322            };
1323            ($first:expr $(, $rest:expr)*) => {
1324                concat!(#prefix, #schema, ",\"configuration_defaults\":", #configuration_defaults, #after_schema, $first $(, ",", $rest)*, #suffix #(, #requirement_parts)*, #defaults)
1325            };
1326        }
1327
1328        impl #name {
1329            #[doc(hidden)]
1330            const __LENSO_AUTHORING_VERSION: u32 = #authoring_version;
1331            #[doc(hidden)]
1332            const __LENSO_RUNTIME_PROFILE: &'static str = #runtime_profile;
1333
1334            #[doc(hidden)]
1335            fn __lenso_inputs(
1336                context: &#sdk::__private::ConstructionContext,
1337            ) -> Result<#inputs_name, #sdk::__private::RuntimeFailure> {
1338                #v2_configuration
1339                Ok(#inputs_name { #(#input_initializers),* })
1340            }
1341
1342            #[doc(hidden)]
1343            #[allow(unreachable_code)]
1344            fn __lenso_construct(
1345                context: #sdk::__private::NativePluginFactoryContext<'_>,
1346            ) -> Result<Self, #sdk::__private::RuntimeFailure> {
1347                if context.entrypoint() != "default" {
1348                    return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1349                        detail: format!("unsupported {} entrypoint {}", #plugin_id, context.entrypoint()),
1350                    });
1351                }
1352                #construct_configuration
1353                Ok(Self { #(#initializers),* })
1354            }
1355
1356            #[doc(hidden)]
1357            fn __lenso_auto_construct(
1358                context: #sdk::__private::ConstructionContext,
1359            ) -> #sdk::__private::ErasedConstructionFuture {
1360                Box::pin(async move {
1361                    let _ = context;
1362                    #v2_construct
1363                })
1364            }
1365        }
1366
1367        #[doc(hidden)]
1368        mod #construction_module {
1369            fn plugin_type() -> ::std::any::TypeId {
1370                ::std::any::TypeId::of::<super::#name>()
1371            }
1372
1373            #sdk::__private::__inventory::submit! {
1374                #sdk::__private::LinkedPluginConstruction::new(
1375                    plugin_type,
1376                    false,
1377                    super::#name::__lenso_auto_construct,
1378                    None,
1379                )
1380            }
1381        }
1382
1383        #[doc(hidden)]
1384        #[derive(Clone, Debug)]
1385        struct #lifecycle_name {
1386            plugin: ::std::rc::Rc<#name>,
1387        }
1388
1389        impl #sdk::__private::PluginLifecycle for #lifecycle_name {
1390            fn prepare(&self, context: #sdk::__private::PrepareContext) -> #sdk::__private::PluginFuture {
1391                #prepare
1392            }
1393
1394            fn activate(&self, context: #sdk::__private::ActivateContext) -> #sdk::__private::PluginFuture {
1395                let connected = (|| -> Result<(), #sdk::__private::RuntimeFailure> {
1396                    #(#connect_ports)*
1397                    #(#connect_tasks)*
1398                    Ok(())
1399                })();
1400                if let Err(error) = connected {
1401                    return Box::pin(#sdk::__private::futures::future::ready(Err(error)));
1402                }
1403                #activate
1404            }
1405
1406            fn deactivate(&self, context: #sdk::__private::DeactivateContext) -> #sdk::__private::PluginFuture {
1407                #deactivate
1408            }
1409        }
1410
1411        const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
1412        #(#package_file_tracking)*
1413
1414        #consumer_finalizer
1415    })
1416}
1417
1418fn descriptor_affixes(
1419    plugin_id: &str,
1420    package_version: &str,
1421    root_slot: &str,
1422    authoring_version: u32,
1423    runtime_profile: &str,
1424) -> (String, &'static str, &'static str, &'static str) {
1425    let prefix = format!(
1426        "{{\"authoring_version\":{authoring_version},\"runtime_profile\":{},\"plugin_id\":{},\"release_version\":{},\"root_slot\":{},\"runtime_package_id\":{},\"runtime_package_revision\":{},\"entrypoint\":\"default\",\"configuration_schema\":",
1427        serde_json::to_string(runtime_profile).expect("runtime profile serializes"),
1428        serde_json::to_string(plugin_id).expect("Plugin ID serializes"),
1429        serde_json::to_string(package_version).expect("package version serializes"),
1430        serde_json::to_string(root_slot).expect("root Slot serializes"),
1431        serde_json::to_string(plugin_id).expect("runtime package ID serializes"),
1432        serde_json::to_string(package_version).expect("package version serializes"),
1433    );
1434    let after_schema = ",\"provided_capabilities\":[";
1435    let suffix = "],\"required_capabilities\":[";
1436    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\"}";
1437    (prefix, after_schema, suffix, defaults)
1438}
1439
1440fn package_file_tracking<'a>(
1441    paths: impl IntoIterator<Item = Option<&'a LitStr>>,
1442) -> Vec<proc_macro2::TokenStream> {
1443    paths
1444        .into_iter()
1445        .flatten()
1446        .map(|path| {
1447            quote!(
1448                const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #path));
1449            )
1450        })
1451        .collect()
1452}
1453
1454fn configuration_schema_tokens(
1455    schema_path: Option<&LitStr>,
1456    config_type: Option<&Type>,
1457) -> syn::Result<proc_macro2::TokenStream> {
1458    if let Some(path) = schema_path {
1459        let schema = canonical_json(&read_configuration_schema(path)?);
1460        return Ok(quote!(#schema));
1461    }
1462    let Some(config_type) = config_type else {
1463        let schema = canonical_json(&json!({
1464            "$schema": "https://json-schema.org/draft/2020-12/schema",
1465            "type": "object",
1466            "additionalProperties": false,
1467            "required": [],
1468            "properties": {},
1469        }));
1470        return Ok(quote!(#schema));
1471    };
1472    let Type::Path(config) = config_type else {
1473        return Err(syn::Error::new_spanned(
1474            config_type,
1475            "the `#[config]` field type must be a path",
1476        ));
1477    };
1478    let mut namespace = config.path.clone();
1479    let config_name = namespace
1480        .segments
1481        .pop()
1482        .expect("type paths are non-empty")
1483        .into_value()
1484        .ident;
1485    namespace.segments.pop_punct();
1486    let macro_name = format_ident!("__lenso_config_schema_{}", snake(&config_name.to_string()));
1487    if namespace.segments.is_empty() {
1488        Ok(quote!(#macro_name!()))
1489    } else {
1490        Ok(quote!(#namespace::#macro_name!()))
1491    }
1492}
1493
1494fn configuration_defaults_tokens(
1495    schema_path: Option<&LitStr>,
1496    defaults_path: Option<&LitStr>,
1497    config_type: Option<&Type>,
1498) -> syn::Result<proc_macro2::TokenStream> {
1499    if let Some(path) = defaults_path {
1500        if schema_path.is_none() {
1501            return Err(syn::Error::new(
1502                path.span(),
1503                "`configuration_defaults` requires an explicit `configuration_schema`",
1504            ));
1505        }
1506        let defaults = read_configuration_defaults(path)?;
1507        let schema = read_configuration_schema(schema_path.expect("checked above"))?;
1508        validate_configuration_defaults(&defaults, &schema).map_err(|detail| {
1509            syn::Error::new(
1510                path.span(),
1511                format!("invalid package configuration defaults: {detail}"),
1512            )
1513        })?;
1514        let defaults = canonical_json(&defaults);
1515        return Ok(quote!(#defaults));
1516    }
1517    if schema_path.is_some() || config_type.is_none() {
1518        let defaults = canonical_json(&json!({}));
1519        return Ok(quote!(#defaults));
1520    }
1521    let config_type = config_type.expect("checked above");
1522    let Type::Path(config) = config_type else {
1523        return Err(syn::Error::new_spanned(
1524            config_type,
1525            "the `#[config]` field type must be a path",
1526        ));
1527    };
1528    let mut namespace = config.path.clone();
1529    let config_name = namespace
1530        .segments
1531        .pop()
1532        .expect("type paths are non-empty")
1533        .into_value()
1534        .ident;
1535    namespace.segments.pop_punct();
1536    let macro_name = format_ident!(
1537        "__lenso_config_defaults_{}",
1538        snake(&config_name.to_string())
1539    );
1540    if namespace.segments.is_empty() {
1541        Ok(quote!(#macro_name!()))
1542    } else {
1543        Ok(quote!(#namespace::#macro_name!()))
1544    }
1545}
1546
1547struct StructFields {
1548    config_type: Option<Type>,
1549    ports: Vec<(syn::Ident, Path, PortCardinality)>,
1550    tasks: Vec<syn::Ident>,
1551    initializers: Vec<proc_macro2::TokenStream>,
1552    construction_fields: Vec<ConstructionField>,
1553}
1554
1555struct ConstructionField {
1556    name: syn::Ident,
1557    ty: Type,
1558    kind: ConstructionFieldKind,
1559}
1560
1561enum ConstructionFieldKind {
1562    Config,
1563    Dependency {
1564        id: LitStr,
1565        client: Box<Type>,
1566        cardinality: DependencyCardinality,
1567    },
1568    Private,
1569    Legacy,
1570}
1571
1572#[derive(Clone, Copy)]
1573enum DependencyCardinality {
1574    One,
1575    Optional,
1576    Many,
1577}
1578
1579#[derive(Clone, Copy)]
1580enum PortCardinality {
1581    One,
1582    Many,
1583}
1584
1585#[allow(clippy::too_many_lines)]
1586fn analyze_struct_fields(
1587    plugin: &mut ItemStruct,
1588    sdk: &proc_macro2::TokenStream,
1589) -> syn::Result<StructFields> {
1590    let Fields::Named(fields) = &mut plugin.fields else {
1591        return Err(syn::Error::new_spanned(
1592            &plugin.fields,
1593            "a struct-level Plugin requires named fields",
1594        ));
1595    };
1596    let mut config = None;
1597    let mut ports = Vec::new();
1598    let mut tasks = Vec::new();
1599    let mut resources = None;
1600    let mut initializers = Vec::new();
1601    let mut construction_fields = Vec::new();
1602    for field in &mut fields.named {
1603        let name = field.ident.as_ref().expect("named fields have identifiers");
1604        let is_config = take_marker(&mut field.attrs, "config");
1605        let is_tasks = take_marker(&mut field.attrs, "tasks");
1606        let is_resources = take_marker(&mut field.attrs, "resources");
1607        let dependency = take_dependency(&mut field.attrs)?;
1608        if usize::from(is_config)
1609            + usize::from(is_tasks)
1610            + usize::from(is_resources)
1611            + usize::from(dependency.is_some())
1612            > 1
1613        {
1614            return Err(syn::Error::new_spanned(
1615                field,
1616                "a Plugin field can have only one construction marker",
1617            ));
1618        }
1619        if is_config {
1620            if config.replace(field.ty.clone()).is_some() {
1621                return Err(syn::Error::new_spanned(
1622                    field,
1623                    "a Plugin has exactly one `#[config]` field",
1624                ));
1625            }
1626            initializers.push(quote!(#name: configuration));
1627            construction_fields.push(ConstructionField {
1628                name: name.clone(),
1629                ty: field.ty.clone(),
1630                kind: ConstructionFieldKind::Config,
1631            });
1632        } else if let Some(id) = dependency {
1633            let (client, cardinality) = dependency_client(&field.ty)?;
1634            initializers.push(quote! {
1635                #name: return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1636                    detail: concat!("dependency field `", stringify!(#name), "` requires authoring version 2").to_owned(),
1637                })
1638            });
1639            construction_fields.push(ConstructionField {
1640                name: name.clone(),
1641                ty: field.ty.clone(),
1642                kind: ConstructionFieldKind::Dependency {
1643                    id,
1644                    client: Box::new(client),
1645                    cardinality,
1646                },
1647            });
1648        } else if is_tasks {
1649            if !is_named_type(&field.ty, "ManagedTasks") {
1650                return Err(syn::Error::new_spanned(
1651                    &field.ty,
1652                    "a `#[tasks]` field must have type `ManagedTasks`",
1653                ));
1654            }
1655            if !tasks.is_empty() {
1656                return Err(syn::Error::new_spanned(
1657                    field,
1658                    "a Plugin has at most one `#[tasks]` field",
1659                ));
1660            }
1661            tasks.push(name.clone());
1662            initializers.push(quote!(#name: ::core::default::Default::default()));
1663            construction_fields.push(ConstructionField {
1664                name: name.clone(),
1665                ty: field.ty.clone(),
1666                kind: ConstructionFieldKind::Legacy,
1667            });
1668        } else if is_resources {
1669            if !is_named_type(&field.ty, "InstanceResources") {
1670                return Err(syn::Error::new_spanned(
1671                    &field.ty,
1672                    "a `#[resources]` field must have type `InstanceResources`",
1673                ));
1674            }
1675            if resources.replace(name.clone()).is_some() {
1676                return Err(syn::Error::new_spanned(
1677                    field,
1678                    "a Plugin has at most one `#[resources]` field",
1679                ));
1680            }
1681            initializers.push(quote!(#name: context.resources().clone()));
1682            construction_fields.push(ConstructionField {
1683                name: name.clone(),
1684                ty: field.ty.clone(),
1685                kind: ConstructionFieldKind::Legacy,
1686            });
1687        } else if let Some((client, cardinality)) = port_client(&field.ty)? {
1688            ports.push((name.clone(), client, cardinality));
1689            initializers.push(quote!(#name: ::core::default::Default::default()));
1690            construction_fields.push(ConstructionField {
1691                name: name.clone(),
1692                ty: field.ty.clone(),
1693                kind: ConstructionFieldKind::Legacy,
1694            });
1695        } else {
1696            initializers.push(legacy_default_initializer(name, &field.ty, sdk));
1697            construction_fields.push(ConstructionField {
1698                name: name.clone(),
1699                ty: field.ty.clone(),
1700                kind: ConstructionFieldKind::Private,
1701            });
1702        }
1703    }
1704    Ok(StructFields {
1705        config_type: config,
1706        ports,
1707        tasks,
1708        initializers,
1709        construction_fields,
1710    })
1711}
1712
1713fn take_dependency(attributes: &mut Vec<Attribute>) -> syn::Result<Option<LitStr>> {
1714    let mut id = None;
1715    let mut seen = false;
1716    let mut retained = Vec::with_capacity(attributes.len());
1717    for attribute in attributes.drain(..) {
1718        if !attribute.path().is_ident("dependency") {
1719            retained.push(attribute);
1720            continue;
1721        }
1722        if seen {
1723            return Err(syn::Error::new_spanned(
1724                attribute,
1725                "duplicate `dependency` marker",
1726            ));
1727        }
1728        seen = true;
1729        attribute.parse_nested_meta(|meta| {
1730            if !meta.path.is_ident("id") {
1731                return Err(meta.error("expected `id = \"public_requirement_id\"`"));
1732            }
1733            id = Some(meta.value()?.parse()?);
1734            Ok(())
1735        })?;
1736    }
1737    *attributes = retained;
1738    if seen {
1739        id.map(Some).ok_or_else(|| {
1740            syn::Error::new(proc_macro2::Span::call_site(), "dependency id is required")
1741        })
1742    } else {
1743        Ok(None)
1744    }
1745}
1746
1747fn dependency_client(ty: &Type) -> syn::Result<(Type, DependencyCardinality)> {
1748    let Type::Path(path) = ty else {
1749        return Err(syn::Error::new_spanned(
1750            ty,
1751            "dependency type must be a generated client",
1752        ));
1753    };
1754    let segment = path.path.segments.last().expect("type paths are non-empty");
1755    if segment.ident == "Option" {
1756        return Ok((
1757            single_type_argument(segment, ty)?.clone(),
1758            DependencyCardinality::Optional,
1759        ));
1760    }
1761    if segment.ident == "Vec" {
1762        let bound = single_type_argument(segment, ty)?;
1763        let Type::Path(bound_path) = bound else {
1764            return Err(syn::Error::new_spanned(
1765                bound,
1766                "many dependency must contain a generated client",
1767            ));
1768        };
1769        let bound_segment = bound_path
1770            .path
1771            .segments
1772            .last()
1773            .expect("type paths are non-empty");
1774        if bound_segment.ident != "BoundCapabilityClient" {
1775            return Err(syn::Error::new_spanned(
1776                bound,
1777                "many dependency must be `Vec<BoundCapabilityClient<Client>>`",
1778            ));
1779        }
1780        return Ok((
1781            single_type_argument(bound_segment, bound)?.clone(),
1782            DependencyCardinality::Many,
1783        ));
1784    }
1785    Ok((ty.clone(), DependencyCardinality::One))
1786}
1787
1788fn single_type_argument<'a>(segment: &'a syn::PathSegment, ty: &Type) -> syn::Result<&'a Type> {
1789    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
1790        return Err(syn::Error::new_spanned(
1791            ty,
1792            "dependency wrapper requires one type",
1793        ));
1794    };
1795    let [GenericArgument::Type(inner)] = arguments.args.iter().collect::<Vec<_>>().as_slice()
1796    else {
1797        return Err(syn::Error::new_spanned(
1798            ty,
1799            "dependency wrapper requires one type",
1800        ));
1801    };
1802    Ok(inner)
1803}
1804
1805fn legacy_default_initializer(
1806    name: &syn::Ident,
1807    ty: &Type,
1808    sdk: &proc_macro2::TokenStream,
1809) -> proc_macro2::TokenStream {
1810    quote! {
1811        #name: {
1812            trait __LensoMaybeDefault<T> {
1813                fn __lenso_default(self) -> Option<T>;
1814            }
1815            impl<T: Default> __LensoMaybeDefault<T> for &&::std::marker::PhantomData<T> {
1816                fn __lenso_default(self) -> Option<T> {
1817                    Some(T::default())
1818                }
1819            }
1820            impl<T> __LensoMaybeDefault<T> for &::std::marker::PhantomData<T> {
1821                fn __lenso_default(self) -> Option<T> {
1822                    None
1823                }
1824            }
1825            let marker = ::std::marker::PhantomData::<#ty>;
1826            (&&marker).__lenso_default().ok_or_else(|| {
1827                #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1828                    detail: concat!(
1829                        "Plugin field `",
1830                        stringify!(#name),
1831                        "` has no default; use authoring version 2 with #[create]",
1832                    )
1833                    .to_owned(),
1834                }
1835            })?
1836        }
1837    }
1838}
1839
1840fn construct_v2_configuration(
1841    plugin_id: &str,
1842    config_type: Option<&Type>,
1843    validate: Option<&Path>,
1844    sdk: &proc_macro2::TokenStream,
1845) -> proc_macro2::TokenStream {
1846    if let Some(config_type) = config_type {
1847        let validate = validate.map(|path| quote!(#path(&configuration)?;));
1848        quote! {
1849            let configuration = #sdk::__private::serde_json::from_str::<#config_type>(
1850                context.configuration(),
1851            )
1852            .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1853                detail: format!("invalid {} configuration: {error}", #plugin_id),
1854            })?;
1855            #validate
1856        }
1857    } else {
1858        quote! {
1859            let configuration = #sdk::__private::serde_json::from_str::<
1860                #sdk::__private::serde_json::Value,
1861            >(context.configuration())
1862            .map_err(|error| #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1863                detail: format!("invalid {} configuration: {error}", #plugin_id),
1864            })?;
1865            if !configuration.as_object().is_some_and(|object| object.is_empty()) {
1866                return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1867                    detail: format!("{} does not accept configuration", #plugin_id),
1868                });
1869            }
1870        }
1871    }
1872}
1873
1874fn v2_field_initializer(
1875    field: &ConstructionField,
1876    sdk: &proc_macro2::TokenStream,
1877) -> proc_macro2::TokenStream {
1878    let name = &field.name;
1879    let ty = &field.ty;
1880    match &field.kind {
1881        ConstructionFieldKind::Config => quote!(#name: configuration),
1882        ConstructionFieldKind::Dependency {
1883            id,
1884            client,
1885            cardinality: DependencyCardinality::One,
1886        } => quote! {
1887            #name: {
1888                let dependency = context.dependencies().requirement(#id)?;
1889                <#client as #sdk::__private::CapabilityClient>::from_dependencies(&dependency)?
1890            }
1891        },
1892        ConstructionFieldKind::Dependency {
1893            id,
1894            client,
1895            cardinality: DependencyCardinality::Optional,
1896        } => quote! {
1897            #name: {
1898                let dependency = context.dependencies().requirement(#id)?;
1899                if dependency.bindings().is_empty() {
1900                    None
1901                } else {
1902                    Some(<#client as #sdk::__private::CapabilityClient>::from_dependencies(
1903                        &dependency,
1904                    )?)
1905                }
1906            }
1907        },
1908        ConstructionFieldKind::Dependency {
1909            id,
1910            client,
1911            cardinality: DependencyCardinality::Many,
1912        } => quote! {
1913            #name: {
1914                let dependency = context.dependencies().requirement(#id)?;
1915                <#client as #sdk::__private::CapabilityClientMany>::many_from_dependencies(
1916                    &dependency,
1917                )?
1918            }
1919        },
1920        ConstructionFieldKind::Private => quote! {
1921            #name: {
1922                trait __LensoMaybeDefault<T> {
1923                    fn __lenso_default(self) -> Option<T>;
1924                }
1925                impl<T: Default> __LensoMaybeDefault<T> for &&::std::marker::PhantomData<T> {
1926                    fn __lenso_default(self) -> Option<T> {
1927                        Some(T::default())
1928                    }
1929                }
1930                impl<T> __LensoMaybeDefault<T> for &::std::marker::PhantomData<T> {
1931                    fn __lenso_default(self) -> Option<T> {
1932                        None
1933                    }
1934                }
1935                let marker = ::std::marker::PhantomData::<#ty>;
1936                (&&marker).__lenso_default().ok_or_else(|| {
1937                    #sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1938                        detail: concat!(
1939                            "Plugin field `",
1940                            stringify!(#name),
1941                            "` has no default; add a #[create] constructor",
1942                        )
1943                        .to_owned(),
1944                    }
1945                })?
1946            }
1947        },
1948        ConstructionFieldKind::Legacy => quote! {
1949            #name: return Err(#sdk::__private::RuntimeFailure::InvalidResolvedPlan {
1950                detail: concat!(
1951                    "legacy Plugin field `",
1952                    stringify!(#name),
1953                    "` cannot use authoring version 2",
1954                )
1955                .to_owned(),
1956            })
1957        },
1958    }
1959}
1960
1961fn v2_input_initializer(
1962    field: &ConstructionField,
1963    sdk: &proc_macro2::TokenStream,
1964) -> Option<proc_macro2::TokenStream> {
1965    let name = &field.name;
1966    match &field.kind {
1967        ConstructionFieldKind::Config => Some(quote!(#name: configuration)),
1968        ConstructionFieldKind::Dependency {
1969            id,
1970            client,
1971            cardinality: DependencyCardinality::One,
1972        } => Some(quote! {
1973            #name: {
1974                let dependency = context.dependencies().requirement(#id)?;
1975                <#client as #sdk::__private::CapabilityClient>::from_dependencies(&dependency)?
1976            }
1977        }),
1978        ConstructionFieldKind::Dependency {
1979            id,
1980            client,
1981            cardinality: DependencyCardinality::Optional,
1982        } => Some(quote! {
1983            #name: {
1984                let dependency = context.dependencies().requirement(#id)?;
1985                if dependency.bindings().is_empty() {
1986                    None
1987                } else {
1988                    Some(<#client as #sdk::__private::CapabilityClient>::from_dependencies(
1989                        &dependency,
1990                    )?)
1991                }
1992            }
1993        }),
1994        ConstructionFieldKind::Dependency {
1995            id,
1996            client,
1997            cardinality: DependencyCardinality::Many,
1998        } => Some(quote! {
1999            #name: {
2000                let dependency = context.dependencies().requirement(#id)?;
2001                <#client as #sdk::__private::CapabilityClientMany>::many_from_dependencies(
2002                    &dependency,
2003                )?
2004            }
2005        }),
2006        ConstructionFieldKind::Private | ConstructionFieldKind::Legacy => None,
2007    }
2008}
2009
2010fn is_named_type(ty: &Type, expected: &str) -> bool {
2011    let Type::Path(path) = ty else {
2012        return false;
2013    };
2014    path.path
2015        .segments
2016        .last()
2017        .is_some_and(|segment| segment.ident == expected && segment.arguments.is_empty())
2018}
2019
2020fn take_marker(attributes: &mut Vec<Attribute>, name: &str) -> bool {
2021    let present = attributes
2022        .iter()
2023        .any(|attribute| attribute.path().is_ident(name));
2024    attributes.retain(|attribute| !attribute.path().is_ident(name));
2025    present
2026}
2027
2028fn task_connectors(tasks: &[syn::Ident]) -> Vec<proc_macro2::TokenStream> {
2029    tasks
2030        .iter()
2031        .map(|field| {
2032            quote! { self.plugin.#field.__lenso_connect(context.tasks().clone())?; }
2033        })
2034        .collect()
2035}
2036
2037fn task_disconnectors(tasks: &[syn::Ident]) -> Vec<proc_macro2::TokenStream> {
2038    tasks
2039        .iter()
2040        .map(|field| quote! { plugin.#field.__lenso_disconnect(); })
2041        .collect()
2042}
2043
2044fn port_client(ty: &Type) -> syn::Result<Option<(Path, PortCardinality)>> {
2045    let Type::Path(path) = ty else {
2046        return Ok(None);
2047    };
2048    let Some(segment) = path.path.segments.last() else {
2049        return Ok(None);
2050    };
2051    let cardinality = if segment.ident == "Port" {
2052        PortCardinality::One
2053    } else if segment.ident == "ManyPort" {
2054        PortCardinality::Many
2055    } else {
2056        return Ok(None);
2057    };
2058    let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
2059        return Err(syn::Error::new_spanned(
2060            ty,
2061            "Port or ManyPort requires one Capability client type",
2062        ));
2063    };
2064    let Some(syn::GenericArgument::Type(Type::Path(client))) = arguments.args.first() else {
2065        return Err(syn::Error::new_spanned(
2066            ty,
2067            "Port or ManyPort requires one Capability client type",
2068        ));
2069    };
2070    if arguments.args.len() != 1 {
2071        return Err(syn::Error::new_spanned(
2072            ty,
2073            "Port or ManyPort requires one Capability client type",
2074        ));
2075    }
2076    Ok(Some((client.path.clone(), cardinality)))
2077}
2078
2079fn requirement_macro(
2080    client: &Path,
2081    cardinality: PortCardinality,
2082) -> syn::Result<proc_macro2::TokenStream> {
2083    let prefix = match cardinality {
2084        PortCardinality::One => "__lenso_required_",
2085        PortCardinality::Many => "__lenso_required_many_",
2086    };
2087    requirement_macro_path(client, prefix, None)
2088}
2089
2090fn named_requirement_macro(
2091    client: &Type,
2092    cardinality: DependencyCardinality,
2093    requirement_id: &LitStr,
2094) -> syn::Result<proc_macro2::TokenStream> {
2095    let Type::Path(client) = client else {
2096        return Err(syn::Error::new_spanned(
2097            client,
2098            "dependency client must be a namespace-qualified generated client",
2099        ));
2100    };
2101    let prefix = match cardinality {
2102        DependencyCardinality::One => "__lenso_required_",
2103        DependencyCardinality::Optional => "__lenso_required_optional_",
2104        DependencyCardinality::Many => "__lenso_required_many_",
2105    };
2106    requirement_macro_path(&client.path, prefix, Some(requirement_id))
2107}
2108
2109fn requirement_macro_path(
2110    client: &Path,
2111    prefix: &str,
2112    requirement_id: Option<&LitStr>,
2113) -> syn::Result<proc_macro2::TokenStream> {
2114    if client.segments.len() < 2 {
2115        return Err(syn::Error::new_spanned(
2116            client,
2117            "a Capability client must be namespace-qualified, for example `model::ModelClient`",
2118        ));
2119    }
2120    let mut namespace = client.clone();
2121    let client_name = namespace
2122        .segments
2123        .pop()
2124        .expect("checked length")
2125        .into_value()
2126        .ident;
2127    namespace.segments.pop_punct();
2128    let macro_name = format_ident!("{}{}", prefix, snake(&client_name.to_string()));
2129    Ok(requirement_id.map_or_else(
2130        || quote!(#namespace::#macro_name!()),
2131        |requirement_id| quote!(#namespace::#macro_name!(#requirement_id)),
2132    ))
2133}
2134
2135fn intersperse_commas(values: Vec<proc_macro2::TokenStream>) -> Vec<proc_macro2::TokenStream> {
2136    values
2137        .into_iter()
2138        .enumerate()
2139        .flat_map(|(index, value)| {
2140            if index == 0 {
2141                vec![value]
2142            } else {
2143                vec![quote!(","), value]
2144            }
2145        })
2146        .collect()
2147}
2148
2149fn hook(path: Option<&Path>, sdk: &proc_macro2::TokenStream) -> proc_macro2::TokenStream {
2150    path.map_or_else(
2151        || quote!(Box::pin(#sdk::__private::futures::future::ready(Ok(())))),
2152        |path| quote!(#path(&self.plugin, &context)),
2153    )
2154}
2155
2156fn canonical_json(value: &Value) -> String {
2157    serde_json::to_string(value).expect("JSON values serialize")
2158}
2159
2160fn authoring_crate() -> proc_macro2::TokenStream {
2161    for package in ["lenso", "lenso-native-adapter"] {
2162        match crate_name(package) {
2163            Ok(FoundCrate::Itself) => {
2164                let ident = format_ident!("{}", package.replace('-', "_"));
2165                return quote!(::#ident);
2166            }
2167            Ok(FoundCrate::Name(name)) => {
2168                let ident = format_ident!("{name}");
2169                return quote!(::#ident);
2170            }
2171            Err(_) => {}
2172        }
2173    }
2174    quote!(::lenso_native_adapter)
2175}
2176
2177fn snake(value: &str) -> String {
2178    let mut output = String::new();
2179    for (index, character) in value.chars().enumerate() {
2180        if character.is_ascii_uppercase() && index > 0 {
2181            output.push('_');
2182        }
2183        output.push(character.to_ascii_lowercase());
2184    }
2185    output
2186}
2187
2188fn plugin_descriptor(
2189    plugin_id: &str,
2190    root_slot: &str,
2191    descriptor: &LitStr,
2192    configuration_schema: Option<&LitStr>,
2193    configuration_defaults: Option<&LitStr>,
2194) -> syn::Result<String> {
2195    let supplied: Value = serde_json::from_str(&descriptor.value()).map_err(|error| {
2196        syn::Error::new(
2197            descriptor.span(),
2198            format!("Plugin Descriptor input is not valid JSON: {error}"),
2199        )
2200    })?;
2201    let mut supplied = supplied.as_object().cloned().ok_or_else(|| {
2202        syn::Error::new(
2203            descriptor.span(),
2204            "Plugin Descriptor input must be an object",
2205        )
2206    })?;
2207    if supplied.contains_key("configuration_schema") {
2208        return Err(syn::Error::new(
2209            descriptor.span(),
2210            "Plugin Descriptor input cannot contain `configuration_schema`; use the package-owned schema path attribute",
2211        ));
2212    }
2213    if supplied.contains_key("configuration_defaults") {
2214        return Err(syn::Error::new(
2215            descriptor.span(),
2216            "Plugin Descriptor input cannot contain `configuration_defaults`; use the package-owned defaults path attribute",
2217        ));
2218    }
2219    if let Some(schema_path) = configuration_schema {
2220        supplied.insert(
2221            "configuration_schema".to_owned(),
2222            read_configuration_schema(schema_path)?,
2223        );
2224    }
2225    if let Some(defaults_path) = configuration_defaults {
2226        if configuration_schema.is_none() {
2227            return Err(syn::Error::new(
2228                defaults_path.span(),
2229                "`configuration_defaults` requires `configuration_schema`",
2230            ));
2231        }
2232        let defaults = read_configuration_defaults(defaults_path)?;
2233        let schema = supplied
2234            .get("configuration_schema")
2235            .expect("explicit configuration Schema was inserted above");
2236        validate_configuration_defaults(&defaults, schema).map_err(|detail| {
2237            syn::Error::new(
2238                defaults_path.span(),
2239                format!("invalid package configuration defaults: {detail}"),
2240            )
2241        })?;
2242        supplied.insert("configuration_defaults".to_owned(), defaults);
2243    }
2244    for owned in [
2245        "plugin_id",
2246        "release_version",
2247        "root_slot",
2248        "runtime_package_id",
2249        "runtime_package_revision",
2250        "entrypoint",
2251        "execution_class",
2252        "restart_policy",
2253        "criticality",
2254    ] {
2255        if supplied.contains_key(owned) {
2256            return Err(syn::Error::new(
2257                descriptor.span(),
2258                format!("Plugin Descriptor input cannot override generated field `{owned}`"),
2259            ));
2260        }
2261    }
2262    let package_version = env::var("CARGO_PKG_VERSION").map_err(|_| {
2263        syn::Error::new(
2264            descriptor.span(),
2265            "CARGO_PKG_VERSION is unavailable while deriving Plugin Descriptor",
2266        )
2267    })?;
2268    Ok(complete_plugin_descriptor(
2269        plugin_id,
2270        &package_version,
2271        root_slot,
2272        supplied,
2273    ))
2274}
2275
2276fn read_configuration_schema(schema_path: &LitStr) -> syn::Result<Value> {
2277    let schema = read_package_json(schema_path, "configuration Schema")?;
2278    if !schema.is_object() {
2279        return Err(syn::Error::new(
2280            schema_path.span(),
2281            "configuration Schema must be a JSON object",
2282        ));
2283    }
2284    Ok(schema)
2285}
2286
2287fn read_configuration_defaults(defaults_path: &LitStr) -> syn::Result<Value> {
2288    let defaults = read_package_json(defaults_path, "configuration defaults")?;
2289    if !defaults.is_object() {
2290        return Err(syn::Error::new(
2291            defaults_path.span(),
2292            "configuration defaults must be a JSON object",
2293        ));
2294    }
2295    Ok(defaults)
2296}
2297
2298fn read_package_json(path: &LitStr, label: &str) -> syn::Result<Value> {
2299    let relative = PathBuf::from(path.value());
2300    if relative.is_absolute()
2301        || relative
2302            .components()
2303            .any(|component| !matches!(component, std::path::Component::Normal(_)))
2304    {
2305        return Err(syn::Error::new(
2306            path.span(),
2307            format!("{label} path must stay inside the Plugin package"),
2308        ));
2309    }
2310    let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
2311        syn::Error::new(
2312            path.span(),
2313            format!("CARGO_MANIFEST_DIR is unavailable while deriving {label}"),
2314        )
2315    })?;
2316    let full_path = PathBuf::from(manifest_dir).join(relative);
2317    let bytes = fs::read(&full_path).map_err(|error| {
2318        syn::Error::new(
2319            path.span(),
2320            format!("failed to read {label} {}: {error}", full_path.display()),
2321        )
2322    })?;
2323    serde_json::from_slice(&bytes).map_err(|error| {
2324        syn::Error::new(
2325            path.span(),
2326            format!("{label} {} is invalid JSON: {error}", full_path.display()),
2327        )
2328    })
2329}
2330
2331fn validate_configuration_defaults(defaults: &Value, schema: &Value) -> Result<(), String> {
2332    if !defaults.is_object() {
2333        return Err("$: defaults must be an object".to_owned());
2334    }
2335    validate_default_value(defaults, schema, "$")
2336}
2337
2338fn validate_default_value(value: &Value, schema: &Value, path: &str) -> Result<(), String> {
2339    let schema = schema
2340        .as_object()
2341        .ok_or_else(|| format!("{path}: configuration Schema must be an object"))?;
2342    if schema
2343        .get("x-lenso-sensitive")
2344        .and_then(Value::as_bool)
2345        .unwrap_or(false)
2346    {
2347        return Err(format!(
2348            "{path}: sensitive configuration cannot have a package default"
2349        ));
2350    }
2351    if let Some(expected) = schema.get("type").and_then(Value::as_str) {
2352        let valid = match expected {
2353            "array" => value.is_array(),
2354            "boolean" => value.is_boolean(),
2355            "integer" => value
2356                .as_number()
2357                .is_some_and(|number| number.is_i64() || number.is_u64()),
2358            "null" => value.is_null(),
2359            "number" => value.is_number(),
2360            "object" => value.is_object(),
2361            "string" => value.is_string(),
2362            _ => false,
2363        };
2364        if !valid {
2365            return Err(format!(
2366                "{path}: default does not match Schema type `{expected}`"
2367            ));
2368        }
2369    }
2370    if let (Some(minimum), Some(number)) = (schema.get("minimum"), value.as_f64()) {
2371        let minimum = minimum
2372            .as_f64()
2373            .ok_or_else(|| format!("{path}: Schema minimum must be a number"))?;
2374        if number < minimum {
2375            return Err(format!(
2376                "{path}: default must be greater than or equal to {minimum}"
2377            ));
2378        }
2379    }
2380    if let Some(expected) = schema.get("const")
2381        && value != expected
2382    {
2383        return Err(format!("{path}: default does not match Schema const"));
2384    }
2385    if let Some(allowed) = schema.get("enum") {
2386        let allowed = allowed
2387            .as_array()
2388            .ok_or_else(|| format!("{path}: Schema enum must be an array"))?;
2389        if !allowed.contains(value) {
2390            return Err(format!("{path}: default is not in Schema enum"));
2391        }
2392    }
2393    validate_default_object(value, schema, path)?;
2394    validate_default_array(value, schema, path)
2395}
2396
2397fn validate_default_object(
2398    value: &Value,
2399    schema: &Map<String, Value>,
2400    path: &str,
2401) -> Result<(), String> {
2402    let Some(object) = value.as_object() else {
2403        return Ok(());
2404    };
2405    let empty = Map::new();
2406    let properties = schema.get("properties").map_or(Ok(&empty), |properties| {
2407        properties
2408            .as_object()
2409            .ok_or_else(|| format!("{path}: Schema properties must be an object"))
2410    })?;
2411    for (name, child) in object {
2412        if let Some(child_schema) = properties.get(name) {
2413            validate_default_value(child, child_schema, &format!("{path}.{name}"))?;
2414            continue;
2415        }
2416        match schema.get("additionalProperties") {
2417            Some(Value::Bool(false)) => {
2418                return Err(format!("{path}.{name}: additional property is not allowed"));
2419            }
2420            Some(Value::Object(additional_schema)) => validate_default_value(
2421                child,
2422                &Value::Object(additional_schema.clone()),
2423                &format!("{path}.{name}"),
2424            )?,
2425            _ => {}
2426        }
2427    }
2428    Ok(())
2429}
2430
2431fn validate_default_array(
2432    value: &Value,
2433    schema: &Map<String, Value>,
2434    path: &str,
2435) -> Result<(), String> {
2436    let (Some(items), Some(item_schema)) = (value.as_array(), schema.get("items")) else {
2437        return Ok(());
2438    };
2439    for (index, item) in items.iter().enumerate() {
2440        validate_default_value(item, item_schema, &format!("{path}[{index}]"))?;
2441    }
2442    Ok(())
2443}
2444
2445fn complete_plugin_descriptor(
2446    plugin_id: &str,
2447    package_version: &str,
2448    root_slot: &str,
2449    mut supplied: Map<String, Value>,
2450) -> String {
2451    let mut generated = Map::new();
2452    generated.insert("plugin_id".to_owned(), json!(plugin_id));
2453    generated.insert("release_version".to_owned(), json!(package_version));
2454    generated.insert("root_slot".to_owned(), json!(root_slot));
2455    generated.insert("runtime_package_id".to_owned(), json!(plugin_id));
2456    generated.insert(
2457        "runtime_package_revision".to_owned(),
2458        json!(package_version),
2459    );
2460    generated.insert("entrypoint".to_owned(), json!("default"));
2461    for (key, value) in std::mem::take(&mut supplied) {
2462        generated.insert(key, value);
2463    }
2464    generated.insert("execution_class".to_owned(), json!("lenso.native-rust@1"));
2465    generated.insert(
2466        "restart_policy".to_owned(),
2467        json!({
2468            "mode": "never",
2469            "max_attempts": 0,
2470            "window": {"secs": 0, "nanos": 0},
2471            "backoff": {"secs": 0, "nanos": 0},
2472            "stability": {"secs": 0, "nanos": 0},
2473            "jitter": {"secs": 0, "nanos": 0}
2474        }),
2475    );
2476    generated.insert("criticality".to_owned(), json!("non_critical"));
2477    serde_json::to_string(&Value::Object(generated))
2478        .expect("generated Plugin Descriptor values must serialize")
2479}
2480
2481fn plugin_metadata() -> syn::Result<(String, String)> {
2482    let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
2483        syn::Error::new(
2484            proc_macro2::Span::call_site(),
2485            "CARGO_MANIFEST_DIR is unavailable",
2486        )
2487    })?;
2488    let manifest_path = PathBuf::from(manifest_dir).join("Cargo.toml");
2489    let manifest = fs::read_to_string(&manifest_path).map_err(|error| {
2490        syn::Error::new(
2491            proc_macro2::Span::call_site(),
2492            format!("failed to read {}: {error}", manifest_path.display()),
2493        )
2494    })?;
2495    let manifest: toml::Value = toml::from_str(&manifest).map_err(|error| {
2496        syn::Error::new(
2497            proc_macro2::Span::call_site(),
2498            format!("failed to parse {}: {error}", manifest_path.display()),
2499        )
2500    })?;
2501    let lenso = manifest
2502        .get("package")
2503        .and_then(|package| package.get("metadata"))
2504        .and_then(|metadata| metadata.get("lenso"))
2505        .and_then(toml::Value::as_table)
2506        .ok_or_else(|| metadata_error("missing `[package.metadata.lenso]` in Cargo.toml"))?;
2507    let plugin_id = lenso
2508        .get("plugin-id")
2509        .and_then(toml::Value::as_str)
2510        .ok_or_else(|| {
2511            metadata_error("missing `plugin-id = \"...\"` in `[package.metadata.lenso]`")
2512        })?;
2513    let root_slot = lenso
2514        .get("root-slot")
2515        .and_then(toml::Value::as_str)
2516        .ok_or_else(|| {
2517            metadata_error("missing `root-slot = \"...\"` in `[package.metadata.lenso]`")
2518        })?;
2519    Ok((plugin_id.to_owned(), root_slot.to_owned()))
2520}
2521
2522fn metadata_error(detail: &str) -> syn::Error {
2523    syn::Error::new(proc_macro2::Span::call_site(), detail)
2524}
2525
2526#[cfg(test)]
2527mod tests {
2528    use super::*;
2529    use syn::parse_quote;
2530
2531    #[test]
2532    fn generated_descriptor_owns_identity_and_execution_defaults() {
2533        let supplied = serde_json::from_value::<Map<String, Value>>(json!({
2534            "provided_capabilities": [],
2535            "required_capabilities": []
2536        }))
2537        .unwrap();
2538        let descriptor = complete_plugin_descriptor("example.tool", "1.2.3", "tools", supplied);
2539        let descriptor: Value = serde_json::from_str(&descriptor).unwrap();
2540
2541        assert_eq!(descriptor["plugin_id"], "example.tool");
2542        assert_eq!(descriptor["release_version"], "1.2.3");
2543        assert_eq!(descriptor["runtime_package_id"], "example.tool");
2544        assert_eq!(descriptor["runtime_package_revision"], "1.2.3");
2545        assert_eq!(descriptor["entrypoint"], "default");
2546        assert_eq!(descriptor["execution_class"], "lenso.native-rust@1");
2547        assert_eq!(descriptor["restart_policy"]["mode"], "never");
2548        assert_eq!(descriptor["criticality"], "non_critical");
2549    }
2550
2551    #[test]
2552    fn package_schema_is_embedded_as_descriptor_data() {
2553        let path = LitStr::new(
2554            "tests/fixtures/config.schema.json",
2555            proc_macro2::Span::call_site(),
2556        );
2557        let schema = read_configuration_schema(&path).unwrap();
2558
2559        assert_eq!(schema["type"], "object");
2560        assert_eq!(schema["required"], json!(["name", "retries"]));
2561    }
2562
2563    #[test]
2564    fn package_defaults_are_embedded_as_descriptor_data() {
2565        let path = LitStr::new(
2566            "tests/fixtures/config.defaults.json",
2567            proc_macro2::Span::call_site(),
2568        );
2569        let defaults = read_configuration_defaults(&path).unwrap();
2570
2571        assert_eq!(defaults, json!({"name": "fixture", "retries": 3}));
2572    }
2573
2574    #[test]
2575    fn factory_function_descriptor_embeds_package_defaults() {
2576        let descriptor = LitStr::new(
2577            r#"{"provided_capabilities":[],"required_capabilities":[]}"#,
2578            proc_macro2::Span::call_site(),
2579        );
2580        let schema = LitStr::new(
2581            "tests/fixtures/config.schema.json",
2582            proc_macro2::Span::call_site(),
2583        );
2584        let defaults = LitStr::new(
2585            "tests/fixtures/config.defaults.json",
2586            proc_macro2::Span::call_site(),
2587        );
2588
2589        let generated = plugin_descriptor(
2590            "example.tool",
2591            "tools",
2592            &descriptor,
2593            Some(&schema),
2594            Some(&defaults),
2595        )
2596        .unwrap();
2597        let generated: Value = serde_json::from_str(&generated).unwrap();
2598        assert_eq!(
2599            generated["configuration_defaults"],
2600            json!({"name": "fixture", "retries": 3})
2601        );
2602    }
2603
2604    #[test]
2605    fn typed_configuration_defaults_must_match_the_field_type() {
2606        let input: DeriveInput = parse_quote! {
2607            struct InvalidConfig {
2608                #[lenso(default = 3)]
2609                name: String,
2610            }
2611        };
2612
2613        let error = expand_plugin_config(&input).unwrap_err();
2614        assert!(error.to_string().contains("does not match the field type"));
2615    }
2616
2617    #[test]
2618    fn package_defaults_fail_closed_against_schema_constraints() {
2619        let schema = json!({
2620            "type": "object",
2621            "properties": {
2622                "retries": {"type": "integer", "minimum": 1},
2623                "token": {"x-lenso-sensitive": true}
2624            },
2625            "additionalProperties": false
2626        });
2627
2628        assert_eq!(
2629            validate_configuration_defaults(&json!({"retries": 0}), &schema),
2630            Err("$.retries: default must be greater than or equal to 1".to_owned())
2631        );
2632        assert_eq!(
2633            validate_configuration_defaults(&json!({"token": {"secret_ref": "TOKEN"}}), &schema),
2634            Err("$.token: sensitive configuration cannot have a package default".to_owned())
2635        );
2636    }
2637
2638    #[test]
2639    fn typed_ports_preserve_client_paths_and_cardinality() {
2640        let one: Type = parse_quote!(Port<secrets::SecretsClient>);
2641        let many: Type = parse_quote!(ManyPort<auth::AuthClient>);
2642
2643        let (one_client, one_cardinality) = port_client(&one).unwrap().unwrap();
2644        let (many_client, many_cardinality) = port_client(&many).unwrap().unwrap();
2645
2646        assert_eq!(quote!(#one_client).to_string(), "secrets :: SecretsClient");
2647        assert!(matches!(one_cardinality, PortCardinality::One));
2648        assert_eq!(quote!(#many_client).to_string(), "auth :: AuthClient");
2649        assert!(matches!(many_cardinality, PortCardinality::Many));
2650    }
2651
2652    #[test]
2653    fn named_dependency_fields_determine_cardinality_without_type_only_matching() {
2654        let mut plugin: ItemStruct = parse_quote! {
2655            struct Consumer {
2656                #[dependency(id = "source")]
2657                source: store::StoreClient,
2658                #[dependency(id = "fallback")]
2659                fallback: Option<store::StoreClient>,
2660                #[dependency(id = "replicas")]
2661                replicas: Vec<BoundCapabilityClient<store::StoreClient>>,
2662            }
2663        };
2664        let fields = analyze_struct_fields(&mut plugin, &quote!(::lenso)).unwrap();
2665
2666        assert_eq!(fields.construction_fields.len(), 3);
2667        let ids = fields
2668            .construction_fields
2669            .iter()
2670            .map(|field| match &field.kind {
2671                ConstructionFieldKind::Dependency {
2672                    id, cardinality, ..
2673                } => (
2674                    id.value(),
2675                    match cardinality {
2676                        DependencyCardinality::One => "one",
2677                        DependencyCardinality::Optional => "optional",
2678                        DependencyCardinality::Many => "many",
2679                    },
2680                ),
2681                _ => panic!("expected dependency field"),
2682            })
2683            .collect::<Vec<_>>();
2684        assert_eq!(
2685            ids,
2686            vec![
2687                ("source".to_owned(), "one"),
2688                ("fallback".to_owned(), "optional"),
2689                ("replicas".to_owned(), "many"),
2690            ]
2691        );
2692        assert!(plugin.fields.iter().all(|field| field.attrs.is_empty()));
2693    }
2694
2695    #[test]
2696    fn managed_tasks_fields_are_initialized_and_connected_on_activate() {
2697        let mut plugin: ItemStruct = parse_quote! {
2698            struct Worker {
2699                #[tasks]
2700                tasks: ManagedTasks,
2701            }
2702        };
2703        let fields = analyze_struct_fields(&mut plugin, &quote!(::lenso)).unwrap();
2704
2705        let task_field: syn::Ident = parse_quote!(tasks);
2706        assert_eq!(fields.tasks, vec![task_field]);
2707        assert_eq!(
2708            fields.initializers[0].to_string(),
2709            "tasks : :: core :: default :: Default :: default ()"
2710        );
2711        assert_eq!(
2712            task_connectors(&fields.tasks)[0].to_string(),
2713            "self . plugin . tasks . __lenso_connect (context . tasks () . clone ()) ? ;"
2714        );
2715        assert_eq!(
2716            task_disconnectors(&fields.tasks)[0].to_string(),
2717            "plugin . tasks . __lenso_disconnect () ;"
2718        );
2719        assert!(plugin.fields.iter().next().unwrap().attrs.is_empty());
2720    }
2721
2722    #[test]
2723    fn multiple_capabilities_reject_trait_impls() {
2724        let implementation: ItemImpl = parse_quote! {
2725            impl fixture::Provider for ExamplePlugin {}
2726        };
2727        let error = expand_provides(
2728            &[parse_quote!(fixture::One), parse_quote!(fixture::Two)],
2729            &implementation,
2730        )
2731        .expect_err("multi-Capability authoring must have one inherent impl");
2732
2733        assert!(
2734            error
2735                .to_string()
2736                .contains("multiple Capabilities require one inherent impl")
2737        );
2738    }
2739
2740    #[test]
2741    fn duplicate_capabilities_are_rejected() {
2742        let implementation: ItemImpl = parse_quote! { impl ExamplePlugin {} };
2743        let error = expand_provides(
2744            &[parse_quote!(fixture::One), parse_quote!(fixture::One)],
2745            &implementation,
2746        )
2747        .expect_err("one Capability cannot be contributed twice");
2748
2749        assert!(error.to_string().contains("same Capability more than once"));
2750    }
2751
2752    #[test]
2753    fn capability_paths_must_be_namespace_qualified() {
2754        let implementation: ItemImpl = parse_quote! { impl ExamplePlugin {} };
2755        let error = expand_provides(&[parse_quote!(One)], &implementation)
2756            .expect_err("generated Capability macros live in their namespace");
2757
2758        assert!(error.to_string().contains("namespace-qualified"));
2759    }
2760}