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!("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}