1use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
12pub enum ManifestError {
13 #[error("Missing required field: {0}")]
14 MissingField(&'static str),
15
16 #[error("Invalid API version: {0}")]
17 InvalidApiVersion(String),
18
19 #[error("Unsupported capability: {0}")]
20 UnsupportedCapability(String),
21
22 #[error("Missing required module: {0}")]
23 MissingModule(String),
24
25 #[error("Manifest validation failed: {0}")]
26 ValidationFailed(String),
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PluginManifest {
32 pub name: String,
34
35 pub version: String,
37
38 pub description: String,
40
41 pub author: String,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub homepage: Option<String>,
47
48 #[serde(rename = "api-version")]
50 pub api_version: String,
51
52 #[serde(rename = "min-scarab-version")]
54 pub min_scarab_version: String,
55
56 #[serde(default)]
58 pub capabilities: HashSet<Capability>,
59
60 #[serde(default, rename = "required-modules")]
62 pub required_modules: HashSet<FusabiModule>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub emoji: Option<String>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub color: Option<String>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub catchphrase: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum Capability {
79 OutputFiltering,
81
82 InputFiltering,
84
85 ShellExecution,
87
88 FileSystem,
90
91 Network,
93
94 Clipboard,
96
97 ProcessSpawn,
99
100 TerminalControl,
102
103 UiOverlay,
105
106 MenuRegistration,
108
109 CommandRegistration,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum FusabiModule {
117 Terminal,
119
120 Gpu,
122
123 Fs,
125
126 Net,
128
129 Process,
131
132 Text,
134
135 Config,
137}
138
139impl PluginManifest {
140 pub fn validate(&self, current_api_version: &str) -> Result<(), ManifestError> {
142 use semver::Version;
144
145 let plugin_version = Version::parse(&self.api_version)
146 .map_err(|_| ManifestError::InvalidApiVersion(self.api_version.clone()))?;
147
148 let current_version = Version::parse(current_api_version)
149 .map_err(|_| ManifestError::InvalidApiVersion(current_api_version.to_string()))?;
150
151 if plugin_version.major != current_version.major {
153 return Err(ManifestError::ValidationFailed(format!(
154 "API major version mismatch: plugin requires {}, current is {}",
155 plugin_version.major, current_version.major
156 )));
157 }
158
159 if plugin_version.minor > current_version.minor {
161 return Err(ManifestError::ValidationFailed(format!(
162 "Plugin requires API version {}.{}, but current is {}.{}",
163 plugin_version.major,
164 plugin_version.minor,
165 current_version.major,
166 current_version.minor
167 )));
168 }
169
170 Ok(())
171 }
172
173 pub fn has_capability(&self, capability: &Capability) -> bool {
175 self.capabilities.contains(capability)
176 }
177
178 pub fn requires_module(&self, module: &FusabiModule) -> bool {
180 self.required_modules.contains(module)
181 }
182
183 pub fn capabilities_list(&self) -> Vec<Capability> {
185 let mut caps: Vec<_> = self.capabilities.iter().cloned().collect();
186 caps.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
187 caps
188 }
189
190 pub fn modules_list(&self) -> Vec<FusabiModule> {
192 let mut mods: Vec<_> = self.required_modules.iter().cloned().collect();
193 mods.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
194 mods
195 }
196}
197
198impl Default for PluginManifest {
199 fn default() -> Self {
200 Self {
201 name: String::new(),
202 version: "0.1.0".to_string(),
203 description: String::new(),
204 author: String::new(),
205 homepage: None,
206 api_version: crate::API_VERSION.to_string(),
207 min_scarab_version: "0.1.0".to_string(),
208 capabilities: HashSet::new(),
209 required_modules: HashSet::new(),
210 emoji: None,
211 color: None,
212 catchphrase: None,
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_manifest_validation_compatible() {
223 let manifest = PluginManifest {
224 name: "test-plugin".to_string(),
225 version: "1.0.0".to_string(),
226 description: "Test".to_string(),
227 author: "Test Author".to_string(),
228 homepage: None,
229 api_version: "0.1.0".to_string(),
230 min_scarab_version: "0.1.0".to_string(),
231 capabilities: HashSet::new(),
232 required_modules: HashSet::new(),
233 emoji: None,
234 color: None,
235 catchphrase: None,
236 };
237
238 assert!(manifest.validate("0.1.0").is_ok());
239 assert!(manifest.validate("0.2.0").is_ok());
240 }
241
242 #[test]
243 fn test_manifest_validation_incompatible() {
244 let manifest = PluginManifest {
245 api_version: "1.0.0".to_string(),
246 ..Default::default()
247 };
248
249 assert!(manifest.validate("0.1.0").is_err());
250 }
251
252 #[test]
253 fn test_capability_checking() {
254 let mut manifest = PluginManifest::default();
255 manifest.capabilities.insert(Capability::OutputFiltering);
256 manifest.capabilities.insert(Capability::FileSystem);
257
258 assert!(manifest.has_capability(&Capability::OutputFiltering));
259 assert!(manifest.has_capability(&Capability::FileSystem));
260 assert!(!manifest.has_capability(&Capability::Network));
261 }
262
263 #[test]
264 fn test_module_requirements() {
265 let mut manifest = PluginManifest::default();
266 manifest.required_modules.insert(FusabiModule::Terminal);
267 manifest.required_modules.insert(FusabiModule::Fs);
268
269 assert!(manifest.requires_module(&FusabiModule::Terminal));
270 assert!(manifest.requires_module(&FusabiModule::Fs));
271 assert!(!manifest.requires_module(&FusabiModule::Net));
272 }
273
274 #[test]
275 fn test_toml_serialization() {
276 let mut manifest = PluginManifest {
277 name: "example-plugin".to_string(),
278 version: "1.0.0".to_string(),
279 description: "An example plugin".to_string(),
280 author: "Example Author".to_string(),
281 homepage: Some("https://example.com".to_string()),
282 api_version: "0.1.0".to_string(),
283 min_scarab_version: "0.1.0".to_string(),
284 capabilities: HashSet::new(),
285 required_modules: HashSet::new(),
286 emoji: Some("🔌".to_string()),
287 color: Some("#FF5733".to_string()),
288 catchphrase: Some("Power to the plugins!".to_string()),
289 };
290
291 manifest.capabilities.insert(Capability::OutputFiltering);
292 manifest.capabilities.insert(Capability::UiOverlay);
293 manifest.required_modules.insert(FusabiModule::Terminal);
294
295 let toml = toml::to_string_pretty(&manifest).unwrap();
296 let deserialized: PluginManifest = toml::from_str(&toml).unwrap();
297
298 assert_eq!(manifest.name, deserialized.name);
299 assert_eq!(manifest.version, deserialized.version);
300 assert_eq!(manifest.capabilities, deserialized.capabilities);
301 assert_eq!(manifest.required_modules, deserialized.required_modules);
302 }
303}