use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Missing required field: {0}")]
MissingField(&'static str),
#[error("Invalid API version: {0}")]
InvalidApiVersion(String),
#[error("Unsupported capability: {0}")]
UnsupportedCapability(String),
#[error("Missing required module: {0}")]
MissingModule(String),
#[error("Manifest validation failed: {0}")]
ValidationFailed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(rename = "api-version")]
pub api_version: String,
#[serde(rename = "min-scarab-version")]
pub min_scarab_version: String,
#[serde(default)]
pub capabilities: HashSet<Capability>,
#[serde(default, rename = "required-modules")]
pub required_modules: HashSet<FusabiModule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emoji: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub catchphrase: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Capability {
OutputFiltering,
InputFiltering,
ShellExecution,
FileSystem,
Network,
Clipboard,
ProcessSpawn,
TerminalControl,
UiOverlay,
MenuRegistration,
CommandRegistration,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FusabiModule {
Terminal,
Gpu,
Fs,
Net,
Process,
Text,
Config,
}
impl PluginManifest {
pub fn validate(&self, current_api_version: &str) -> Result<(), ManifestError> {
use semver::Version;
let plugin_version = Version::parse(&self.api_version)
.map_err(|_| ManifestError::InvalidApiVersion(self.api_version.clone()))?;
let current_version = Version::parse(current_api_version)
.map_err(|_| ManifestError::InvalidApiVersion(current_api_version.to_string()))?;
if plugin_version.major != current_version.major {
return Err(ManifestError::ValidationFailed(format!(
"API major version mismatch: plugin requires {}, current is {}",
plugin_version.major, current_version.major
)));
}
if plugin_version.minor > current_version.minor {
return Err(ManifestError::ValidationFailed(format!(
"Plugin requires API version {}.{}, but current is {}.{}",
plugin_version.major,
plugin_version.minor,
current_version.major,
current_version.minor
)));
}
Ok(())
}
pub fn has_capability(&self, capability: &Capability) -> bool {
self.capabilities.contains(capability)
}
pub fn requires_module(&self, module: &FusabiModule) -> bool {
self.required_modules.contains(module)
}
pub fn capabilities_list(&self) -> Vec<Capability> {
let mut caps: Vec<_> = self.capabilities.iter().cloned().collect();
caps.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
caps
}
pub fn modules_list(&self) -> Vec<FusabiModule> {
let mut mods: Vec<_> = self.required_modules.iter().cloned().collect();
mods.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
mods
}
}
impl Default for PluginManifest {
fn default() -> Self {
Self {
name: String::new(),
version: "0.1.0".to_string(),
description: String::new(),
author: String::new(),
homepage: None,
api_version: crate::API_VERSION.to_string(),
min_scarab_version: "0.1.0".to_string(),
capabilities: HashSet::new(),
required_modules: HashSet::new(),
emoji: None,
color: None,
catchphrase: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_validation_compatible() {
let manifest = PluginManifest {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
description: "Test".to_string(),
author: "Test Author".to_string(),
homepage: None,
api_version: "0.1.0".to_string(),
min_scarab_version: "0.1.0".to_string(),
capabilities: HashSet::new(),
required_modules: HashSet::new(),
emoji: None,
color: None,
catchphrase: None,
};
assert!(manifest.validate("0.1.0").is_ok());
assert!(manifest.validate("0.2.0").is_ok());
}
#[test]
fn test_manifest_validation_incompatible() {
let manifest = PluginManifest {
api_version: "1.0.0".to_string(),
..Default::default()
};
assert!(manifest.validate("0.1.0").is_err());
}
#[test]
fn test_capability_checking() {
let mut manifest = PluginManifest::default();
manifest.capabilities.insert(Capability::OutputFiltering);
manifest.capabilities.insert(Capability::FileSystem);
assert!(manifest.has_capability(&Capability::OutputFiltering));
assert!(manifest.has_capability(&Capability::FileSystem));
assert!(!manifest.has_capability(&Capability::Network));
}
#[test]
fn test_module_requirements() {
let mut manifest = PluginManifest::default();
manifest.required_modules.insert(FusabiModule::Terminal);
manifest.required_modules.insert(FusabiModule::Fs);
assert!(manifest.requires_module(&FusabiModule::Terminal));
assert!(manifest.requires_module(&FusabiModule::Fs));
assert!(!manifest.requires_module(&FusabiModule::Net));
}
#[test]
fn test_toml_serialization() {
let mut manifest = PluginManifest {
name: "example-plugin".to_string(),
version: "1.0.0".to_string(),
description: "An example plugin".to_string(),
author: "Example Author".to_string(),
homepage: Some("https://example.com".to_string()),
api_version: "0.1.0".to_string(),
min_scarab_version: "0.1.0".to_string(),
capabilities: HashSet::new(),
required_modules: HashSet::new(),
emoji: Some("🔌".to_string()),
color: Some("#FF5733".to_string()),
catchphrase: Some("Power to the plugins!".to_string()),
};
manifest.capabilities.insert(Capability::OutputFiltering);
manifest.capabilities.insert(Capability::UiOverlay);
manifest.required_modules.insert(FusabiModule::Terminal);
let toml = toml::to_string_pretty(&manifest).unwrap();
let deserialized: PluginManifest = toml::from_str(&toml).unwrap();
assert_eq!(manifest.name, deserialized.name);
assert_eq!(manifest.version, deserialized.version);
assert_eq!(manifest.capabilities, deserialized.capabilities);
assert_eq!(manifest.required_modules, deserialized.required_modules);
}
}