Skip to main content

scarab_plugin_api/
manifest.rs

1//! Plugin manifest schema and validation
2//!
3//! This module defines the plugin manifest format that plugins must provide
4//! to declare their capabilities, dependencies, and requirements.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashSet;
8use thiserror::Error;
9
10/// Plugin manifest validation errors
11#[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/// Plugin manifest that declares plugin capabilities and requirements
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PluginManifest {
32    /// Plugin name (must be unique)
33    pub name: String,
34
35    /// Plugin version (semver)
36    pub version: String,
37
38    /// Short description
39    pub description: String,
40
41    /// Author name
42    pub author: String,
43
44    /// Homepage URL
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub homepage: Option<String>,
47
48    /// API version this plugin requires
49    #[serde(rename = "api-version")]
50    pub api_version: String,
51
52    /// Minimum Scarab version required
53    #[serde(rename = "min-scarab-version")]
54    pub min_scarab_version: String,
55
56    /// Required capabilities
57    #[serde(default)]
58    pub capabilities: HashSet<Capability>,
59
60    /// Required modules from fusabi-stdlib-ext
61    #[serde(default, rename = "required-modules")]
62    pub required_modules: HashSet<FusabiModule>,
63
64    /// Optional visual metadata
65    #[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/// Plugin capabilities that must be declared
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum Capability {
79    /// Can intercept and modify terminal output
80    OutputFiltering,
81
82    /// Can intercept and modify user input
83    InputFiltering,
84
85    /// Can execute shell commands
86    ShellExecution,
87
88    /// Can read/write files
89    FileSystem,
90
91    /// Can make network requests
92    Network,
93
94    /// Can access clipboard
95    Clipboard,
96
97    /// Can spawn processes
98    ProcessSpawn,
99
100    /// Can modify terminal state
101    TerminalControl,
102
103    /// Can draw overlays on client UI
104    UiOverlay,
105
106    /// Can register menu items
107    MenuRegistration,
108
109    /// Can register commands in command palette
110    CommandRegistration,
111}
112
113/// Fusabi stdlib modules that plugins can depend on
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum FusabiModule {
117    /// Terminal I/O operations
118    Terminal,
119
120    /// GPU rendering utilities
121    Gpu,
122
123    /// File system operations
124    Fs,
125
126    /// Network operations
127    Net,
128
129    /// Process management
130    Process,
131
132    /// Text processing utilities
133    Text,
134
135    /// JSON/TOML parsing
136    Config,
137}
138
139impl PluginManifest {
140    /// Validate the manifest against current API version and available capabilities
141    pub fn validate(&self, current_api_version: &str) -> Result<(), ManifestError> {
142        // Check API version compatibility
143        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        // Major version must match
152        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        // Plugin minor version must not exceed current
160        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    /// Check if the plugin declares a specific capability
174    pub fn has_capability(&self, capability: &Capability) -> bool {
175        self.capabilities.contains(capability)
176    }
177
178    /// Check if the plugin requires a specific module
179    pub fn requires_module(&self, module: &FusabiModule) -> bool {
180        self.required_modules.contains(module)
181    }
182
183    /// Get all required capabilities as a sorted list
184    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    /// Get all required modules as a sorted list
191    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}