Skip to main content

systemprompt_models/services/
plugin.rs

1//! Plugin configuration and component-reference model.
2//!
3//! [`PluginConfig`] is the manifest shape loaded from a plugin's config file;
4//! its skill/agent/MCP/content references are [`PluginComponentRef`]s resolved
5//! against the instance ([`ComponentSource`]). [`PluginSummary`] is the
6//! list-view projection; [`PluginConfig::validate`] enforces id and reference
7//! rules.
8
9use std::fmt;
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use systemprompt_identifiers::PluginId;
14
15use crate::errors::ConfigValidationError;
16
17const fn default_true() -> bool {
18    true
19}
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "lowercase")]
23pub enum ComponentSource {
24    #[default]
25    Instance,
26    Explicit,
27}
28
29impl fmt::Display for ComponentSource {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Instance => write!(f, "instance"),
33            Self::Explicit => write!(f, "explicit"),
34        }
35    }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "lowercase")]
40pub enum ComponentFilter {
41    Enabled,
42}
43
44impl fmt::Display for ComponentFilter {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Enabled => write!(f, "enabled"),
48        }
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct PluginConfigFile {
54    pub plugin: PluginConfig,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58pub struct PluginVariableDef {
59    pub name: String,
60    #[serde(default)]
61    pub description: String,
62    #[serde(default = "default_true")]
63    pub required: bool,
64    #[serde(default)]
65    pub secret: bool,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub example: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PluginConfig {
72    pub id: PluginId,
73    pub name: String,
74    pub description: String,
75    pub version: String,
76    #[serde(default = "default_true")]
77    pub enabled: bool,
78    pub author: PluginAuthor,
79    pub keywords: Vec<String>,
80    pub license: String,
81    pub category: String,
82
83    pub skills: PluginComponentRef,
84    pub agents: PluginComponentRef,
85    #[serde(default)]
86    pub mcp_servers: PluginComponentRef,
87    #[serde(default)]
88    pub content_sources: PluginComponentRef,
89    #[serde(default)]
90    pub scripts: Vec<PluginScript>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct PluginComponentRef {
95    #[serde(default)]
96    pub source: ComponentSource,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub filter: Option<ComponentFilter>,
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub include: Vec<String>,
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub exclude: Vec<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PluginScript {
107    pub name: String,
108    pub source: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct PluginAuthor {
113    pub name: String,
114    pub email: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118pub struct PluginSummary {
119    pub id: PluginId,
120    pub name: String,
121    pub display_name: String,
122    pub enabled: bool,
123    pub skill_count: usize,
124    pub agent_count: usize,
125}
126
127impl From<&PluginConfig> for PluginSummary {
128    fn from(config: &PluginConfig) -> Self {
129        Self {
130            id: config.id.clone(),
131            name: config.name.clone(),
132            display_name: config.name.clone(),
133            enabled: config.enabled,
134            skill_count: config.skills.include.len(),
135            agent_count: config.agents.include.len(),
136        }
137    }
138}
139
140impl PluginConfig {
141    pub fn validate(&self, key: &str) -> Result<(), ConfigValidationError> {
142        let id_str = self.id.as_str();
143        if id_str.len() < 3 || id_str.len() > 50 {
144            return Err(ConfigValidationError::invalid_field(format!(
145                "Plugin '{key}': id must be between 3 and 50 characters"
146            )));
147        }
148
149        if !id_str
150            .chars()
151            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
152        {
153            return Err(ConfigValidationError::invalid_field(format!(
154                "Plugin '{key}': id must be lowercase alphanumeric with hyphens only (kebab-case)"
155            )));
156        }
157
158        if self.version.is_empty() {
159            return Err(ConfigValidationError::required(format!(
160                "Plugin '{key}': version must not be empty"
161            )));
162        }
163
164        Self::validate_component_ref(&self.skills, key, "skills")?;
165        Self::validate_component_ref(&self.agents, key, "agents")?;
166
167        Ok(())
168    }
169
170    fn validate_component_ref(
171        component: &PluginComponentRef,
172        key: &str,
173        field: &str,
174    ) -> Result<(), ConfigValidationError> {
175        if component.source == ComponentSource::Explicit && component.include.is_empty() {
176            return Err(ConfigValidationError::invalid_field(format!(
177                "Plugin '{key}': {field}.source is 'explicit' but {field}.include is empty"
178            )));
179        }
180
181        Ok(())
182    }
183}