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