1use std::{collections::BTreeSet, env, fs, path::PathBuf};
4
5use proc_macro::TokenStream;
6use proc_macro_crate::{FoundCrate, crate_name};
7use quote::{format_ident, quote};
8use serde_json::{Map, Value, json};
9use syn::{
10 Attribute, Data, DeriveInput, Expr, Fields, GenericArgument, Item, ItemFn, ItemImpl,
11 ItemStruct, LitStr, Path, PathArguments, Token, Type, parse_macro_input,
12 punctuated::Punctuated,
13};
14
15struct 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#[proc_macro_attribute]
132pub fn plugin(attributes: TokenStream, item: TokenStream) -> TokenStream {
133 expand_authoring_item(attributes, item)
134}
135
136#[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#[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 pub const PLUGIN_DESCRIPTOR_JSON: &str = #descriptor;
678 #[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 pub const PACKAGE_ID: &str = #plugin_id;
689 pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
691 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 const _: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
735 }
736 })
737}
738
739#[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 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 #[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 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 #[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 pub const PACKAGE_ID: &str = #plugin_id;
1306 pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
1308 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, "e!(::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, "e!(::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}