Skip to main content

systemprompt_models/services/
plugin.rs

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