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