1use crate::{helpers::json_string, json_schema_for, proto};
2use anyhow::{Context, Result, anyhow};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6mod control_behavior;
7#[cfg(test)]
8mod exemplar_tests;
9mod web_ui;
10
11use self::control_behavior::PackagedPluginControlBehavior;
12use self::web_ui::PackagedPluginWebUi;
13pub use self::web_ui::{
14 PluginWebUiBuilder, PluginWebUiBundleBuilder, PluginWebUiConfigSectionBuilder,
15 PluginWebUiPageBuilder, web_ui, web_ui_bundle, web_ui_config_section, web_ui_page,
16};
17
18#[cfg(test)]
19use self::control_behavior::{
20 PackagedPluginDisabledWritePolicy, PackagedPluginOptionsSource, PackagedPluginTextFormat,
21};
22
23#[derive(Clone, Debug)]
24pub enum ManifestEntry {
25 Capability(String),
26 ConfigSchema(proto::PluginConfigSchemaManifest),
27 Operation(proto::OperationManifest),
28 Resource(proto::ResourceManifest),
29 ResourceTemplate(proto::ResourceTemplateManifest),
30 Prompt(proto::PromptManifest),
31 Completion(proto::CompletionManifest),
32 HttpBinding(proto::HttpBindingManifest),
33 Endpoint(proto::EndpointManifest),
34 MeshChannel(proto::MeshChannelManifest),
35 MeshEventSubscription(proto::MeshEventSubscriptionManifest),
36 WebUi(proto::PluginWebUiManifest),
37}
38
39#[derive(Clone, Debug, Default)]
40pub struct PluginManifestBuilder {
41 manifest: proto::PluginManifest,
42}
43
44impl PluginManifestBuilder {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn item<T: Into<ManifestEntry>>(mut self, item: T) -> Self {
50 self.push(item.into());
51 self
52 }
53
54 pub fn build(self) -> proto::PluginManifest {
55 self.manifest
56 }
57
58 pub fn push_item<T: Into<ManifestEntry>>(&mut self, item: T) {
59 self.push(item.into());
60 }
61
62 fn push(&mut self, item: ManifestEntry) {
63 match item {
64 ManifestEntry::Capability(capability) => self.manifest.capabilities.push(capability),
65 ManifestEntry::ConfigSchema(schema) => self.manifest.config_schema = Some(schema),
66 ManifestEntry::Operation(operation) => self.manifest.operations.push(operation),
67 ManifestEntry::Resource(resource) => self.manifest.resources.push(resource),
68 ManifestEntry::ResourceTemplate(template) => {
69 self.manifest.resource_templates.push(template);
70 }
71 ManifestEntry::Prompt(prompt) => self.manifest.prompts.push(prompt),
72 ManifestEntry::Completion(completion) => {
73 self.manifest.completions.push(completion);
74 }
75 ManifestEntry::HttpBinding(binding) => self.manifest.http_bindings.push(binding),
76 ManifestEntry::Endpoint(endpoint) => self.manifest.endpoints.push(endpoint),
77 ManifestEntry::MeshChannel(channel) => self.manifest.mesh_channels.push(channel),
78 ManifestEntry::MeshEventSubscription(subscription) => {
79 self.manifest.mesh_event_subscriptions.push(subscription);
80 }
81 ManifestEntry::WebUi(web_ui) => self.manifest.web_ui = Some(web_ui),
82 }
83 }
84}
85
86pub fn plugin_manifest() -> PluginManifestBuilder {
87 PluginManifestBuilder::new()
88}
89
90pub fn capability(name: impl Into<String>) -> ManifestEntry {
91 ManifestEntry::Capability(name.into())
92}
93
94pub fn config_schema(plugin_name: impl Into<String>) -> PluginConfigSchemaBuilder {
95 PluginConfigSchemaBuilder {
96 inner: proto::PluginConfigSchemaManifest {
97 plugin_name: plugin_name.into(),
98 schema_version: 1,
99 allow_unvalidated_config: false,
100 settings: Vec::new(),
101 },
102 }
103}
104
105pub fn config_setting(
106 key: impl Into<String>,
107 value_schema: proto::PluginConfigValueSchema,
108) -> PluginConfigSettingBuilder {
109 PluginConfigSettingBuilder {
110 inner: proto::PluginConfigSettingManifest {
111 key: key.into(),
112 value_schema: Some(value_schema),
113 required: false,
114 default_json: None,
115 constraints: Vec::new(),
116 apply_mode: proto::PluginConfigApplyMode::StaticOnLoad as i32,
117 restart_scope: proto::PluginConfigRestartScope::None as i32,
118 visibility: proto::PluginConfigVisibility::User as i32,
119 description: None,
120 presentation: None,
121 control_behavior: None,
122 },
123 }
124}
125
126pub fn config_boolean() -> proto::PluginConfigValueSchema {
127 value_schema(proto::PluginConfigValueKind::Boolean)
128}
129
130pub fn config_integer() -> proto::PluginConfigValueSchema {
131 value_schema(proto::PluginConfigValueKind::Integer)
132}
133
134pub fn config_float() -> proto::PluginConfigValueSchema {
135 value_schema(proto::PluginConfigValueKind::Float)
136}
137
138pub fn config_string() -> proto::PluginConfigValueSchema {
139 value_schema(proto::PluginConfigValueKind::String)
140}
141
142pub fn config_path() -> proto::PluginConfigValueSchema {
143 value_schema(proto::PluginConfigValueKind::Path)
144}
145
146pub fn config_url() -> proto::PluginConfigValueSchema {
147 value_schema(proto::PluginConfigValueKind::Url)
148}
149
150pub fn config_enum<I, S>(values: I) -> proto::PluginConfigValueSchema
151where
152 I: IntoIterator<Item = S>,
153 S: Into<String>,
154{
155 let mut schema = value_schema(proto::PluginConfigValueKind::Enum);
156 schema.enum_values = values.into_iter().map(Into::into).collect();
157 schema
158}
159
160pub fn config_array(items: proto::PluginConfigValueSchema) -> proto::PluginConfigValueSchema {
161 let mut schema = value_schema(proto::PluginConfigValueKind::Array);
162 schema.items = Some(Box::new(items));
163 schema
164}
165
166pub fn config_object<I>(properties: I) -> proto::PluginConfigValueSchema
167where
168 I: IntoIterator<Item = proto::PluginConfigObjectProperty>,
169{
170 let mut schema = value_schema(proto::PluginConfigValueKind::Object);
171 schema.object_properties = properties.into_iter().collect();
172 schema
173}
174
175pub fn config_object_property(
176 key: impl Into<String>,
177 value_schema: proto::PluginConfigValueSchema,
178) -> PluginConfigObjectPropertyBuilder {
179 PluginConfigObjectPropertyBuilder {
180 inner: proto::PluginConfigObjectProperty {
181 key: key.into(),
182 value_schema: Some(value_schema),
183 required: false,
184 description: None,
185 },
186 }
187}
188
189pub fn constraint_non_empty() -> proto::PluginConfigConstraintManifest {
190 proto::PluginConfigConstraintManifest {
191 constraint: Some(
192 proto::plugin_config_constraint_manifest::Constraint::NonEmpty(
193 proto::PluginConfigNonEmptyConstraint {},
194 ),
195 ),
196 }
197}
198
199pub fn constraint_positive() -> proto::PluginConfigConstraintManifest {
200 proto::PluginConfigConstraintManifest {
201 constraint: Some(
202 proto::plugin_config_constraint_manifest::Constraint::Positive(
203 proto::PluginConfigPositiveConstraint {},
204 ),
205 ),
206 }
207}
208
209pub fn constraint_range(
210 min: Option<impl Into<String>>,
211 max: Option<impl Into<String>>,
212) -> proto::PluginConfigConstraintManifest {
213 proto::PluginConfigConstraintManifest {
214 constraint: Some(proto::plugin_config_constraint_manifest::Constraint::Range(
215 proto::PluginConfigRangeConstraint {
216 min: min.map(Into::into),
217 max: max.map(Into::into),
218 },
219 )),
220 }
221}
222
223pub fn constraint_allowed_values<I, S>(values: I) -> proto::PluginConfigConstraintManifest
224where
225 I: IntoIterator<Item = S>,
226 S: Into<String>,
227{
228 proto::PluginConfigConstraintManifest {
229 constraint: Some(
230 proto::plugin_config_constraint_manifest::Constraint::AllowedValues(
231 proto::PluginConfigAllowedValuesConstraint {
232 values: values.into_iter().map(Into::into).collect(),
233 },
234 ),
235 ),
236 }
237}
238
239pub fn constraint_requires(key: impl Into<String>) -> proto::PluginConfigConstraintManifest {
240 proto::PluginConfigConstraintManifest {
241 constraint: Some(
242 proto::plugin_config_constraint_manifest::Constraint::Requires(
243 proto::PluginConfigRequiresConstraint { key: key.into() },
244 ),
245 ),
246 }
247}
248
249pub fn package_manifest_json(manifest: &proto::PluginManifest) -> Result<String> {
250 let packaged = PackagedPluginManifest::try_from(manifest)?;
251 Ok(serde_json::to_string_pretty(&packaged)?)
252}
253
254fn value_schema(kind: proto::PluginConfigValueKind) -> proto::PluginConfigValueSchema {
255 proto::PluginConfigValueSchema {
256 kind: kind as i32,
257 enum_values: Vec::new(),
258 items: None,
259 object_properties: Vec::new(),
260 allow_additional_properties: false,
261 }
262}
263
264#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
265struct PackagedPluginManifest {
266 #[serde(skip_serializing_if = "Option::is_none", default)]
267 config_schema: Option<PackagedPluginConfigSchema>,
268 #[serde(skip_serializing_if = "Option::is_none", default)]
269 web_ui: Option<PackagedPluginWebUi>,
270}
271
272impl TryFrom<&proto::PluginManifest> for PackagedPluginManifest {
273 type Error = anyhow::Error;
274
275 fn try_from(value: &proto::PluginManifest) -> Result<Self> {
276 let config_schema = value
277 .config_schema
278 .as_ref()
279 .map(PackagedPluginConfigSchema::try_from)
280 .transpose()?;
281 let web_ui = value
282 .web_ui
283 .as_ref()
284 .map(PackagedPluginWebUi::try_from)
285 .transpose()?;
286
287 Ok(Self {
288 config_schema,
289 web_ui,
290 })
291 }
292}
293
294#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
295struct PackagedPluginConfigSchema {
296 plugin_name: String,
297 schema_version: u32,
298 #[serde(default)]
299 allow_unvalidated_config: bool,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 settings: Vec<PackagedPluginSetting>,
302}
303
304impl TryFrom<&proto::PluginConfigSchemaManifest> for PackagedPluginConfigSchema {
305 type Error = anyhow::Error;
306
307 fn try_from(value: &proto::PluginConfigSchemaManifest) -> Result<Self> {
308 let settings = value
309 .settings
310 .iter()
311 .map(PackagedPluginSetting::try_from)
312 .collect::<Result<Vec<_>>>()?;
313
314 Ok(Self {
315 plugin_name: value.plugin_name.clone(),
316 schema_version: value.schema_version,
317 allow_unvalidated_config: value.allow_unvalidated_config,
318 settings,
319 })
320 }
321}
322
323#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
324struct PackagedPluginSetting {
325 key: String,
326 value_schema: PackagedPluginValueSchema,
327 #[serde(default)]
328 required: bool,
329 #[serde(skip_serializing_if = "Option::is_none", default)]
330 default_json: Option<String>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 constraints: Vec<PackagedPluginConstraint>,
333 apply_mode: PackagedPluginApplyMode,
334 restart_scope: PackagedPluginRestartScope,
335 visibility: PackagedPluginVisibility,
336 #[serde(skip_serializing_if = "Option::is_none", default)]
337 description: Option<String>,
338 #[serde(skip_serializing_if = "Option::is_none", default)]
339 presentation: Option<PackagedPluginPresentation>,
340 #[serde(skip_serializing_if = "Option::is_none", default)]
341 control_behavior: Option<PackagedPluginControlBehavior>,
342}
343
344impl TryFrom<&proto::PluginConfigSettingManifest> for PackagedPluginSetting {
345 type Error = anyhow::Error;
346
347 fn try_from(value: &proto::PluginConfigSettingManifest) -> Result<Self> {
348 let value_schema = value.value_schema.as_ref().ok_or_else(|| {
349 anyhow!(
350 "plugin config setting `{}` is missing value_schema",
351 value.key
352 )
353 })?;
354 let constraints = value
355 .constraints
356 .iter()
357 .enumerate()
358 .map(|(index, constraint)| {
359 PackagedPluginConstraint::try_from(constraint).with_context(|| {
360 format!(
361 "plugin config setting `{}` has invalid constraint #{}",
362 value.key,
363 index + 1
364 )
365 })
366 })
367 .collect::<Result<Vec<_>>>()?;
368
369 Ok(Self {
370 key: value.key.clone(),
371 value_schema: PackagedPluginValueSchema::try_from(value_schema).with_context(|| {
372 format!(
373 "plugin config setting `{}` has invalid value_schema",
374 value.key
375 )
376 })?,
377 required: value.required,
378 default_json: value.default_json.clone(),
379 constraints,
380 apply_mode: PackagedPluginApplyMode::try_from_i32(value.apply_mode).with_context(
381 || {
382 format!(
383 "plugin config setting `{}` has invalid apply_mode",
384 value.key
385 )
386 },
387 )?,
388 restart_scope: PackagedPluginRestartScope::try_from_i32(value.restart_scope)
389 .with_context(|| {
390 format!(
391 "plugin config setting `{}` has invalid restart_scope",
392 value.key
393 )
394 })?,
395 visibility: PackagedPluginVisibility::try_from_i32(value.visibility).with_context(
396 || {
397 format!(
398 "plugin config setting `{}` has invalid visibility",
399 value.key
400 )
401 },
402 )?,
403 description: value.description.clone(),
404 presentation: value
405 .presentation
406 .as_ref()
407 .map(PackagedPluginPresentation::from),
408 control_behavior: value
409 .control_behavior
410 .as_ref()
411 .map(PackagedPluginControlBehavior::try_from)
412 .transpose()
413 .with_context(|| {
414 format!(
415 "plugin config setting `{}` has invalid control_behavior",
416 value.key
417 )
418 })?,
419 })
420 }
421}
422
423#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
424struct PackagedPluginPresentation {
425 #[serde(skip_serializing_if = "Option::is_none", default)]
426 label: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none", default)]
428 help: Option<String>,
429 #[serde(skip_serializing_if = "Option::is_none", default)]
430 category_id: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none", default)]
432 category_label: Option<String>,
433 #[serde(skip_serializing_if = "Option::is_none", default)]
434 category_summary: Option<String>,
435 #[serde(skip_serializing_if = "Option::is_none", default)]
436 category_order: Option<u32>,
437 #[serde(skip_serializing_if = "Option::is_none", default)]
438 setting_order: Option<u32>,
439 #[serde(skip_serializing_if = "Option::is_none", default)]
440 unit: Option<String>,
441 #[serde(skip_serializing_if = "Option::is_none", default)]
442 placeholder: Option<String>,
443 #[serde(skip_serializing_if = "Option::is_none", default)]
444 control_hint: Option<String>,
445 #[serde(skip_serializing_if = "Option::is_none", default)]
446 renderer_id: Option<String>,
447}
448
449impl From<&proto::PluginConfigPresentationManifest> for PackagedPluginPresentation {
450 fn from(value: &proto::PluginConfigPresentationManifest) -> Self {
451 Self {
452 label: value.label.clone(),
453 help: value.help.clone(),
454 category_id: value.category_id.clone(),
455 category_label: value.category_label.clone(),
456 category_summary: value.category_summary.clone(),
457 category_order: value.category_order,
458 setting_order: value.setting_order,
459 unit: value.unit.clone(),
460 placeholder: value.placeholder.clone(),
461 control_hint: value.control_hint.clone(),
462 renderer_id: value.renderer_id.clone(),
463 }
464 }
465}
466
467#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
468struct PackagedPluginValueSchema {
469 kind: PackagedPluginValueKind,
470 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 enum_values: Vec<String>,
472 #[serde(skip_serializing_if = "Option::is_none", default)]
473 items: Option<Box<PackagedPluginValueSchema>>,
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
475 object_properties: Vec<PackagedPluginObjectProperty>,
476 #[serde(default)]
477 allow_additional_properties: bool,
478}
479
480impl TryFrom<&proto::PluginConfigValueSchema> for PackagedPluginValueSchema {
481 type Error = anyhow::Error;
482
483 fn try_from(value: &proto::PluginConfigValueSchema) -> Result<Self> {
484 let items = value
485 .items
486 .as_ref()
487 .map(|items| PackagedPluginValueSchema::try_from(items.as_ref()).map(Box::new))
488 .transpose()
489 .context("array items schema is invalid")?;
490 let object_properties = value
491 .object_properties
492 .iter()
493 .map(PackagedPluginObjectProperty::try_from)
494 .collect::<Result<Vec<_>>>()?;
495
496 Ok(Self {
497 kind: PackagedPluginValueKind::try_from_i32(value.kind)?,
498 enum_values: value.enum_values.clone(),
499 items,
500 object_properties,
501 allow_additional_properties: value.allow_additional_properties,
502 })
503 }
504}
505
506#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
507struct PackagedPluginObjectProperty {
508 key: String,
509 value_schema: PackagedPluginValueSchema,
510 #[serde(default)]
511 required: bool,
512 #[serde(skip_serializing_if = "Option::is_none", default)]
513 description: Option<String>,
514}
515
516impl TryFrom<&proto::PluginConfigObjectProperty> for PackagedPluginObjectProperty {
517 type Error = anyhow::Error;
518
519 fn try_from(value: &proto::PluginConfigObjectProperty) -> Result<Self> {
520 let value_schema = value.value_schema.as_ref().ok_or_else(|| {
521 anyhow!(
522 "plugin config object property `{}` is missing value_schema",
523 value.key
524 )
525 })?;
526
527 Ok(Self {
528 key: value.key.clone(),
529 value_schema: PackagedPluginValueSchema::try_from(value_schema).with_context(|| {
530 format!(
531 "plugin config object property `{}` has invalid value_schema",
532 value.key
533 )
534 })?,
535 required: value.required,
536 description: value.description.clone(),
537 })
538 }
539}
540
541#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
542#[serde(rename_all = "snake_case")]
543enum PackagedPluginValueKind {
544 Boolean,
545 Integer,
546 Float,
547 String,
548 Path,
549 Url,
550 Enum,
551 Array,
552 Object,
553}
554
555impl PackagedPluginValueKind {
556 fn try_from_i32(value: i32) -> Result<Self> {
557 let kind = match proto::PluginConfigValueKind::try_from(value)
558 .map_err(|_| anyhow!("unknown plugin config value kind `{value}`"))?
559 {
560 proto::PluginConfigValueKind::Boolean => Self::Boolean,
561 proto::PluginConfigValueKind::Integer => Self::Integer,
562 proto::PluginConfigValueKind::Float => Self::Float,
563 proto::PluginConfigValueKind::String => Self::String,
564 proto::PluginConfigValueKind::Path => Self::Path,
565 proto::PluginConfigValueKind::Url => Self::Url,
566 proto::PluginConfigValueKind::Enum => Self::Enum,
567 proto::PluginConfigValueKind::Array => Self::Array,
568 proto::PluginConfigValueKind::Object => Self::Object,
569 proto::PluginConfigValueKind::Unspecified => {
570 return Err(anyhow!("plugin config value kind is unspecified"));
571 }
572 };
573 Ok(kind)
574 }
575}
576
577#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
578#[serde(rename_all = "snake_case")]
579enum PackagedPluginApplyMode {
580 StaticOnLoad,
581 DynamicValidationOnly,
582 DynamicApply,
583}
584
585impl PackagedPluginApplyMode {
586 fn try_from_i32(value: i32) -> Result<Self> {
587 let mode = match proto::PluginConfigApplyMode::try_from(value)
588 .map_err(|_| anyhow!("unknown plugin config apply mode `{value}`"))?
589 {
590 proto::PluginConfigApplyMode::StaticOnLoad => Self::StaticOnLoad,
591 proto::PluginConfigApplyMode::DynamicValidationOnly => Self::DynamicValidationOnly,
592 proto::PluginConfigApplyMode::DynamicApply => Self::DynamicApply,
593 proto::PluginConfigApplyMode::Unspecified => {
594 return Err(anyhow!("plugin config apply mode is unspecified"));
595 }
596 };
597 Ok(mode)
598 }
599}
600
601#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
602#[serde(rename_all = "snake_case")]
603enum PackagedPluginRestartScope {
604 None,
605 ModelReload,
606 ProcessRestart,
607 MeshRestart,
608 PluginProcess,
609}
610
611impl PackagedPluginRestartScope {
612 fn try_from_i32(value: i32) -> Result<Self> {
613 let scope = match proto::PluginConfigRestartScope::try_from(value)
614 .map_err(|_| anyhow!("unknown plugin config restart scope `{value}`"))?
615 {
616 proto::PluginConfigRestartScope::None => Self::None,
617 proto::PluginConfigRestartScope::ModelReload => Self::ModelReload,
618 proto::PluginConfigRestartScope::ProcessRestart => Self::ProcessRestart,
619 proto::PluginConfigRestartScope::MeshRestart => Self::MeshRestart,
620 proto::PluginConfigRestartScope::PluginProcess => Self::PluginProcess,
621 proto::PluginConfigRestartScope::Unspecified => {
622 return Err(anyhow!("plugin config restart scope is unspecified"));
623 }
624 };
625 Ok(scope)
626 }
627}
628
629#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
630#[serde(rename_all = "snake_case")]
631enum PackagedPluginVisibility {
632 User,
633 Advanced,
634 Hidden,
635 Internal,
636}
637
638impl PackagedPluginVisibility {
639 fn try_from_i32(value: i32) -> Result<Self> {
640 let visibility = match proto::PluginConfigVisibility::try_from(value)
641 .map_err(|_| anyhow!("unknown plugin config visibility `{value}`"))?
642 {
643 proto::PluginConfigVisibility::User => Self::User,
644 proto::PluginConfigVisibility::Advanced => Self::Advanced,
645 proto::PluginConfigVisibility::Hidden => Self::Hidden,
646 proto::PluginConfigVisibility::Internal => Self::Internal,
647 proto::PluginConfigVisibility::Unspecified => {
648 return Err(anyhow!("plugin config visibility is unspecified"));
649 }
650 };
651 Ok(visibility)
652 }
653}
654
655#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
656#[serde(tag = "kind", rename_all = "snake_case")]
657enum PackagedPluginConstraint {
658 NonEmpty,
659 Positive,
660 Range {
661 #[serde(skip_serializing_if = "Option::is_none", default)]
662 min: Option<String>,
663 #[serde(skip_serializing_if = "Option::is_none", default)]
664 max: Option<String>,
665 },
666 AllowedValues {
667 values: Vec<String>,
668 },
669 Requires {
670 key: String,
671 },
672}
673
674impl PackagedPluginConstraint {
675 fn try_from(value: &proto::PluginConfigConstraintManifest) -> Result<Self> {
676 match value
677 .constraint
678 .as_ref()
679 .ok_or_else(|| anyhow!("plugin config constraint is empty"))?
680 {
681 proto::plugin_config_constraint_manifest::Constraint::NonEmpty(_) => Ok(Self::NonEmpty),
682 proto::plugin_config_constraint_manifest::Constraint::Positive(_) => Ok(Self::Positive),
683 proto::plugin_config_constraint_manifest::Constraint::Range(range) => Ok(Self::Range {
684 min: range.min.clone(),
685 max: range.max.clone(),
686 }),
687 proto::plugin_config_constraint_manifest::Constraint::AllowedValues(values) => {
688 Ok(Self::AllowedValues {
689 values: values.values.clone(),
690 })
691 }
692 proto::plugin_config_constraint_manifest::Constraint::Requires(requires) => {
693 Ok(Self::Requires {
694 key: requires.key.clone(),
695 })
696 }
697 }
698 }
699}
700
701pub fn mesh_channel(name: impl Into<String>) -> ManifestEntry {
702 ManifestEntry::MeshChannel(proto::MeshChannelManifest { name: name.into() })
703}
704
705pub fn mesh_event_subscription(kind: proto::mesh_event::Kind) -> ManifestEntry {
706 ManifestEntry::MeshEventSubscription(proto::MeshEventSubscriptionManifest { kind: kind as i32 })
707}
708
709pub fn mesh_event_peer_up() -> ManifestEntry {
710 mesh_event_subscription(proto::mesh_event::Kind::PeerUp)
711}
712
713pub fn mesh_event_peer_down() -> ManifestEntry {
714 mesh_event_subscription(proto::mesh_event::Kind::PeerDown)
715}
716
717pub fn mesh_event_peer_updated() -> ManifestEntry {
718 mesh_event_subscription(proto::mesh_event::Kind::PeerUpdated)
719}
720
721pub fn mesh_event_local_accepting() -> ManifestEntry {
722 mesh_event_subscription(proto::mesh_event::Kind::LocalAccepting)
723}
724
725pub fn mesh_event_local_standby() -> ManifestEntry {
726 mesh_event_subscription(proto::mesh_event::Kind::LocalStandby)
727}
728
729pub fn mesh_event_mesh_id_updated() -> ManifestEntry {
730 mesh_event_subscription(proto::mesh_event::Kind::MeshIdUpdated)
731}
732
733impl From<proto::MeshChannelManifest> for ManifestEntry {
734 fn from(value: proto::MeshChannelManifest) -> Self {
735 Self::MeshChannel(value)
736 }
737}
738
739impl From<proto::PluginConfigSchemaManifest> for ManifestEntry {
740 fn from(value: proto::PluginConfigSchemaManifest) -> Self {
741 Self::ConfigSchema(value)
742 }
743}
744
745#[derive(Clone, Debug)]
746pub struct PluginConfigSchemaBuilder {
747 inner: proto::PluginConfigSchemaManifest,
748}
749
750impl PluginConfigSchemaBuilder {
751 pub fn schema_version(mut self, schema_version: u32) -> Self {
752 self.inner.schema_version = schema_version;
753 self
754 }
755
756 pub fn allow_unvalidated_config(mut self, allow_unvalidated_config: bool) -> Self {
757 self.inner.allow_unvalidated_config = allow_unvalidated_config;
758 self
759 }
760
761 pub fn setting<T: Into<proto::PluginConfigSettingManifest>>(mut self, setting: T) -> Self {
762 self.inner.settings.push(setting.into());
763 self
764 }
765}
766
767impl From<PluginConfigSchemaBuilder> for ManifestEntry {
768 fn from(value: PluginConfigSchemaBuilder) -> Self {
769 Self::ConfigSchema(value.inner)
770 }
771}
772
773#[derive(Clone, Debug)]
774pub struct PluginConfigSettingBuilder {
775 inner: proto::PluginConfigSettingManifest,
776}
777
778impl PluginConfigSettingBuilder {
779 pub fn required(mut self, required: bool) -> Self {
780 self.inner.required = required;
781 self
782 }
783
784 pub fn default_value<T: Serialize>(mut self, value: &T) -> Self {
785 self.inner.default_json = json_string(value).ok();
786 self
787 }
788
789 pub fn constraint(mut self, constraint: proto::PluginConfigConstraintManifest) -> Self {
790 self.inner.constraints.push(constraint);
791 self
792 }
793
794 pub fn apply_mode(mut self, apply_mode: proto::PluginConfigApplyMode) -> Self {
795 self.inner.apply_mode = apply_mode as i32;
796 self
797 }
798
799 pub fn restart_scope(mut self, restart_scope: proto::PluginConfigRestartScope) -> Self {
800 self.inner.restart_scope = restart_scope as i32;
801 self
802 }
803
804 pub fn visibility(mut self, visibility: proto::PluginConfigVisibility) -> Self {
805 self.inner.visibility = visibility as i32;
806 self
807 }
808
809 pub fn description(mut self, description: impl Into<String>) -> Self {
810 self.inner.description = Some(description.into());
811 self
812 }
813
814 pub fn label(mut self, label: impl Into<String>) -> Self {
815 self.presentation_mut().label = Some(label.into());
816 self
817 }
818
819 pub fn help(mut self, help: impl Into<String>) -> Self {
820 self.presentation_mut().help = Some(help.into());
821 self
822 }
823
824 pub fn category(
825 mut self,
826 id: impl Into<String>,
827 label: impl Into<String>,
828 summary: impl Into<String>,
829 order: u32,
830 ) -> Self {
831 let presentation = self.presentation_mut();
832 presentation.category_id = Some(id.into());
833 presentation.category_label = Some(label.into());
834 presentation.category_summary = Some(summary.into());
835 presentation.category_order = Some(order);
836 self
837 }
838
839 pub fn order(mut self, order: u32) -> Self {
840 self.presentation_mut().setting_order = Some(order);
841 self
842 }
843
844 pub fn unit(mut self, unit: impl Into<String>) -> Self {
845 self.presentation_mut().unit = Some(unit.into());
846 self
847 }
848
849 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
850 self.presentation_mut().placeholder = Some(placeholder.into());
851 self
852 }
853
854 pub fn control_hint(mut self, control_hint: impl Into<String>) -> Self {
855 self.presentation_mut().control_hint = Some(control_hint.into());
856 self
857 }
858
859 pub fn renderer_id(mut self, renderer_id: impl Into<String>) -> Self {
860 self.presentation_mut().renderer_id = Some(renderer_id.into());
861 self
862 }
863
864 fn presentation_mut(&mut self) -> &mut proto::PluginConfigPresentationManifest {
865 self.inner
866 .presentation
867 .get_or_insert_with(proto::PluginConfigPresentationManifest::default)
868 }
869}
870
871impl From<PluginConfigSettingBuilder> for proto::PluginConfigSettingManifest {
872 fn from(value: PluginConfigSettingBuilder) -> Self {
873 value.inner
874 }
875}
876
877#[derive(Clone, Debug)]
878pub struct PluginConfigObjectPropertyBuilder {
879 inner: proto::PluginConfigObjectProperty,
880}
881
882impl PluginConfigObjectPropertyBuilder {
883 pub fn required(mut self, required: bool) -> Self {
884 self.inner.required = required;
885 self
886 }
887
888 pub fn description(mut self, description: impl Into<String>) -> Self {
889 self.inner.description = Some(description.into());
890 self
891 }
892}
893
894impl From<PluginConfigObjectPropertyBuilder> for proto::PluginConfigObjectProperty {
895 fn from(value: PluginConfigObjectPropertyBuilder) -> Self {
896 value.inner
897 }
898}
899
900impl From<proto::MeshEventSubscriptionManifest> for ManifestEntry {
901 fn from(value: proto::MeshEventSubscriptionManifest) -> Self {
902 Self::MeshEventSubscription(value)
903 }
904}
905
906impl From<proto::PluginWebUiManifest> for ManifestEntry {
907 fn from(value: proto::PluginWebUiManifest) -> Self {
908 Self::WebUi(value)
909 }
910}
911
912impl From<PluginWebUiBuilder> for ManifestEntry {
913 fn from(value: PluginWebUiBuilder) -> Self {
914 Self::WebUi(value.into())
915 }
916}
917
918#[derive(Clone, Debug)]
919pub struct OperationBuilder {
920 inner: proto::OperationManifest,
921}
922
923pub fn operation<Input: JsonSchema>(
924 name: impl Into<String>,
925 description: impl Into<String>,
926) -> OperationBuilder {
927 OperationBuilder {
928 inner: proto::OperationManifest {
929 name: name.into(),
930 description: description.into(),
931 input_schema_json: schema_json::<Input>(),
932 output_schema_json: None,
933 title: None,
934 },
935 }
936}
937
938impl OperationBuilder {
939 pub fn title(mut self, title: impl Into<String>) -> Self {
940 self.inner.title = Some(title.into());
941 self
942 }
943
944 pub fn output_schema<Output: JsonSchema>(mut self) -> Self {
945 self.inner.output_schema_json = Some(schema_json::<Output>());
946 self
947 }
948}
949
950impl From<OperationBuilder> for ManifestEntry {
951 fn from(value: OperationBuilder) -> Self {
952 Self::Operation(value.inner)
953 }
954}
955
956#[derive(Clone, Debug)]
957pub struct ResourceBuilder {
958 inner: proto::ResourceManifest,
959}
960
961pub fn resource(uri: impl Into<String>, name: impl Into<String>) -> ResourceBuilder {
962 ResourceBuilder {
963 inner: proto::ResourceManifest {
964 uri: uri.into(),
965 name: name.into(),
966 description: None,
967 mime_type: None,
968 },
969 }
970}
971
972impl ResourceBuilder {
973 pub fn description(mut self, description: impl Into<String>) -> Self {
974 self.inner.description = Some(description.into());
975 self
976 }
977
978 pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
979 self.inner.mime_type = Some(mime_type.into());
980 self
981 }
982}
983
984impl From<ResourceBuilder> for ManifestEntry {
985 fn from(value: ResourceBuilder) -> Self {
986 Self::Resource(value.inner)
987 }
988}
989
990#[derive(Clone, Debug)]
991pub struct ResourceTemplateBuilder {
992 inner: proto::ResourceTemplateManifest,
993}
994
995pub fn resource_template_service(
996 uri_template: impl Into<String>,
997 name: impl Into<String>,
998) -> ResourceTemplateBuilder {
999 ResourceTemplateBuilder {
1000 inner: proto::ResourceTemplateManifest {
1001 uri_template: uri_template.into(),
1002 name: name.into(),
1003 description: None,
1004 mime_type: None,
1005 },
1006 }
1007}
1008
1009impl ResourceTemplateBuilder {
1010 pub fn description(mut self, description: impl Into<String>) -> Self {
1011 self.inner.description = Some(description.into());
1012 self
1013 }
1014
1015 pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
1016 self.inner.mime_type = Some(mime_type.into());
1017 self
1018 }
1019}
1020
1021impl From<ResourceTemplateBuilder> for ManifestEntry {
1022 fn from(value: ResourceTemplateBuilder) -> Self {
1023 Self::ResourceTemplate(value.inner)
1024 }
1025}
1026
1027#[derive(Clone, Debug)]
1028pub struct PromptBuilder {
1029 inner: proto::PromptManifest,
1030}
1031
1032pub fn prompt_service(name: impl Into<String>) -> PromptBuilder {
1033 PromptBuilder {
1034 inner: proto::PromptManifest {
1035 name: name.into(),
1036 description: None,
1037 },
1038 }
1039}
1040
1041impl PromptBuilder {
1042 pub fn description(mut self, description: impl Into<String>) -> Self {
1043 self.inner.description = Some(description.into());
1044 self
1045 }
1046}
1047
1048impl From<PromptBuilder> for ManifestEntry {
1049 fn from(value: PromptBuilder) -> Self {
1050 Self::Prompt(value.inner)
1051 }
1052}
1053
1054#[derive(Clone, Debug)]
1055pub struct CompletionBuilder {
1056 inner: proto::CompletionManifest,
1057}
1058
1059pub fn completion(argument_ref: impl Into<String>) -> CompletionBuilder {
1060 CompletionBuilder {
1061 inner: proto::CompletionManifest {
1062 argument_ref: argument_ref.into(),
1063 description: None,
1064 },
1065 }
1066}
1067
1068impl CompletionBuilder {
1069 pub fn description(mut self, description: impl Into<String>) -> Self {
1070 self.inner.description = Some(description.into());
1071 self
1072 }
1073}
1074
1075impl From<CompletionBuilder> for ManifestEntry {
1076 fn from(value: CompletionBuilder) -> Self {
1077 Self::Completion(value.inner)
1078 }
1079}
1080
1081#[derive(Clone, Debug)]
1082pub struct HttpBindingBuilder {
1083 inner: proto::HttpBindingManifest,
1084}
1085
1086pub fn http_binding(
1087 method: proto::HttpMethod,
1088 path: impl Into<String>,
1089 operation_name: impl Into<String>,
1090) -> HttpBindingBuilder {
1091 let path = normalize_path(path.into());
1092 let operation_name = operation_name.into();
1093 HttpBindingBuilder {
1094 inner: proto::HttpBindingManifest {
1095 binding_id: default_binding_id(&path, &operation_name),
1096 method: method as i32,
1097 path,
1098 operation_name: Some(operation_name),
1099 request_body_mode: proto::HttpBodyMode::Buffered as i32,
1100 response_body_mode: proto::HttpBodyMode::Buffered as i32,
1101 request_schema_json: None,
1102 response_schema_json: None,
1103 },
1104 }
1105}
1106
1107pub fn http_get(path: impl Into<String>, operation_name: impl Into<String>) -> HttpBindingBuilder {
1108 http_binding(proto::HttpMethod::Get, path, operation_name)
1109}
1110
1111pub fn http_post(path: impl Into<String>, operation_name: impl Into<String>) -> HttpBindingBuilder {
1112 http_binding(proto::HttpMethod::Post, path, operation_name)
1113}
1114
1115pub fn http_put(path: impl Into<String>, operation_name: impl Into<String>) -> HttpBindingBuilder {
1116 http_binding(proto::HttpMethod::Put, path, operation_name)
1117}
1118
1119pub fn http_patch(
1120 path: impl Into<String>,
1121 operation_name: impl Into<String>,
1122) -> HttpBindingBuilder {
1123 http_binding(proto::HttpMethod::Patch, path, operation_name)
1124}
1125
1126pub fn http_delete(
1127 path: impl Into<String>,
1128 operation_name: impl Into<String>,
1129) -> HttpBindingBuilder {
1130 http_binding(proto::HttpMethod::Delete, path, operation_name)
1131}
1132
1133impl HttpBindingBuilder {
1134 pub fn binding_id(mut self, binding_id: impl Into<String>) -> Self {
1135 self.inner.binding_id = binding_id.into();
1136 self
1137 }
1138
1139 pub fn request_schema<Request: JsonSchema>(mut self) -> Self {
1140 self.inner.request_schema_json = Some(schema_json::<Request>());
1141 self
1142 }
1143
1144 pub fn response_schema<Response: JsonSchema>(mut self) -> Self {
1145 self.inner.response_schema_json = Some(schema_json::<Response>());
1146 self
1147 }
1148
1149 pub fn streamed_request(mut self) -> Self {
1150 self.inner.request_body_mode = proto::HttpBodyMode::Streamed as i32;
1151 self
1152 }
1153
1154 pub fn streamed_response(mut self) -> Self {
1155 self.inner.response_body_mode = proto::HttpBodyMode::Streamed as i32;
1156 self
1157 }
1158
1159 pub fn buffered_request(mut self) -> Self {
1160 self.inner.request_body_mode = proto::HttpBodyMode::Buffered as i32;
1161 self
1162 }
1163
1164 pub fn buffered_response(mut self) -> Self {
1165 self.inner.response_body_mode = proto::HttpBodyMode::Buffered as i32;
1166 self
1167 }
1168}
1169
1170impl From<HttpBindingBuilder> for ManifestEntry {
1171 fn from(value: HttpBindingBuilder) -> Self {
1172 Self::HttpBinding(value.inner)
1173 }
1174}
1175
1176#[derive(Clone, Debug)]
1177pub struct EndpointBuilder {
1178 inner: proto::EndpointManifest,
1179}
1180
1181pub fn openai_http_inference_endpoint(
1182 endpoint_id: impl Into<String>,
1183 address: impl Into<String>,
1184) -> EndpointBuilder {
1185 EndpointBuilder {
1186 inner: proto::EndpointManifest {
1187 endpoint_id: endpoint_id.into(),
1188 kind: proto::EndpointKind::Inference as i32,
1189 transport_kind: proto::EndpointTransportKind::EndpointTransportHttp as i32,
1190 protocol: Some("openai_compatible".into()),
1191 address: Some(address.into()),
1192 args: Vec::new(),
1193 namespace: None,
1194 supports_streaming: true,
1195 managed_by_plugin: false,
1196 },
1197 }
1198}
1199
1200pub fn mcp_stdio_endpoint(
1201 endpoint_id: impl Into<String>,
1202 command: impl Into<String>,
1203) -> EndpointBuilder {
1204 EndpointBuilder {
1205 inner: proto::EndpointManifest {
1206 endpoint_id: endpoint_id.into(),
1207 kind: proto::EndpointKind::Mcp as i32,
1208 transport_kind: proto::EndpointTransportKind::EndpointTransportStdio as i32,
1209 protocol: None,
1210 address: Some(command.into()),
1211 args: Vec::new(),
1212 namespace: None,
1213 supports_streaming: false,
1214 managed_by_plugin: false,
1215 },
1216 }
1217}
1218
1219pub fn mcp_http_endpoint(
1220 endpoint_id: impl Into<String>,
1221 address: impl Into<String>,
1222) -> EndpointBuilder {
1223 EndpointBuilder {
1224 inner: proto::EndpointManifest {
1225 endpoint_id: endpoint_id.into(),
1226 kind: proto::EndpointKind::Mcp as i32,
1227 transport_kind: proto::EndpointTransportKind::EndpointTransportHttp as i32,
1228 protocol: Some("streamable_http".into()),
1229 address: Some(address.into()),
1230 args: Vec::new(),
1231 namespace: None,
1232 supports_streaming: true,
1233 managed_by_plugin: false,
1234 },
1235 }
1236}
1237
1238pub fn mcp_tcp_endpoint(
1239 endpoint_id: impl Into<String>,
1240 address: impl Into<String>,
1241) -> EndpointBuilder {
1242 EndpointBuilder {
1243 inner: proto::EndpointManifest {
1244 endpoint_id: endpoint_id.into(),
1245 kind: proto::EndpointKind::Mcp as i32,
1246 transport_kind: proto::EndpointTransportKind::EndpointTransportTcp as i32,
1247 protocol: None,
1248 address: Some(address.into()),
1249 args: Vec::new(),
1250 namespace: None,
1251 supports_streaming: false,
1252 managed_by_plugin: false,
1253 },
1254 }
1255}
1256
1257pub fn mcp_unix_socket_endpoint(
1258 endpoint_id: impl Into<String>,
1259 address: impl Into<String>,
1260) -> EndpointBuilder {
1261 EndpointBuilder {
1262 inner: proto::EndpointManifest {
1263 endpoint_id: endpoint_id.into(),
1264 kind: proto::EndpointKind::Mcp as i32,
1265 transport_kind: proto::EndpointTransportKind::EndpointTransportUnixSocket as i32,
1266 protocol: None,
1267 address: Some(address.into()),
1268 args: Vec::new(),
1269 namespace: None,
1270 supports_streaming: false,
1271 managed_by_plugin: false,
1272 },
1273 }
1274}
1275
1276impl EndpointBuilder {
1277 pub fn protocol(mut self, protocol: impl Into<String>) -> Self {
1278 self.inner.protocol = Some(protocol.into());
1279 self
1280 }
1281
1282 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
1283 self.inner.namespace = Some(namespace.into());
1284 self
1285 }
1286
1287 pub fn arg(mut self, arg: impl Into<String>) -> Self {
1288 self.inner.args.push(arg.into());
1289 self
1290 }
1291
1292 pub fn args<I, S>(mut self, args: I) -> Self
1293 where
1294 I: IntoIterator<Item = S>,
1295 S: Into<String>,
1296 {
1297 self.inner.args.extend(args.into_iter().map(Into::into));
1298 self
1299 }
1300
1301 pub fn supports_streaming(mut self, supports_streaming: bool) -> Self {
1302 self.inner.supports_streaming = supports_streaming;
1303 self
1304 }
1305
1306 pub fn managed_by_plugin(mut self, managed_by_plugin: bool) -> Self {
1307 self.inner.managed_by_plugin = managed_by_plugin;
1308 self
1309 }
1310}
1311
1312impl From<EndpointBuilder> for ManifestEntry {
1313 fn from(value: EndpointBuilder) -> Self {
1314 Self::Endpoint(value.inner)
1315 }
1316}
1317
1318fn schema_json<T: JsonSchema>() -> String {
1319 json_string(&json_schema_for::<T>()).unwrap_or_else(|_| "{}".into())
1320}
1321
1322fn normalize_path(path: String) -> String {
1323 if path.is_empty() {
1324 "/".into()
1325 } else if path.starts_with('/') {
1326 path
1327 } else {
1328 format!("/{path}")
1329 }
1330}
1331
1332fn default_binding_id(path: &str, operation_name: &str) -> String {
1333 let candidate = if !operation_name.trim().is_empty() {
1334 operation_name
1335 } else {
1336 path.trim_matches('/')
1337 };
1338 let sanitized = candidate
1339 .chars()
1340 .map(|ch| {
1341 if ch.is_ascii_alphanumeric() {
1342 ch.to_ascii_lowercase()
1343 } else {
1344 '_'
1345 }
1346 })
1347 .collect::<String>();
1348 let sanitized = sanitized.trim_matches('_');
1349 if sanitized.is_empty() {
1350 "root".into()
1351 } else {
1352 sanitized.into()
1353 }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 use super::*;
1359 use crate::{Plugin, PluginMetadata, inference, mcp, plugin_server_info};
1360 use prost::Message;
1361
1362 #[allow(dead_code)]
1363 #[derive(serde::Deserialize, schemars::JsonSchema)]
1364 struct DemoInput {
1365 value: String,
1366 }
1367
1368 #[allow(dead_code)]
1369 #[derive(serde::Serialize, schemars::JsonSchema)]
1370 struct DemoOutput {
1371 echoed: String,
1372 }
1373
1374 fn manifest_with_setting(setting: proto::PluginConfigSettingManifest) -> proto::PluginManifest {
1375 proto::PluginManifest {
1376 config_schema: Some(proto::PluginConfigSchemaManifest {
1377 plugin_name: "demo".into(),
1378 schema_version: 1,
1379 settings: vec![setting],
1380 ..Default::default()
1381 }),
1382 ..Default::default()
1383 }
1384 }
1385
1386 fn error_chain_contains(error: &anyhow::Error, needle: &str) -> bool {
1387 error
1388 .chain()
1389 .any(|cause| cause.to_string().contains(needle))
1390 }
1391
1392 #[test]
1393 fn macro_builds_manifest_entries() {
1394 let manifest = crate::plugin_manifest![
1395 capability("demo.v1"),
1396 mesh_channel("demo.v1"),
1397 mesh_event_peer_up(),
1398 operation::<DemoInput>("echo", "Echo input").title("Echo"),
1399 http_post("/echo", "echo")
1400 .request_schema::<DemoInput>()
1401 .response_schema::<DemoOutput>(),
1402 mcp_stdio_endpoint("notes", "demo-mcp").arg("--serve"),
1403 ];
1404
1405 assert_eq!(manifest.capabilities, vec!["demo.v1"]);
1406 assert_eq!(manifest.operations.len(), 1);
1407 assert_eq!(manifest.http_bindings.len(), 1);
1408 assert_eq!(manifest.endpoints.len(), 1);
1409 assert_eq!(manifest.mesh_channels.len(), 1);
1410 assert_eq!(manifest.mesh_event_subscriptions.len(), 1);
1411 assert_eq!(manifest.http_bindings[0].binding_id, "echo");
1412 assert_eq!(manifest.endpoints[0].args, vec!["--serve"]);
1413 }
1414
1415 #[test]
1416 fn streaming_http_builder_sets_modes() {
1417 let entry: ManifestEntry = http_post("/upload", "upload")
1418 .streamed_request()
1419 .streamed_response()
1420 .into();
1421 let ManifestEntry::HttpBinding(binding) = entry else {
1422 panic!("expected http binding");
1423 };
1424 assert_eq!(
1425 binding.request_body_mode,
1426 proto::HttpBodyMode::Streamed as i32
1427 );
1428 assert_eq!(
1429 binding.response_body_mode,
1430 proto::HttpBodyMode::Streamed as i32
1431 );
1432 }
1433
1434 #[test]
1435 fn plugin_macro_builds_simple_plugin_with_manifest() {
1436 let plugin = crate::plugin! {
1437 metadata: PluginMetadata::new(
1438 "demo",
1439 "1.0.0",
1440 plugin_server_info("demo", "1.0.0", "Demo", "Demo plugin", None::<String>),
1441 ),
1442 provides: [capability("demo.v1")],
1443 config: [config_schema("demo")],
1444 web_ui: [
1445 web_ui()
1446 .bundle(web_ui_bundle("main", "bundle"))
1447 .page(
1448 web_ui_page("overview", "Overview", "overview", "plugin-ui.js")
1449 .bundle_id("main"),
1450 ),
1451 ],
1452 mesh: [mesh_channel("demo.v1")],
1453 events: [mesh_event_peer_up()],
1454 mcp: [
1455 mcp::tool("echo")
1456 .description("Echo input")
1457 .input::<DemoInput>()
1458 .handle(|args, _context| Box::pin(async move {
1459 Ok(DemoOutput { echoed: args.value })
1460 })),
1461 mcp::external_stdio("stdio", "demo-mcp"),
1462 ],
1463 http: [
1464 crate::http::post("/echo")
1465 .description("Echo input")
1466 .input::<DemoInput>()
1467 .output::<DemoOutput>()
1468 .handle(|args, _context| Box::pin(async move {
1469 Ok(DemoOutput { echoed: args.value })
1470 })),
1471 ],
1472 inference: [
1473 inference::openai_http("local", "http://127.0.0.1:8080/v1"),
1474 ],
1475 };
1476
1477 let manifest = plugin.manifest().expect("manifest");
1478 assert_eq!(plugin.capabilities(), vec!["demo.v1"]);
1479 assert_eq!(manifest.capabilities, vec!["demo.v1"]);
1480 assert_eq!(manifest.config_schema.as_ref().unwrap().plugin_name, "demo");
1481 assert_eq!(manifest.web_ui.as_ref().unwrap().pages[0].id, "overview");
1482 assert_eq!(manifest.operations.len(), 2);
1483 assert_eq!(manifest.http_bindings.len(), 1);
1484 assert_eq!(manifest.endpoints.len(), 2);
1485 assert_eq!(manifest.mesh_channels.len(), 1);
1486 assert_eq!(manifest.mesh_event_subscriptions.len(), 1);
1487 }
1488
1489 #[test]
1490 fn declarative_macro_builds_local_mcp_entries() {
1491 let plugin = crate::plugin! {
1492 metadata: PluginMetadata::new(
1493 "demo",
1494 "1.0.0",
1495 plugin_server_info("demo", "1.0.0", "Demo", "Demo plugin", None::<String>),
1496 ),
1497 provides: [capability("demo.v1")],
1498 mcp: [
1499 mcp::tool("echo")
1500 .description("Echo input")
1501 .input::<DemoInput>()
1502 .handle(|args, _context| Box::pin(async move {
1503 Ok(DemoOutput { echoed: args.value })
1504 })),
1505 mcp::resource("demo://snapshot")
1506 .name("Snapshot")
1507 .handle(|request, _context| Box::pin(async move {
1508 Ok(crate::read_resource_result(vec![
1509 rmcp::model::ResourceContents::text("snapshot", request.uri),
1510 ]))
1511 })),
1512 mcp::prompt("brief")
1513 .description("Brief prompt")
1514 .handle(|request, _context| Box::pin(async move {
1515 Ok(crate::get_prompt_result(vec![
1516 rmcp::model::PromptMessage::new(
1517 rmcp::model::PromptMessageRole::User,
1518 rmcp::model::PromptMessageContent::text(request.name),
1519 ),
1520 ]))
1521 })),
1522 mcp::completion("prompt.brief.topic")
1523 .description("Topic completion")
1524 .handle(|_request, _context| Box::pin(async move {
1525 crate::complete_result(vec!["alpha".into()])
1526 })),
1527 ],
1528 };
1529
1530 let manifest = plugin.manifest().expect("manifest");
1531 assert_eq!(manifest.operations.len(), 1);
1532 assert_eq!(manifest.resources.len(), 1);
1533 assert_eq!(manifest.prompts.len(), 1);
1534 assert_eq!(manifest.completions.len(), 1);
1535 }
1536
1537 #[test]
1538 fn manifest_can_embed_packaged_config_schema() {
1539 let manifest = crate::plugin_manifest![
1540 config_schema("demo")
1541 .setting(
1542 config_setting("retention_days", config_integer())
1543 .required(true)
1544 .default_value(&14)
1545 .constraint(constraint_range(Some("1"), Some("365")))
1546 .apply_mode(proto::PluginConfigApplyMode::DynamicValidationOnly)
1547 .restart_scope(proto::PluginConfigRestartScope::PluginProcess)
1548 .description("How long to retain entries."),
1549 )
1550 .setting(
1551 config_setting("mode", config_enum(["strict", "relaxed"]))
1552 .default_value(&"strict")
1553 .constraint(constraint_allowed_values(["strict", "relaxed"])),
1554 )
1555 ];
1556
1557 let schema = manifest.config_schema.expect("config schema");
1558 assert_eq!(schema.plugin_name, "demo");
1559 assert_eq!(schema.schema_version, 1);
1560 assert_eq!(schema.settings.len(), 2);
1561 assert_eq!(schema.settings[0].default_json.as_deref(), Some("14"));
1562 }
1563
1564 #[test]
1565 fn packaged_manifest_json_includes_config_schema() {
1566 let manifest = crate::plugin_manifest![
1567 config_schema("demo")
1568 .allow_unvalidated_config(true)
1569 .setting(
1570 config_setting("legacy", config_boolean())
1571 .default_value(&true)
1572 .label("Legacy mode")
1573 .help("Enable the legacy compatibility path.")
1574 .category("compat", "Compatibility", "Compatibility settings", 20)
1575 .order(10)
1576 .control_hint("toggle"),
1577 )
1578 ];
1579
1580 let encoded = package_manifest_json(&manifest).expect("manifest json");
1581 let decoded: PackagedPluginManifest =
1582 serde_json::from_str(&encoded).expect("manifest should deserialize");
1583
1584 let schema = decoded.config_schema.expect("config schema");
1585 assert!(schema.allow_unvalidated_config);
1586 assert_eq!(schema.settings[0].key, "legacy");
1587 assert_eq!(
1588 schema.settings[0]
1589 .presentation
1590 .as_ref()
1591 .and_then(|presentation| presentation.label.as_deref()),
1592 Some("Legacy mode")
1593 );
1594 assert!(schema.settings[0].control_behavior.is_none());
1595 }
1596
1597 #[test]
1598 fn packaged_manifest_json_omits_control_behavior_for_old_manifests() {
1599 let manifest = crate::plugin_manifest![config_schema("demo").setting(
1600 config_setting("legacy", config_string()).description("Legacy free-form setting."),
1601 )];
1602
1603 let encoded = package_manifest_json(&manifest).expect("manifest json");
1604 let decoded: serde_json::Value =
1605 serde_json::from_str(&encoded).expect("manifest should deserialize");
1606
1607 assert!(
1608 !decoded["config_schema"]["settings"][0]
1609 .as_object()
1610 .expect("setting object")
1611 .contains_key("control_behavior")
1612 );
1613 }
1614
1615 #[test]
1616 fn packaged_manifest_json_roundtrips_control_behavior_metadata() {
1617 let manifest = crate::plugin_manifest![
1618 config_schema("demo").setting(
1619 config_setting("service_url", config_url())
1620 .control_text_format(proto::PluginConfigTextFormat::Url)
1621 .control_options_runtime_local_models()
1622 .control_availability(
1623 false,
1624 proto::PluginConfigControlAvailabilitySource::Runtime
1625 )
1626 .control_availability_reason("Waiting for runtime discovery")
1627 .control_availability_note("The current value will be preserved.")
1628 .control_enable_when(proto::PluginConfigControlCondition {
1629 key: "mode".into(),
1630 operator: proto::PluginConfigConditionOperator::Equals as i32,
1631 values: vec![proto::PluginConfigConditionValue {
1632 value: Some(proto::plugin_config_condition_value::Value::StringValue(
1633 "remote".into(),
1634 ),),
1635 }],
1636 })
1637 .control_disable_when(proto::PluginConfigConditionalDisable {
1638 condition: Some(proto::PluginConfigControlCondition {
1639 key: "mode".into(),
1640 operator: proto::PluginConfigConditionOperator::NotEquals as i32,
1641 values: vec![proto::PluginConfigConditionValue {
1642 value: Some(
1643 proto::plugin_config_condition_value::Value::StringValue(
1644 "remote".into(),
1645 ),
1646 ),
1647 }],
1648 }),
1649 reason: "Remote mode is required".into(),
1650 note: Some("Switch mode back to remote to edit this setting.".into()),
1651 write_policy: proto::PluginConfigDisabledWritePolicy::PreserveExisting
1652 as i32,
1653 })
1654 .control_conflict(proto::PluginConfigConflictRule {
1655 group: "transport".into(),
1656 condition: Some(proto::PluginConfigControlCondition {
1657 key: "socket_path".into(),
1658 operator: proto::PluginConfigConditionOperator::Present as i32,
1659 values: Vec::new(),
1660 }),
1661 reason: "Use either a URL or a socket path.".into(),
1662 preferred_key: Some("service_url".into()),
1663 })
1664 .control_write_policy(proto::PluginConfigDisabledWritePolicy::PreserveExisting),
1665 )
1666 ];
1667
1668 let encoded = package_manifest_json(&manifest).expect("manifest json");
1669 let decoded: PackagedPluginManifest =
1670 serde_json::from_str(&encoded).expect("manifest should deserialize");
1671 let schema = decoded.config_schema.expect("config schema");
1672 let setting = &schema.settings[0];
1673 let control_behavior = setting
1674 .control_behavior
1675 .as_ref()
1676 .expect("control behavior should be present");
1677
1678 assert_eq!(setting.value_schema.kind, PackagedPluginValueKind::Url);
1679 assert_eq!(
1680 control_behavior.text_format,
1681 Some(PackagedPluginTextFormat::Url)
1682 );
1683 assert_eq!(
1684 control_behavior.options_source,
1685 Some(PackagedPluginOptionsSource::RuntimeLocalModels)
1686 );
1687 assert_eq!(
1688 control_behavior
1689 .availability
1690 .as_ref()
1691 .map(|availability| availability.enabled),
1692 Some(false)
1693 );
1694 assert_eq!(
1695 control_behavior.write_policy,
1696 Some(PackagedPluginDisabledWritePolicy::PreserveExisting)
1697 );
1698 assert_eq!(control_behavior.enable_when.len(), 1);
1699 assert_eq!(control_behavior.disable_when.len(), 1);
1700 assert_eq!(control_behavior.conflicts.len(), 1);
1701 }
1702
1703 #[test]
1704 fn manifest_builder_preserves_web_ui_proto_and_package_json_metadata() {
1705 let manifest = crate::plugin_manifest![
1706 web_ui()
1707 .bundle(web_ui_bundle("main", "dist"))
1708 .page(
1709 web_ui_page("dashboard", "Dashboard", "dashboard", "dashboard.js")
1710 .icon("icons/dashboard.svg")
1711 .bundle_id("main"),
1712 )
1713 .config_section(
1714 web_ui_config_section("settings", "Settings", "settings.js")
1715 .parent_tab("integrations")
1716 .bundle_id("main"),
1717 )
1718 ];
1719
1720 let web_ui = manifest.web_ui.as_ref().expect("web_ui should be present");
1721 assert_eq!(web_ui.pages[0].id, "dashboard");
1722 assert_eq!(web_ui.pages[0].icon.as_deref(), Some("icons/dashboard.svg"));
1723 assert_eq!(
1724 web_ui.config_sections[0].parent_tab.as_deref(),
1725 Some("integrations")
1726 );
1727 assert_eq!(web_ui.bundles[0].root_path, "dist");
1728
1729 let encoded = package_manifest_json(&manifest).expect("manifest json");
1730 let decoded: PackagedPluginManifest =
1731 serde_json::from_str(&encoded).expect("manifest should deserialize");
1732 let packaged_web_ui = decoded.web_ui.expect("web_ui json should be present");
1733
1734 assert_eq!(packaged_web_ui.pages[0].route, "dashboard");
1735 assert_eq!(packaged_web_ui.pages[0].entry_script, "dashboard.js");
1736 assert_eq!(packaged_web_ui.config_sections[0].title, "Settings");
1737 assert_eq!(packaged_web_ui.bundles[0].id, "main");
1738 }
1739
1740 #[test]
1741 fn old_manifest_bytes_decode_without_web_ui() {
1742 let old_shape = proto::PluginManifest {
1743 capabilities: vec!["demo.v1".into()],
1744 ..Default::default()
1745 };
1746 let mut encoded = Vec::new();
1747 old_shape.encode(&mut encoded).expect("encode manifest");
1748
1749 let decoded = proto::PluginManifest::decode(encoded.as_slice()).expect("decode manifest");
1750
1751 assert_eq!(decoded.capabilities, vec!["demo.v1"]);
1752 assert!(decoded.web_ui.is_none());
1753 }
1754
1755 #[test]
1756 fn packaged_manifest_json_rejects_missing_setting_value_schema() {
1757 let setting = proto::PluginConfigSettingManifest {
1758 key: "broken".into(),
1759 value_schema: None,
1760 apply_mode: proto::PluginConfigApplyMode::StaticOnLoad as i32,
1761 restart_scope: proto::PluginConfigRestartScope::None as i32,
1762 visibility: proto::PluginConfigVisibility::User as i32,
1763 ..Default::default()
1764 };
1765
1766 let error = package_manifest_json(&manifest_with_setting(setting))
1767 .expect_err("missing value_schema should fail packaging");
1768
1769 assert!(
1770 error_chain_contains(&error, "missing value_schema"),
1771 "{error}"
1772 );
1773 assert!(error_chain_contains(&error, "broken"), "{error}");
1774 }
1775
1776 #[test]
1777 fn packaged_manifest_json_rejects_empty_constraint_payload() {
1778 let mut setting: proto::PluginConfigSettingManifest =
1779 config_setting("mode", config_string()).into();
1780 setting
1781 .constraints
1782 .push(proto::PluginConfigConstraintManifest { constraint: None });
1783
1784 let error = package_manifest_json(&manifest_with_setting(setting))
1785 .expect_err("empty constraint should fail packaging");
1786
1787 assert!(
1788 error_chain_contains(&error, "invalid constraint #1"),
1789 "{error}"
1790 );
1791 assert!(
1792 error_chain_contains(&error, "constraint is empty"),
1793 "{error}"
1794 );
1795 }
1796
1797 #[test]
1798 fn packaged_manifest_json_rejects_unknown_enum_discriminants() {
1799 let mut setting: proto::PluginConfigSettingManifest =
1800 config_setting("mode", config_string()).into();
1801 setting.apply_mode = 99_999;
1802
1803 let error = package_manifest_json(&manifest_with_setting(setting))
1804 .expect_err("unknown apply mode should fail packaging");
1805
1806 assert!(
1807 error_chain_contains(&error, "invalid apply_mode"),
1808 "{error}"
1809 );
1810 assert!(
1811 error_chain_contains(&error, "unknown plugin config apply mode"),
1812 "{error}"
1813 );
1814 }
1815}