//! Plugin registry for managing and executing plugins. //! //! Provides high-level operations for listing, previewing, and executing imports. use std::sync::Arc; use rhai::{Dynamic, Map}; use crate::api::{dynamic_to_import_result, PluginApi, PluginApiContext}; use crate::engine::PluginEngine; use crate::error::{PluginError, Result}; use crate::loader::{LoadedPlugin, PluginLoader}; use goingson_core::{ ImportOptions, ImportParseResult, PluginMeta, PluginType, }; /// Registry for managing active plugins. pub struct PluginRegistry { loader: PluginLoader, } impl PluginRegistry { /// Creates a new plugin registry. #[tracing::instrument(skip_all)] pub fn new(plugins_dir: impl Into) -> Result { let loader = PluginLoader::new(plugins_dir)?; Ok(Self { loader }) } /// Creates a registry with a custom engine. #[tracing::instrument(skip_all)] pub fn with_engine( plugins_dir: impl Into, engine: Arc, ) -> Result { let loader = PluginLoader::with_engine(plugins_dir, engine)?; Ok(Self { loader }) } /// Returns the plugin loader. #[tracing::instrument(skip_all)] pub fn loader(&self) -> &PluginLoader { &self.loader } /// Returns a mutable reference to the plugin loader. #[tracing::instrument(skip_all)] pub fn loader_mut(&mut self) -> &mut PluginLoader { &mut self.loader } /// Initializes the registry by discovering enabled plugins. #[tracing::instrument(skip_all)] pub fn initialize(&mut self) -> Result> { self.loader.discover_enabled() } /// Lists all available import plugins. #[tracing::instrument(skip_all)] pub fn list_import_plugins(&self) -> Result> { let available = self.loader.discover_available()?; Ok(available .into_iter() .filter(|p| matches!(p.plugin_type, PluginType::Import(_))) .collect()) } /// Lists all enabled import plugins. #[tracing::instrument(skip_all)] pub fn list_enabled_import_plugins(&self) -> Vec { self.loader .loaded() .iter() .filter(|(_, p)| matches!(p.meta.plugin_type, PluginType::Import(_))) .map(|(_, p)| p.meta.clone()) .collect() } /// Gets plugins that can handle a specific file extension. #[tracing::instrument(skip_all)] pub fn get_plugins_for_extension(&self, extension: &str) -> Vec { let ext_lower = extension.to_lowercase(); self.loader .loaded() .iter() .filter_map(|(_, p)| { if let PluginType::Import(config) = &p.meta.plugin_type { if config.file_extensions.iter().any(|e| e.to_lowercase() == ext_lower) { return Some(p.meta.clone()); } } None }) .collect() } /// Previews an import by running the plugin's parse function. #[tracing::instrument(skip_all)] pub fn preview_import( &self, plugin_id: &str, file_path: &str, options: ImportOptions, projects: Vec<(String, String)>, ) -> Result { let plugin = self .loader .get_plugin(plugin_id) .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?; // Verify it's an import plugin if !matches!(plugin.meta.plugin_type, PluginType::Import(_)) { return Err(PluginError::InvalidManifest(format!( "'{}' is not an import plugin", plugin_id ))); } // Set up context let ctx = PluginApiContext { import_file_path: Some(file_path.to_string()), can_read_files: plugin.meta.capabilities.file_read, can_write_db: plugin.meta.capabilities.database_write, logs: Vec::new(), progress: None, projects, }; PluginApiContext::set(ctx); // Guard ensures context is cleared even if call_fn_2 errors struct ContextGuard; impl Drop for ContextGuard { fn drop(&mut self) { PluginApiContext::clear(); } } let _guard = ContextGuard; // Convert options to Rhai map let options_map = options_to_rhai_map(&options); // Call parse function let engine = self.loader.engine(); let result = engine.call_fn_2::( &plugin.ast, plugin_id, "parse", file_path.to_string(), options_map, )?; // Log any messages let logs = PluginApi::get_logs(); for (level, msg) in &logs { match level.as_str() { "info" => tracing::info!("[{}] {}", plugin_id, msg), "warn" => tracing::warn!("[{}] {}", plugin_id, msg), "error" => tracing::error!("[{}] {}", plugin_id, msg), _ => {} } } // Convert result dynamic_to_import_result(result, plugin_id) } /// Enables a plugin. #[tracing::instrument(skip_all)] pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<()> { self.loader.enable_plugin(plugin_id)?; // Load the plugin let available_path = self.loader.available_dir().join(plugin_id); self.loader.load_plugin(plugin_id, &available_path)?; Ok(()) } /// Disables a plugin. #[tracing::instrument(skip_all)] pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> { self.loader.disable_plugin(plugin_id) } /// Reloads a plugin from disk. #[tracing::instrument(skip_all)] pub fn reload_plugin(&mut self, plugin_id: &str) -> Result { let loaded = self.loader.reload_plugin(plugin_id)?; Ok(loaded.meta) } } // Allow access to internal loaded plugins for iteration impl PluginRegistry { /// Iterates over loaded plugins. #[tracing::instrument(skip_all)] pub fn iter_loaded(&self) -> impl Iterator { self.loader.loaded().iter() } } fn options_to_rhai_map(options: &ImportOptions) -> Map { let mut map = Map::new(); map.insert("has_header".into(), Dynamic::from(options.has_header)); if let Some(delimiter) = options.delimiter { map.insert("delimiter".into(), Dynamic::from(delimiter.to_string())); } if let Some(ref date_format) = options.date_format { map.insert("date_format".into(), Dynamic::from(date_format.clone())); } for (key, value) in &options.extra { map.insert(key.clone().into(), Dynamic::from(value.clone())); } map } #[cfg(test)] mod tests { use super::*; use std::path::Path; use crate::ImportEntityType; use tempfile::TempDir; fn create_csv_import_plugin(dir: &Path) { let plugin_dir = dir.join("available").join("csv-import"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "CSV Import" version = "1.0.0" description = "Import tasks from CSV files" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv"] entity_types = ["task"] [plugin.capabilities] file_read = true database_write = true "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "CSV Import", file_extensions: ["csv"] } } fn parse(file_path, options) { let content = goingson::read_file(file_path); let rows = goingson::parse_csv(content, options); let tasks = []; for row in rows { tasks.push(#{ description: row.description, due: row.due, priority: row.priority }); } goingson::task_result(tasks) } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); } #[test] fn test_list_import_plugins() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let registry = PluginRegistry::new(temp_dir.path()).unwrap(); let plugins = registry.list_import_plugins().unwrap(); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].name, "CSV Import"); } #[test] fn test_preview_import() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); // Create test CSV file let csv_path = temp_dir.path().join("test.csv"); std::fs::write( &csv_path, "description,due,priority\nBuy milk,2024-02-20,High\n", ) .unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); // Enable the plugin registry.enable_plugin("csv-import").unwrap(); // Preview import let result = registry .preview_import( "csv-import", csv_path.to_str().unwrap(), ImportOptions::default(), Vec::new(), ) .unwrap(); assert_eq!(result.entity_type, ImportEntityType::Task); assert!(!result.items.is_empty(), "Expected at least 1 parsed item, got {}", result.items.len()); } // --- Helpers for additional plugin types --- /// Creates a JSON import plugin that handles .json files. fn create_json_import_plugin(dir: &Path) { let plugin_dir = dir.join("available").join("json-import"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "JSON Import" version = "2.0.0" description = "Import tasks from JSON files" [plugin.type] kind = "import" [plugin.import] file_extensions = ["json"] entity_types = ["task"] [plugin.capabilities] file_read = true database_write = false "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "JSON Import", file_extensions: ["json"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); } /// Creates a command plugin (not an import plugin). fn create_command_plugin(dir: &Path) { let plugin_dir = dir.join("available").join("my-command"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "My Command" version = "1.0.0" description = "A custom command plugin" [plugin.type] kind = "command" [plugin.capabilities] "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "My Command" } } fn execute(args) { 42 } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); } // --- list_enabled_import_plugins --- #[test] fn list_enabled_import_plugins_empty_when_none_loaded() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); // Registry exists but no plugins have been enabled/loaded let registry = PluginRegistry::new(temp_dir.path()).unwrap(); let enabled = registry.list_enabled_import_plugins(); assert!(enabled.is_empty()); } #[test] fn list_enabled_import_plugins_returns_loaded_imports() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); create_json_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); registry.enable_plugin("json-import").unwrap(); let enabled = registry.list_enabled_import_plugins(); assert_eq!(enabled.len(), 2); let names: Vec<&str> = enabled.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"CSV Import")); assert!(names.contains(&"JSON Import")); } #[test] fn list_enabled_import_plugins_excludes_non_import() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); create_command_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); registry.enable_plugin("my-command").unwrap(); let enabled = registry.list_enabled_import_plugins(); assert_eq!(enabled.len(), 1); assert_eq!(enabled[0].name, "CSV Import"); } // --- get_plugins_for_extension --- #[test] fn get_plugins_for_extension_matches() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); let plugins = registry.get_plugins_for_extension("csv"); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].name, "CSV Import"); } #[test] fn get_plugins_for_extension_case_insensitive() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); let plugins = registry.get_plugins_for_extension("CSV"); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].name, "CSV Import"); } #[test] fn get_plugins_for_extension_no_match() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); let plugins = registry.get_plugins_for_extension("xlsx"); assert!(plugins.is_empty()); } #[test] fn get_plugins_for_extension_multiple_plugins_same_ext() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); // Create a second CSV plugin let plugin_dir = temp_dir.path().join("available").join("csv-import-v2"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "CSV Import V2" version = "2.0.0" description = "Another CSV importer" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv", "tsv"] entity_types = ["task"] [plugin.capabilities] file_read = true "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "CSV Import V2", file_extensions: ["csv", "tsv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); registry.enable_plugin("csv-import-v2").unwrap(); let plugins = registry.get_plugins_for_extension("csv"); assert_eq!(plugins.len(), 2); // Only the v2 plugin handles tsv let tsv_plugins = registry.get_plugins_for_extension("tsv"); assert_eq!(tsv_plugins.len(), 1); assert_eq!(tsv_plugins[0].name, "CSV Import V2"); } #[test] fn get_plugins_for_extension_ignores_non_import_plugins() { let temp_dir = TempDir::new().unwrap(); create_command_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("my-command").unwrap(); // Command plugins have no file_extensions, should never match let plugins = registry.get_plugins_for_extension("csv"); assert!(plugins.is_empty()); } // --- enable_plugin / disable_plugin --- #[test] fn enable_and_disable_plugin() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); // Enable registry.enable_plugin("csv-import").unwrap(); assert!(registry.loader().get_plugin("csv-import").is_some()); // Disable registry.disable_plugin("csv-import").unwrap(); assert!(registry.loader().get_plugin("csv-import").is_none()); } #[test] fn enable_plugin_not_found() { let temp_dir = TempDir::new().unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); let result = registry.enable_plugin("nonexistent"); assert!(result.is_err()); match result.unwrap_err() { PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"), other => panic!("Expected PluginNotFound, got {:?}", other), } } #[test] fn disable_plugin_not_loaded_is_ok() { let temp_dir = TempDir::new().unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); // Disabling a plugin that was never enabled should not error let result = registry.disable_plugin("never-existed"); assert!(result.is_ok()); } // --- reload_plugin (hot-reload) --- #[test] fn reload_plugin_picks_up_disk_changes() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Verify initial version let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone(); assert_eq!(meta.version, "1.0.0"); // Update the manifest version on disk let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml"); let updated_manifest = r#" [plugin] name = "CSV Import" version = "1.1.0" description = "Import tasks from CSV files (updated)" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv"] entity_types = ["task"] [plugin.capabilities] file_read = true database_write = true "#; std::fs::write(&manifest_path, updated_manifest).unwrap(); // Reload and verify new metadata let reloaded_meta = registry.reload_plugin("csv-import").unwrap(); assert_eq!(reloaded_meta.version, "1.1.0"); assert_eq!(reloaded_meta.description, "Import tasks from CSV files (updated)"); } #[test] fn reload_plugin_picks_up_script_changes() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Update the script on disk — add a new function signature won't break // but the AST should be different (re-compiled) let script_path = temp_dir.path().join("available/csv-import/main.rhai"); let updated_script = r#" fn describe() { #{ name: "CSV Import Updated", file_extensions: ["csv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(&script_path, updated_script).unwrap(); // Reload — should succeed with the new script let result = registry.reload_plugin("csv-import"); assert!(result.is_ok()); } #[test] fn reload_plugin_not_loaded_is_error() { let temp_dir = TempDir::new().unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); let result = registry.reload_plugin("nonexistent"); assert!(result.is_err()); match result.unwrap_err() { PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"), other => panic!("Expected PluginNotFound, got {:?}", other), } } #[test] fn reload_plugin_with_broken_script_is_error() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Break the script on disk let script_path = temp_dir.path().join("available/csv-import/main.rhai"); std::fs::write(&script_path, "fn broken( { }").unwrap(); let result = registry.reload_plugin("csv-import"); assert!(result.is_err()); } #[test] fn reload_plugin_with_missing_required_fn_is_error() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Replace script with one missing the required parse() function let script_path = temp_dir.path().join("available/csv-import/main.rhai"); let script_no_parse = r#" fn describe() { #{ name: "Broken", file_extensions: ["csv"] } } "#; std::fs::write(&script_path, script_no_parse).unwrap(); let result = registry.reload_plugin("csv-import"); assert!(result.is_err()); match result.unwrap_err() { PluginError::MissingFunction { plugin, function } => { assert_eq!(plugin, "csv-import"); assert_eq!(function, "parse"); } other => panic!("Expected MissingFunction, got {:?}", other), } } // --- preview_import error paths --- #[test] fn preview_import_plugin_not_found() { let temp_dir = TempDir::new().unwrap(); let registry = PluginRegistry::new(temp_dir.path()).unwrap(); let result = registry.preview_import( "nonexistent", "/tmp/test.csv", ImportOptions::default(), Vec::new(), ); assert!(result.is_err()); match result.unwrap_err() { PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"), other => panic!("Expected PluginNotFound, got {:?}", other), } } #[test] fn preview_import_rejects_non_import_plugin() { let temp_dir = TempDir::new().unwrap(); create_command_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("my-command").unwrap(); let result = registry.preview_import( "my-command", "/tmp/test.csv", ImportOptions::default(), Vec::new(), ); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!(msg.contains("not an import plugin"), "Unexpected message: {}", msg); } other => panic!("Expected InvalidManifest, got {:?}", other), } } // --- iter_loaded --- #[test] fn iter_loaded_empty_initially() { let temp_dir = TempDir::new().unwrap(); let registry = PluginRegistry::new(temp_dir.path()).unwrap(); assert_eq!(registry.iter_loaded().count(), 0); } #[test] fn iter_loaded_reflects_enabled_plugins() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); create_json_import_plugin(temp_dir.path()); create_command_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); registry.enable_plugin("json-import").unwrap(); registry.enable_plugin("my-command").unwrap(); let loaded: Vec<_> = registry.iter_loaded().collect(); assert_eq!(loaded.len(), 3); let ids: Vec<&str> = loaded.iter().map(|(id, _)| id.as_str()).collect(); assert!(ids.contains(&"csv-import")); assert!(ids.contains(&"json-import")); assert!(ids.contains(&"my-command")); } #[test] fn iter_loaded_shrinks_after_disable() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); create_json_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); registry.enable_plugin("json-import").unwrap(); assert_eq!(registry.iter_loaded().count(), 2); registry.disable_plugin("csv-import").unwrap(); assert_eq!(registry.iter_loaded().count(), 1); let remaining: Vec<_> = registry.iter_loaded().collect(); assert_eq!(remaining[0].0, "json-import"); } // --- initialize --- #[test] fn initialize_discovers_enabled_plugins() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); // Manually create symlink to simulate a previously-enabled plugin let available = temp_dir.path().join("available/csv-import"); let enabled = temp_dir.path().join("enabled/csv-import"); std::fs::create_dir_all(temp_dir.path().join("enabled")).unwrap(); #[cfg(unix)] std::os::unix::fs::symlink(&available, &enabled).unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); let plugins = registry.initialize().unwrap(); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].name, "CSV Import"); } #[test] fn initialize_empty_when_no_enabled() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); // Plugin exists in available but not enabled let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); let plugins = registry.initialize().unwrap(); assert!(plugins.is_empty()); } // --- options_to_rhai_map --- #[test] fn options_to_rhai_map_defaults() { let options = ImportOptions::default(); let map = options_to_rhai_map(&options); // ImportOptions derives Default, so has_header is false (the // serde default_true function only applies during deserialization). assert!(!map.get("has_header").unwrap().as_bool().unwrap()); assert!(!map.contains_key("delimiter")); assert!(!map.contains_key("date_format")); } #[test] fn options_to_rhai_map_all_fields() { let mut extra = std::collections::HashMap::new(); extra.insert("encoding".to_string(), "utf-8".to_string()); extra.insert("skip_rows".to_string(), "2".to_string()); let options = ImportOptions { has_header: false, delimiter: Some('\t'), date_format: Some("%d/%m/%Y".to_string()), extra, }; let map = options_to_rhai_map(&options); assert!(!map.get("has_header").unwrap().as_bool().unwrap()); assert_eq!( map.get("delimiter").unwrap().clone().into_string().unwrap(), "\t" ); assert_eq!( map.get("date_format").unwrap().clone().into_string().unwrap(), "%d/%m/%Y" ); assert_eq!( map.get("encoding").unwrap().clone().into_string().unwrap(), "utf-8" ); assert_eq!( map.get("skip_rows").unwrap().clone().into_string().unwrap(), "2" ); } #[test] fn options_to_rhai_map_empty_extra() { let options = ImportOptions { has_header: true, delimiter: None, date_format: None, extra: std::collections::HashMap::new(), }; let map = options_to_rhai_map(&options); // Only has_header should be present assert_eq!(map.len(), 1); assert!(map.contains_key("has_header")); } // ============ User Plugin Overrides Bundled ============ /// Simulates the case where a user installs a plugin with the same ID as /// a previously-loaded one (e.g. a bundled default). Loading the same ID /// twice should allow reload to replace the original. #[test] fn user_plugin_overrides_same_id_via_reload() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Verify the original let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone(); assert_eq!(meta.version, "1.0.0"); assert_eq!(meta.description, "Import tasks from CSV files"); // User "updates" the same plugin on disk (new version, new description) let plugin_dir = temp_dir.path().join("available").join("csv-import"); let user_manifest = r#" [plugin] name = "CSV Import" version = "2.0.0" description = "User-customized CSV import" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv", "tsv"] entity_types = ["task"] [plugin.capabilities] file_read = true database_write = true "#; std::fs::write(plugin_dir.join("plugin.toml"), user_manifest).unwrap(); let user_script = r#" fn describe() { #{ name: "CSV Import (User)", file_extensions: ["csv", "tsv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), user_script).unwrap(); // Reload to pick up user version let reloaded = registry.reload_plugin("csv-import").unwrap(); assert_eq!(reloaded.version, "2.0.0"); assert_eq!(reloaded.description, "User-customized CSV import"); // The updated plugin should handle tsv now let tsv_plugins = registry.get_plugins_for_extension("tsv"); assert_eq!(tsv_plugins.len(), 1); assert_eq!(tsv_plugins[0].version, "2.0.0"); } // ============ Reload While Running (Sequential Safety) ============ /// Verifies that after a reload, the old AST is no longer returned by /// the loader, and the new AST produces different results. #[test] fn reload_replaces_ast_atomically() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Capture the Arc from the first load let old_ast = { let plugin = registry.loader().get_plugin("csv-import").unwrap(); plugin.ast.clone() }; // Modify the script to return a different describe name let plugin_dir = temp_dir.path().join("available").join("csv-import"); let new_script = r#" fn describe() { #{ name: "CSV Import V2", file_extensions: ["csv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), new_script).unwrap(); registry.reload_plugin("csv-import").unwrap(); // The registry now holds a different AST let new_ast = { let plugin = registry.loader().get_plugin("csv-import").unwrap(); plugin.ast.clone() }; // Verify new AST produces different output let engine = registry.loader().engine(); let old_desc: Dynamic = engine.call_fn(&old_ast, "csv-import", "describe").unwrap(); let new_desc: Dynamic = engine.call_fn(&new_ast, "csv-import", "describe").unwrap(); let old_name = old_desc .try_cast::() .unwrap() .get("name") .unwrap() .clone() .into_string() .unwrap(); let new_name = new_desc .try_cast::() .unwrap() .get("name") .unwrap() .clone() .into_string() .unwrap(); assert_eq!(old_name, "CSV Import"); assert_eq!(new_name, "CSV Import V2"); } // ============ Corrupt Manifest After Initial Load ============ #[test] fn reload_with_corrupt_manifest_returns_error() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); // Corrupt the manifest on disk let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml"); std::fs::write(&manifest_path, "{{{{ not valid TOML !@#$").unwrap(); let result = registry.reload_plugin("csv-import"); assert!(result.is_err()); match result.unwrap_err() { PluginError::InvalidManifest(msg) => { assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg); } other => panic!("Expected InvalidManifest, got {:?}", other), } } // ============ Permission Escalation ============ /// A plugin that initially has no capabilities should not gain them on /// Reload rejects capability escalation (false → true). #[test] fn reload_rejects_capability_escalation() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("available").join("sneaky"); std::fs::create_dir_all(&plugin_dir).unwrap(); let safe_manifest = r#" [plugin] name = "Sneaky Plugin" version = "1.0.0" description = "Starts safe" [plugin.type] kind = "command" [plugin.capabilities] file_read = false database_write = false network = false "#; std::fs::write(plugin_dir.join("plugin.toml"), safe_manifest).unwrap(); let script = r#" fn describe() { #{ name: "Sneaky" } } fn execute(args) { 42 } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("sneaky").unwrap(); assert!(!registry.loader().get_plugin("sneaky").unwrap().meta.capabilities.file_read); // Attacker modifies manifest to escalate permissions let escalated_manifest = r#" [plugin] name = "Sneaky Plugin" version = "1.0.1" description = "Now wants everything" [plugin.type] kind = "command" [plugin.capabilities] file_read = true database_write = true network = true "#; std::fs::write(plugin_dir.join("plugin.toml"), escalated_manifest).unwrap(); // Reload must reject the escalation let err = registry.reload_plugin("sneaky").unwrap_err(); match err { PluginError::CapabilityEscalation { plugin, details } => { assert_eq!(plugin, "sneaky"); assert!(details.contains("file_read")); assert!(details.contains("database_write")); assert!(details.contains("network")); } other => panic!("Expected CapabilityEscalation, got {:?}", other), } // Plugin should be evicted from cache after escalation rejection assert!(registry.loader().get_plugin("sneaky").is_none()); } /// Reload allows de-escalation (removing capabilities). #[test] fn reload_allows_deescalation() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("available").join("generous"); std::fs::create_dir_all(&plugin_dir).unwrap(); let full_manifest = r#" [plugin] name = "Generous" version = "1.0.0" description = "Has all capabilities" [plugin.type] kind = "command" [plugin.capabilities] file_read = true database_write = true network = true "#; std::fs::write(plugin_dir.join("plugin.toml"), full_manifest).unwrap(); std::fs::write(plugin_dir.join("main.rhai"), "fn describe() { #{} }\nfn execute(a) { 0 }").unwrap(); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("generous").unwrap(); // De-escalate: remove all capabilities let reduced_manifest = r#" [plugin] name = "Generous" version = "1.1.0" description = "Reduced capabilities" [plugin.type] kind = "command" [plugin.capabilities] file_read = false database_write = false network = false "#; std::fs::write(plugin_dir.join("plugin.toml"), reduced_manifest).unwrap(); let reloaded = registry.reload_plugin("generous").unwrap(); assert!(!reloaded.capabilities.file_read); assert!(!reloaded.capabilities.database_write); assert!(!reloaded.capabilities.network); } /// Reload succeeds when capabilities are unchanged. #[test] fn reload_succeeds_unchanged_capabilities() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); registry.enable_plugin("csv-import").unwrap(); let original_caps = registry.loader().get_plugin("csv-import").unwrap().meta.capabilities.clone(); let reloaded = registry.reload_plugin("csv-import").unwrap(); assert_eq!(reloaded.capabilities, original_caps); } // --- with_engine constructor --- #[test] fn with_engine_uses_custom_engine() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let custom_engine = Arc::new(PluginEngine::new()); let registry = PluginRegistry::with_engine(temp_dir.path(), custom_engine).unwrap(); let plugins = registry.list_import_plugins().unwrap(); assert_eq!(plugins.len(), 1); } // --- list_import_plugins filtering --- #[test] fn list_import_plugins_excludes_non_import() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); create_command_plugin(temp_dir.path()); let registry = PluginRegistry::new(temp_dir.path()).unwrap(); let import_plugins = registry.list_import_plugins().unwrap(); assert_eq!(import_plugins.len(), 1); assert_eq!(import_plugins[0].name, "CSV Import"); } #[test] fn list_import_plugins_empty_dir() { let temp_dir = TempDir::new().unwrap(); let registry = PluginRegistry::new(temp_dir.path()).unwrap(); let plugins = registry.list_import_plugins().unwrap(); assert!(plugins.is_empty()); } // --- loader accessors --- #[test] fn loader_mut_allows_direct_manipulation() { let temp_dir = TempDir::new().unwrap(); create_csv_import_plugin(temp_dir.path()); let mut registry = PluginRegistry::new(temp_dir.path()).unwrap(); // Load directly through the mutable loader let available = temp_dir.path().join("available/csv-import"); let loaded = registry.loader_mut().load_plugin("csv-import", &available).unwrap(); assert_eq!(loaded.meta.name, "CSV Import"); // Verify it shows up via the registry assert_eq!(registry.iter_loaded().count(), 1); } }