Skip to main content

synaps_cli/skills/
plugin_index.rs

1//! Plugin index schema support.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
6pub struct PluginIndex {
7    pub schema_version: u32,
8    #[serde(default)]
9    pub generated_at: Option<String>,
10    pub plugins: Vec<PluginIndexEntry>,
11}
12
13#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
14pub struct PluginIndexEntry {
15    pub id: String,
16    pub name: String,
17    pub version: String,
18    pub description: String,
19    pub repository: String,
20    #[serde(default)]
21    pub subdir: Option<String>,
22    #[serde(default)]
23    pub license: Option<String>,
24    #[serde(default)]
25    pub categories: Vec<String>,
26    #[serde(default)]
27    pub keywords: Vec<String>,
28    pub checksum: PluginIndexChecksum,
29    pub compatibility: PluginIndexCompatibility,
30    pub capabilities: PluginIndexCapabilities,
31    #[serde(default)]
32    pub trust: Option<PluginIndexTrust>,
33}
34
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36pub struct PluginIndexChecksum {
37    pub algorithm: String,
38    pub value: String,
39}
40
41#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
42pub struct PluginIndexCompatibility {
43    #[serde(default)]
44    pub synaps: Option<String>,
45    #[serde(default)]
46    pub extension_protocol: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
50pub struct PluginIndexCapabilities {
51    #[serde(default)]
52    pub skills: Vec<String>,
53    pub has_extension: bool,
54    #[serde(default)]
55    pub permissions: Vec<String>,
56    #[serde(default)]
57    pub hooks: Vec<String>,
58    #[serde(default)]
59    pub commands: Vec<String>,
60    #[serde(default)]
61    pub providers: Vec<PluginIndexProviderCapability>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct PluginIndexProviderCapability {
66    pub id: String,
67    #[serde(default)]
68    pub display_name: Option<String>,
69    #[serde(default)]
70    pub models: Vec<String>,
71}
72
73#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
74pub struct PluginIndexTrust {
75    #[serde(default)]
76    pub publisher: Option<String>,
77    #[serde(default)]
78    pub homepage: Option<String>,
79}
80
81pub fn validate_plugin_index(index: &PluginIndex) -> Result<(), String> {
82    if index.schema_version != 1 {
83        return Err(format!("plugin index schema_version must be 1, got {}", index.schema_version));
84    }
85    for (idx, plugin) in index.plugins.iter().enumerate() {
86        if plugin.id.is_empty() || !plugin.id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
87            return Err(format!("plugins[{idx}].id must be lower-kebab-case"));
88        }
89        if plugin.name.trim().is_empty() {
90            return Err(format!("plugins[{idx}].name is required"));
91        }
92        if !is_semver(&plugin.version) {
93            return Err(format!("plugins[{idx}].version must be semver"));
94        }
95        if plugin.description.trim().is_empty() {
96            return Err(format!("plugins[{idx}].description is required"));
97        }
98        if !(plugin.repository.starts_with("https://") || plugin.repository.starts_with("file://")) {
99            return Err(format!("plugins[{idx}].repository must be https:// or file://"));
100        }
101        if plugin.checksum.algorithm != "sha256" {
102            return Err(format!("plugins[{idx}].checksum.algorithm must be sha256"));
103        }
104        if plugin.checksum.value.len() != 64 || !plugin.checksum.value.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
105            return Err(format!("plugins[{idx}].checksum.value must be 64 lowercase hex characters"));
106        }
107        if let Some(trust) = &plugin.trust {
108            if let Some(homepage) = &trust.homepage {
109                if !homepage.starts_with("https://") {
110                    return Err(format!("plugins[{idx}].trust.homepage must be https://"));
111                }
112            }
113        }
114        for (provider_idx, provider) in plugin.capabilities.providers.iter().enumerate() {
115            if provider.id.trim().is_empty() {
116                return Err(format!("plugins[{idx}].capabilities.providers[{provider_idx}].id is required"));
117            }
118            if provider.id.contains(':') {
119                return Err(format!("plugins[{idx}].capabilities.providers[{provider_idx}].id must not contain ':'"));
120            }
121            for (model_idx, model) in provider.models.iter().enumerate() {
122                if model.trim().is_empty() || model.contains(':') {
123                    return Err(format!("plugins[{idx}].capabilities.providers[{provider_idx}].models[{model_idx}] must be non-empty and must not contain ':'"));
124                }
125            }
126        }
127    }
128    Ok(())
129}
130
131fn is_semver(value: &str) -> bool {
132    let mut parts = value.splitn(2, '-');
133    let core = parts.next().unwrap_or_default();
134    let nums: Vec<&str> = core.split('.').collect();
135    nums.len() == 3
136        && nums
137            .iter()
138            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn sample_index_json() -> &'static str {
146        r#"{
147          "schema_version": 1,
148          "generated_at": "2026-05-01T12:00:00Z",
149          "plugins": [{
150            "id": "session-memory",
151            "name": "session-memory",
152            "version": "0.1.0",
153            "description": "Extracts local session notes.",
154            "repository": "https://github.com/example/synaps-skills.git",
155            "subdir": "session-memory-plugin",
156            "checksum": {"algorithm": "sha256", "value": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
157            "compatibility": {"synaps": ">=0.1.0", "extension_protocol": "1"},
158            "capabilities": {
159              "skills": ["session-memory"],
160              "has_extension": true,
161              "permissions": ["session.lifecycle"],
162              "hooks": ["on_session_end"],
163              "commands": []
164            },
165            "trust": {"publisher": "Maha Media", "homepage": "https://example.com"}
166          }]
167        }"#
168    }
169
170    #[test]
171    fn parses_and_validates_v1_plugin_index() {
172        let index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
173        validate_plugin_index(&index).unwrap();
174        assert_eq!(index.plugins[0].id, "session-memory");
175        assert!(index.plugins[0].capabilities.has_extension);
176        assert_eq!(index.plugins[0].capabilities.permissions, vec!["session.lifecycle"]);
177    }
178
179    #[test]
180    fn rejects_unsupported_schema_version() {
181        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
182        index.schema_version = 2;
183        assert!(validate_plugin_index(&index).unwrap_err().contains("schema_version"));
184    }
185
186    #[test]
187    fn rejects_bad_checksum_algorithm() {
188        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
189        index.plugins[0].checksum.algorithm = "md5".into();
190        assert!(validate_plugin_index(&index).unwrap_err().contains("checksum.algorithm"));
191    }
192
193    #[test]
194    fn rejects_bad_checksum_shape() {
195        let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
196        index.plugins[0].checksum.value = "abc123".into();
197        assert!(validate_plugin_index(&index).unwrap_err().contains("checksum.value"));
198    }
199}