//! Plugin manifest parsing. //! //! Parses plugin.toml manifests into structured types. use serde::Deserialize; use std::path::Path; use crate::error::{PluginError, Result}; use goingson_core::{ ExportPluginConfig, ImportPluginConfig, PluginCapabilities, PluginMeta, PluginType, }; /// Raw manifest as parsed from TOML. #[derive(Debug, Deserialize)] pub struct PluginManifest { pub plugin: PluginSection, } #[derive(Debug, Deserialize)] pub struct PluginSection { pub name: String, pub version: String, pub description: String, #[serde(default)] pub author: String, pub min_app_version: Option, #[serde(rename = "type")] pub plugin_type: PluginTypeSection, #[serde(default)] pub import: Option, #[serde(default)] pub export: Option, #[serde(default)] pub capabilities: CapabilitiesSection, } #[derive(Debug, Deserialize)] pub struct PluginTypeSection { pub kind: String, } #[derive(Debug, Deserialize, Default)] pub struct ImportSection { #[serde(default)] pub file_extensions: Vec, #[serde(default)] pub entity_types: Vec, } #[derive(Debug, Deserialize, Default)] pub struct ExportSection { #[serde(default)] pub file_extension: String, #[serde(default)] pub entity_types: Vec, } #[derive(Debug, Deserialize, Default)] pub struct CapabilitiesSection { #[serde(default)] pub file_read: bool, #[serde(default)] pub database_write: bool, #[serde(default)] pub network: bool, } impl PluginManifest { /// Parses a plugin manifest from a TOML file. #[tracing::instrument(skip_all)] pub fn from_file(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| { PluginError::FileError(format!("Failed to read {}: {}", path.display(), e)) })?; Self::parse(&content) } /// Parses a plugin manifest from a TOML string. #[tracing::instrument(skip_all)] pub fn parse(content: &str) -> Result { toml::from_str(content) .map_err(|e| PluginError::InvalidManifest(format!("TOML parse error: {}", e))) } /// Converts the raw manifest to a PluginMeta with the given ID. #[tracing::instrument(skip_all)] pub fn to_meta(&self, id: String) -> Result { let plugin_type = self.parse_plugin_type()?; Ok(PluginMeta { id, name: self.plugin.name.clone(), version: self.plugin.version.clone(), description: self.plugin.description.clone(), author: self.plugin.author.clone(), min_app_version: self.plugin.min_app_version.clone(), plugin_type, capabilities: PluginCapabilities { file_read: self.plugin.capabilities.file_read, database_write: self.plugin.capabilities.database_write, network: self.plugin.capabilities.network, }, }) } fn parse_plugin_type(&self) -> Result { match self.plugin.plugin_type.kind.as_str() { "import" => { let import = self.plugin.import.as_ref().ok_or_else(|| { PluginError::InvalidManifest( "Import plugin requires [plugin.import] section".to_string(), ) })?; Ok(PluginType::Import(ImportPluginConfig { file_extensions: import.file_extensions.clone(), entity_types: import.entity_types.clone(), })) } "export" => { let export = self.plugin.export.as_ref().ok_or_else(|| { PluginError::InvalidManifest( "Export plugin requires [plugin.export] section".to_string(), ) })?; Ok(PluginType::Export(ExportPluginConfig { file_extension: export.file_extension.clone(), entity_types: export.entity_types.clone(), })) } "command" => Ok(PluginType::Command), "hook" => Ok(PluginType::Hook), other => Err(PluginError::InvalidManifest(format!( "Unknown plugin kind: {}", other ))), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_import_manifest() { let toml = r#" [plugin] name = "csv-import" version = "1.0.0" description = "Import tasks from CSV files" author = "Community" min_app_version = "0.2.0" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv", "tsv"] entity_types = ["task", "project"] [plugin.capabilities] file_read = true database_write = true network = false "#; let manifest = PluginManifest::parse(toml).unwrap(); let meta = manifest.to_meta("csv-import".to_string()).unwrap(); assert_eq!(meta.name, "csv-import"); assert_eq!(meta.version, "1.0.0"); assert!(matches!(meta.plugin_type, PluginType::Import(_))); if let PluginType::Import(config) = &meta.plugin_type { assert_eq!(config.file_extensions, vec!["csv", "tsv"]); assert_eq!(config.entity_types, vec!["task", "project"]); } assert!(meta.capabilities.file_read); assert!(meta.capabilities.database_write); assert!(!meta.capabilities.network); } #[test] fn test_parse_export_manifest() { let toml = r#" [plugin] name = "json-export" version = "1.0.0" description = "Export tasks to JSON" author = "Community" [plugin.type] kind = "export" [plugin.export] file_extension = "json" entity_types = ["task"] [plugin.capabilities] file_read = false database_write = false "#; let manifest = PluginManifest::parse(toml).unwrap(); let meta = manifest.to_meta("json-export".to_string()).unwrap(); assert!(matches!(meta.plugin_type, PluginType::Export(_))); } #[test] fn test_missing_import_section() { let toml = r#" [plugin] name = "bad-plugin" version = "1.0.0" description = "Missing import section" [plugin.type] kind = "import" [plugin.capabilities] "#; let manifest = PluginManifest::parse(toml).unwrap(); let result = manifest.to_meta("bad-plugin".to_string()); assert!(result.is_err()); } // ============ Corrupt / Invalid Manifest ============ #[test] fn corrupt_toml_returns_error_not_panic() { let garbage = "{{{{ not valid toml at all !@#$"; let result = PluginManifest::parse(garbage); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg); } other => panic!("Expected InvalidManifest, got {:?}", other), } } #[test] fn empty_string_manifest_returns_error() { let result = PluginManifest::parse(""); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(_) => {} other => panic!("Expected InvalidManifest, got {:?}", other), } } #[test] fn manifest_missing_required_fields_returns_error() { // Valid TOML but missing required plugin fields (name, version, description) let toml = r#" [plugin] name = "incomplete" "#; let result = PluginManifest::parse(toml); assert!(result.is_err()); } #[test] fn manifest_with_truncated_toml_returns_error() { // Simulates a file that was partially written (e.g. crash mid-update) let truncated = r#" [plugin] name = "half-written" version = "1.0.0" description = "Truncated during write" [plugin.type] kind = "import" [plugin.import "#; let result = PluginManifest::parse(truncated); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg); } other => panic!("Expected InvalidManifest, got {:?}", other), } } // ============ Unsupported Plugin Kind ============ #[test] fn unsupported_plugin_kind_returns_error() { let toml = r#" [plugin] name = "bad-kind" version = "1.0.0" description = "Uses a kind that does not exist" [plugin.type] kind = "transformer" [plugin.capabilities] "#; let manifest = PluginManifest::parse(toml).unwrap(); let result = manifest.to_meta("bad-kind".to_string()); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!( msg.contains("Unknown plugin kind: transformer"), "Unexpected message: {}", msg ); } other => panic!("Expected InvalidManifest, got {:?}", other), } } #[test] fn empty_plugin_kind_returns_error() { let toml = r#" [plugin] name = "empty-kind" version = "1.0.0" description = "Kind field is empty" [plugin.type] kind = "" [plugin.capabilities] "#; let manifest = PluginManifest::parse(toml).unwrap(); let result = manifest.to_meta("empty-kind".to_string()); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!(msg.contains("Unknown plugin kind"), "Unexpected message: {}", msg); } other => panic!("Expected InvalidManifest, got {:?}", other), } } }