Skip to main content

mesh_llm_plugin/
manifest.rs

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}