//! Plugin manifest parsing and validation use std::{collections::HashMap, path::Path}; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ Capabilities, EnvironmentCapability, FilesystemCapability, NetworkCapability, UiPage, UiWidget, }; /// Plugin manifest file format (TOML) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginManifest { pub plugin: PluginInfo, #[serde(default)] pub capabilities: ManifestCapabilities, #[serde(default)] pub config: HashMap, /// UI pages provided by this plugin #[serde(default)] pub ui: UiSection, } /// UI section of the plugin manifest #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UiSection { /// UI pages defined by this plugin #[serde(default)] pub pages: Vec, /// Widgets to inject into existing host pages #[serde(default)] pub widgets: Vec, /// API endpoint paths this plugin's UI requires. /// Each must start with `/api/`. Informational; host may check availability. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub required_endpoints: Vec, /// CSS custom property overrides provided by this plugin. /// Keys are property names (e.g. `--accent-color`), values are CSS values. /// The host applies these to `document.documentElement` on startup. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub theme_extensions: HashMap, } impl UiSection { /// Validate that all declared required endpoints start with `/api/`. /// /// # Errors /// /// Returns an error string for the first invalid endpoint found. pub fn validate(&self) -> Result<(), String> { for ep in &self.required_endpoints { if !ep.starts_with("/api/") { return Err(format!("required_endpoint must start with '/api/': {ep}")); } } Ok(()) } } /// Entry for a UI page in the manifest - can be inline or file reference #[derive(Debug, Clone)] pub enum UiPageEntry { /// Inline UI page definition (boxed to reduce enum size) Inline(Box), /// Reference to a JSON file containing the page definition File { file: String }, } impl<'de> Deserialize<'de> for UiPageEntry { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { use serde::de::Error; // First try to deserialize as a file reference (has "file" key) // We use toml::Value since the manifest is TOML let value = toml::Value::deserialize(deserializer)?; if let Some(file) = value.get("file").and_then(|v| v.as_str()) { if file.is_empty() { return Err(D::Error::custom("file path cannot be empty")); } return Ok(Self::File { file: file.to_string(), }); } // Otherwise try to deserialize as inline UiPage // Convert toml::Value back to a deserializer for UiPage let page: UiPage = UiPage::deserialize(value) .map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?; Ok(Self::Inline(Box::new(page))) } } impl Serialize for UiPageEntry { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { match self { Self::Inline(page) => page.serialize(serializer), Self::File { file } => { use serde::ser::SerializeStruct; let mut state = serializer.serialize_struct("UiPageEntry", 1)?; state.serialize_field("file", file)?; state.end() }, } } } const fn default_priority() -> u16 { 500 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginInfo { pub name: String, pub version: String, pub api_version: String, pub author: Option, pub description: Option, pub homepage: Option, pub license: Option, /// Pipeline priority (0-999). Lower values run first. Built-in handlers run /// at 100. Default: 500. #[serde(default = "default_priority")] pub priority: u16, /// Plugin kind(s) - e.g., `media_type`, `metadata_extractor` pub kind: Vec, /// Binary configuration pub binary: PluginBinary, /// Dependencies on other plugins #[serde(default)] pub dependencies: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginBinary { /// Path to WASM binary pub wasm: String, /// Optional entrypoint function name (default: "_start") pub entrypoint: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ManifestCapabilities { #[serde(default)] pub filesystem: ManifestFilesystemCapability, #[serde(default)] pub network: bool, #[serde(default)] pub allowed_domains: Option>, #[serde(default)] pub environment: Option>, #[serde(default)] pub max_memory_mb: Option, #[serde(default)] pub max_cpu_time_secs: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ManifestFilesystemCapability { #[serde(default)] pub read: Vec, #[serde(default)] pub write: Vec, } #[derive(Debug, Error)] pub enum ManifestError { #[error("Failed to read manifest file: {0}")] IoError(#[from] std::io::Error), #[error("Failed to parse manifest: {0}")] ParseError(#[from] toml::de::Error), #[error("Invalid manifest: {0}")] ValidationError(String), } impl PluginManifest { /// Load and parse a plugin manifest from a TOML file /// /// # Errors /// /// Returns [`ManifestError::IoError`] if the file cannot be read, /// [`ManifestError::ParseError`] if the TOML is invalid, or /// [`ManifestError::ValidationError`] if the manifest fails validation. pub fn from_file(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let manifest: Self = toml::from_str(&content)?; manifest.validate()?; Ok(manifest) } /// Parse a manifest from TOML string /// /// # Errors /// /// Returns [`ManifestError::ParseError`] if the TOML is invalid, or /// [`ManifestError::ValidationError`] if the manifest fails validation. pub fn parse_str(content: &str) -> Result { let manifest: Self = toml::from_str(content)?; manifest.validate()?; Ok(manifest) } /// Validate the manifest /// /// # Errors /// /// Returns [`ManifestError::ValidationError`] if any required field is empty /// or otherwise invalid. pub fn validate(&self) -> Result<(), ManifestError> { // Check API version format if self.plugin.api_version.is_empty() { return Err(ManifestError::ValidationError( "api_version cannot be empty".to_string(), )); } // Check version format (basic semver check) if !self.plugin.version.contains('.') { return Err(ManifestError::ValidationError( "version must be in semver format (e.g., 1.0.0)".to_string(), )); } // Check that at least one kind is specified if self.plugin.kind.is_empty() { return Err(ManifestError::ValidationError( "at least one plugin kind must be specified".to_string(), )); } // Check plugin kinds are valid let valid_kinds = [ "media_type", "metadata_extractor", "thumbnail_generator", "search_backend", "event_handler", "theme_provider", "ui_page", ]; for kind in &self.plugin.kind { if !valid_kinds.contains(&kind.as_str()) { return Err(ManifestError::ValidationError(format!( "Invalid plugin kind: {}. Must be one of: {}", kind, valid_kinds.join(", ") ))); } } // Check WASM binary path is not empty if self.plugin.binary.wasm.is_empty() { return Err(ManifestError::ValidationError( "WASM binary path cannot be empty".to_string(), )); } if self.plugin.priority > 999 { return Err(ManifestError::ValidationError( "priority must be 0-999".to_string(), )); } // Validate UI section (required_endpoints format); non-fatal: warn only if let Err(e) = self.ui.validate() { tracing::warn!( plugin = %self.plugin.name, error = %e, "plugin UI section has invalid required_endpoints" ); } // Validate UI pages for (idx, page_entry) in self.ui.pages.iter().enumerate() { match page_entry { UiPageEntry::Inline(page) => { if let Err(e) = page.validate() { return Err(ManifestError::ValidationError(format!( "UI page {} validation failed: {e}", idx + 1 ))); } }, UiPageEntry::File { file } => { if file.is_empty() { return Err(ManifestError::ValidationError(format!( "UI page {} file path cannot be empty", idx + 1 ))); } }, } } Ok(()) } /// Load and resolve all UI page definitions /// /// For inline pages, returns them directly. For file references, loads /// and parses the JSON file. /// /// # Arguments /// /// * `base_path` - Base directory for resolving relative file paths /// /// # Errors /// /// Returns [`ManifestError::IoError`] if a file cannot be read, or /// [`ManifestError::ValidationError`] if JSON parsing fails. pub fn load_ui_pages( &self, base_path: &Path, ) -> Result, ManifestError> { let mut pages = Vec::with_capacity(self.ui.pages.len()); for entry in &self.ui.pages { let page = match entry { UiPageEntry::Inline(page) => (**page).clone(), UiPageEntry::File { file } => { let file_path = base_path.join(file); let content = std::fs::read_to_string(&file_path)?; let page: UiPage = serde_json::from_str(&content).map_err(|e| { ManifestError::ValidationError(format!( "Failed to parse UI page from file '{}': {e}", file_path.display() )) })?; if let Err(e) = page.validate() { return Err(ManifestError::ValidationError(format!( "UI page validation failed for file '{}': {e}", file_path.display() ))); } page }, }; pages.push(page); } Ok(pages) } /// Convert manifest capabilities to API capabilities #[must_use] pub fn to_capabilities(&self) -> Capabilities { Capabilities { filesystem: FilesystemCapability { read: self .capabilities .filesystem .read .iter() .map(std::convert::Into::into) .collect(), write: self .capabilities .filesystem .write .iter() .map(std::convert::Into::into) .collect(), }, network: NetworkCapability { enabled: self.capabilities.network, allowed_domains: self.capabilities.allowed_domains.clone(), }, environment: EnvironmentCapability { enabled: self.capabilities.environment.is_some(), allowed_vars: self.capabilities.environment.clone(), }, max_memory_bytes: self .capabilities .max_memory_mb .map(|mb| mb.saturating_mul(1024).saturating_mul(1024)), max_cpu_time_ms: self .capabilities .max_cpu_time_secs .map(|secs| secs.saturating_mul(1000)), } } /// Get plugin ID (derived from name and version) #[must_use] pub fn plugin_id(&self) -> String { format!("{}@{}", self.plugin.name, self.plugin.version) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_valid_manifest() { let toml = r#" [plugin] name = "heif-support" version = "1.0.0" api_version = "1.0" author = "Test Author" description = "HEIF image support" kind = ["media_type", "metadata_extractor"] [plugin.binary] wasm = "plugin.wasm" [capabilities.filesystem] read = ["/tmp/pinakes-thumbnails"] "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.plugin.name, "heif-support"); assert_eq!(manifest.plugin.version, "1.0.0"); assert_eq!(manifest.plugin.kind.len(), 2); } #[test] fn test_invalid_api_version() { let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; assert!(PluginManifest::parse_str(toml).is_err()); } #[test] fn test_invalid_kind() { let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "1.0" kind = ["invalid_kind"] [plugin.binary] wasm = "plugin.wasm" "#; assert!(PluginManifest::parse_str(toml).is_err()); } #[test] fn test_priority_default() { let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "1.0" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.plugin.priority, 500); } #[test] fn test_priority_custom() { let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "1.0" priority = 50 kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.plugin.priority, 50); } #[test] fn test_priority_out_of_range() { let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "1.0" priority = 1000 kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; assert!(PluginManifest::parse_str(toml).is_err()); } #[test] fn test_ui_page_inline() { let toml = r#" [plugin] name = "ui-demo" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] id = "demo" title = "Demo Page" route = "/plugins/demo" icon = "star" [ui.pages.layout] type = "container" children = [] gap = 16 "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.ui.pages.len(), 1); match &manifest.ui.pages[0] { UiPageEntry::Inline(page) => { assert_eq!(page.id, "demo"); assert_eq!(page.title, "Demo Page"); assert_eq!(page.route, "/plugins/demo"); assert_eq!(page.icon, Some("star".to_string())); }, _ => panic!("Expected inline page"), } } #[test] fn test_ui_page_file_reference() { let toml = r#" [plugin] name = "ui-demo" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] file = "pages/demo.json" "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.ui.pages.len(), 1); match &manifest.ui.pages[0] { UiPageEntry::File { file } => { assert_eq!(file, "pages/demo.json"); }, _ => panic!("Expected file reference"), } } #[test] fn test_ui_page_invalid_kind() { // ui_page must be in valid_kinds list let toml = r#" [plugin] name = "test" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" "#; // Should succeed now that ui_page is in valid_kinds let manifest = PluginManifest::parse_str(toml); assert!(manifest.is_ok()); } #[test] fn test_ui_page_validation_failure() { let toml = r#" [plugin] name = "ui-demo" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] id = "" title = "Demo" route = "/plugins/demo" [ui.pages.layout] type = "container" children = [] gap = 16 "#; // Empty ID should fail validation assert!(PluginManifest::parse_str(toml).is_err()); } #[test] fn test_ui_page_empty_file() { let toml = r#" [plugin] name = "ui-demo" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] file = "" "#; // Empty file path should fail validation assert!(PluginManifest::parse_str(toml).is_err()); } #[test] fn test_ui_page_multiple_pages() { let toml = r#" [plugin] name = "ui-demo" version = "1.0.0" api_version = "1.0" kind = ["ui_page"] [plugin.binary] wasm = "plugin.wasm" [[ui.pages]] id = "page1" title = "Page 1" route = "/plugins/page1" [ui.pages.layout] type = "container" children = [] gap = 16 [[ui.pages]] id = "page2" title = "Page 2" route = "/plugins/page2" [ui.pages.layout] type = "container" children = [] gap = 16 "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.ui.pages.len(), 2); } #[test] fn test_ui_section_validate_accepts_api_paths() { let section = UiSection { pages: vec![], widgets: vec![], required_endpoints: vec![ "/api/v1/media".to_string(), "/api/plugins/my-plugin/data".to_string(), ], theme_extensions: HashMap::new(), }; assert!(section.validate().is_ok()); } #[test] fn test_ui_section_validate_rejects_non_api_path() { let section = UiSection { pages: vec![], widgets: vec![], required_endpoints: vec!["/not-api/something".to_string()], theme_extensions: HashMap::new(), }; assert!(section.validate().is_err()); } #[test] fn test_ui_section_validate_rejects_empty_sections_with_bad_path() { let section = UiSection { pages: vec![], widgets: vec![], required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()], theme_extensions: HashMap::new(), }; let err = section.validate().unwrap_err(); assert!( err.contains("no-slash"), "error should mention the bad endpoint" ); } #[test] fn test_theme_extensions_roundtrip() { let toml = r##" [plugin] name = "theme-plugin" version = "1.0.0" api_version = "1.0" kind = ["theme_provider"] [plugin.binary] wasm = "plugin.wasm" [ui.theme_extensions] "--accent-color" = "#ff6b6b" "--sidebar-width" = "280px" "##; let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!( manifest .ui .theme_extensions .get("--accent-color") .map(String::as_str), Some("#ff6b6b") ); assert_eq!( manifest .ui .theme_extensions .get("--sidebar-width") .map(String::as_str), Some("280px") ); } #[test] fn test_theme_extensions_empty_by_default() { let toml = r#" [plugin] name = "no-theme" version = "1.0.0" api_version = "1.0" kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; let manifest = PluginManifest::parse_str(toml).unwrap(); assert!(manifest.ui.theme_extensions.is_empty()); } }