1use 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!(
84 "plugin index schema_version must be 1, got {}",
85 index.schema_version
86 ));
87 }
88 for (idx, plugin) in index.plugins.iter().enumerate() {
89 if plugin.id.is_empty()
90 || !plugin
91 .id
92 .chars()
93 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
94 {
95 return Err(format!("plugins[{idx}].id must be lower-kebab-case"));
96 }
97 if plugin.name.trim().is_empty() {
98 return Err(format!("plugins[{idx}].name is required"));
99 }
100 if !is_semver(&plugin.version) {
101 return Err(format!("plugins[{idx}].version must be semver"));
102 }
103 if plugin.description.trim().is_empty() {
104 return Err(format!("plugins[{idx}].description is required"));
105 }
106 if !(plugin.repository.starts_with("https://") || plugin.repository.starts_with("file://"))
107 {
108 return Err(format!(
109 "plugins[{idx}].repository must be https:// or file://"
110 ));
111 }
112 if plugin.checksum.algorithm != "sha256" {
113 return Err(format!("plugins[{idx}].checksum.algorithm must be sha256"));
114 }
115 if plugin.checksum.value.len() != 64
116 || !plugin
117 .checksum
118 .value
119 .chars()
120 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
121 {
122 return Err(format!(
123 "plugins[{idx}].checksum.value must be 64 lowercase hex characters"
124 ));
125 }
126 if let Some(trust) = &plugin.trust {
127 if let Some(homepage) = &trust.homepage {
128 if !homepage.starts_with("https://") {
129 return Err(format!("plugins[{idx}].trust.homepage must be https://"));
130 }
131 }
132 }
133 for (provider_idx, provider) in plugin.capabilities.providers.iter().enumerate() {
134 if provider.id.trim().is_empty() {
135 return Err(format!(
136 "plugins[{idx}].capabilities.providers[{provider_idx}].id is required"
137 ));
138 }
139 if provider.id.contains(':') {
140 return Err(format!(
141 "plugins[{idx}].capabilities.providers[{provider_idx}].id must not contain ':'"
142 ));
143 }
144 for (model_idx, model) in provider.models.iter().enumerate() {
145 if model.trim().is_empty() || model.contains(':') {
146 return Err(format!("plugins[{idx}].capabilities.providers[{provider_idx}].models[{model_idx}] must be non-empty and must not contain ':'"));
147 }
148 }
149 }
150 }
151 Ok(())
152}
153
154fn is_semver(value: &str) -> bool {
155 let mut parts = value.splitn(2, '-');
156 let core = parts.next().unwrap_or_default();
157 let nums: Vec<&str> = core.split('.').collect();
158 nums.len() == 3
159 && nums
160 .iter()
161 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn sample_index_json() -> &'static str {
169 r#"{
170 "schema_version": 1,
171 "generated_at": "2026-05-01T12:00:00Z",
172 "plugins": [{
173 "id": "session-memory",
174 "name": "session-memory",
175 "version": "0.1.0",
176 "description": "Extracts local session notes.",
177 "repository": "https://github.com/example/synaps-skills.git",
178 "subdir": "session-memory-plugin",
179 "checksum": {"algorithm": "sha256", "value": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
180 "compatibility": {"synaps": ">=0.1.0", "extension_protocol": "1"},
181 "capabilities": {
182 "skills": ["session-memory"],
183 "has_extension": true,
184 "permissions": ["session.lifecycle"],
185 "hooks": ["on_session_end"],
186 "commands": []
187 },
188 "trust": {"publisher": "Maha Media", "homepage": "https://example.com"}
189 }]
190 }"#
191 }
192
193 #[test]
194 fn parses_and_validates_v1_plugin_index() {
195 let index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
196 validate_plugin_index(&index).unwrap();
197 assert_eq!(index.plugins[0].id, "session-memory");
198 assert!(index.plugins[0].capabilities.has_extension);
199 assert_eq!(
200 index.plugins[0].capabilities.permissions,
201 vec!["session.lifecycle"]
202 );
203 }
204
205 #[test]
206 fn rejects_unsupported_schema_version() {
207 let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
208 index.schema_version = 2;
209 assert!(validate_plugin_index(&index)
210 .unwrap_err()
211 .contains("schema_version"));
212 }
213
214 #[test]
215 fn rejects_bad_checksum_algorithm() {
216 let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
217 index.plugins[0].checksum.algorithm = "md5".into();
218 assert!(validate_plugin_index(&index)
219 .unwrap_err()
220 .contains("checksum.algorithm"));
221 }
222
223 #[test]
224 fn rejects_bad_checksum_shape() {
225 let mut index: PluginIndex = serde_json::from_str(sample_index_json()).unwrap();
226 index.plugins[0].checksum.value = "abc123".into();
227 assert!(validate_plugin_index(&index)
228 .unwrap_err()
229 .contains("checksum.value"));
230 }
231}