//! Plugin discovery and loading. //! //! Scans plugin directories, loads manifests, and compiles scripts. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use rhai::AST; use crate::engine::PluginEngine; use crate::error::{PluginError, Result}; use crate::manifest::PluginManifest; use goingson_core::PluginMeta; /// A loaded plugin with its compiled script. #[derive(Debug, Clone)] pub struct LoadedPlugin { /// Plugin metadata from manifest. pub meta: PluginMeta, /// Path to the plugin directory. pub path: PathBuf, /// Compiled Rhai AST. pub ast: Arc, } /// Plugin loader that discovers and loads plugins from the filesystem. pub struct PluginLoader { /// Base directory for plugins (e.g., ~/.config/goingson/plugins). plugins_dir: PathBuf, /// Shared engine for compilation. engine: Arc, /// Cache of loaded plugins. loaded: HashMap, } impl PluginLoader { /// Creates a new plugin loader for the given directory. #[tracing::instrument(skip_all)] pub fn new(plugins_dir: impl Into) -> Result { let plugins_dir = plugins_dir.into(); // Create directories if they don't exist let enabled_dir = plugins_dir.join("enabled"); let available_dir = plugins_dir.join("available"); std::fs::create_dir_all(&enabled_dir) .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?; std::fs::create_dir_all(&available_dir) .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?; Ok(Self { plugins_dir, engine: Arc::new(PluginEngine::new()), loaded: HashMap::new(), }) } /// Creates a plugin loader with a custom engine. #[tracing::instrument(skip_all)] pub fn with_engine(plugins_dir: impl Into, engine: Arc) -> Result { let plugins_dir = plugins_dir.into(); let enabled_dir = plugins_dir.join("enabled"); let available_dir = plugins_dir.join("available"); std::fs::create_dir_all(&enabled_dir) .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?; std::fs::create_dir_all(&available_dir) .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?; Ok(Self { plugins_dir, engine, loaded: HashMap::new(), }) } /// Returns the base plugins directory. #[tracing::instrument(skip_all)] pub fn plugins_dir(&self) -> &Path { &self.plugins_dir } /// Returns the enabled plugins directory. #[tracing::instrument(skip_all)] pub fn enabled_dir(&self) -> PathBuf { self.plugins_dir.join("enabled") } /// Returns the available plugins directory. #[tracing::instrument(skip_all)] pub fn available_dir(&self) -> PathBuf { self.plugins_dir.join("available") } /// Discovers all enabled plugins (symlinks in enabled/). #[tracing::instrument(skip_all)] pub fn discover_enabled(&mut self) -> Result> { let enabled_dir = self.enabled_dir(); let mut plugins = Vec::new(); if !enabled_dir.exists() { return Ok(plugins); } for entry in std::fs::read_dir(&enabled_dir) .map_err(|e| PluginError::FileError(format!("Failed to read enabled dir: {}", e)))? { let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?; let path = entry.path(); // Follow symlinks to get the actual plugin directory let plugin_path = if path.is_symlink() { let target = std::fs::read_link(&path) .map_err(|e| PluginError::FileError(format!("Failed to read symlink: {}", e)))?; // Resolve the symlink target to its canonical path. // If the target doesn't exist (dangling symlink), skip it entirely // to prevent sandbox escape via delayed target creation. let canonical = match std::fs::canonicalize(&target) { Ok(p) => p, Err(_) => { tracing::warn!( "Skipping symlink '{}': target '{}' does not exist", path.display(), target.display() ); continue; } }; let available_canonical = std::fs::canonicalize(self.available_dir()) .unwrap_or_else(|_| self.available_dir()); if !canonical.starts_with(&available_canonical) { tracing::warn!( "Skipping symlink '{}': target '{}' is outside available/", path.display(), canonical.display() ); continue; } canonical } else if path.is_dir() { path.clone() } else { continue; }; // The plugin ID is the directory name let plugin_id = path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))? .to_string(); match self.load_plugin(&plugin_id, &plugin_path) { Ok(loaded) => plugins.push(loaded.meta.clone()), Err(e) => { tracing::warn!("Failed to load plugin '{}': {}", plugin_id, e); } } } Ok(plugins) } /// Discovers all available plugins (directories in available/). #[tracing::instrument(skip_all)] pub fn discover_available(&self) -> Result> { let available_dir = self.available_dir(); let mut plugins = Vec::new(); if !available_dir.exists() { return Ok(plugins); } for entry in std::fs::read_dir(&available_dir) .map_err(|e| PluginError::FileError(format!("Failed to read available dir: {}", e)))? { let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?; let path = entry.path(); if !path.is_dir() { continue; } let plugin_id = path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))? .to_string(); let manifest_path = path.join("plugin.toml"); if !manifest_path.exists() { tracing::warn!("Plugin '{}' missing plugin.toml", plugin_id); continue; } match PluginManifest::from_file(&manifest_path) { Ok(manifest) => match manifest.to_meta(plugin_id.clone()) { Ok(meta) => plugins.push(meta), Err(e) => tracing::warn!("Invalid manifest for '{}': {}", plugin_id, e), }, Err(e) => tracing::warn!("Failed to parse manifest for '{}': {}", plugin_id, e), } } Ok(plugins) } /// Loads a plugin from a directory. #[tracing::instrument(skip_all)] pub fn load_plugin(&mut self, plugin_id: &str, plugin_path: &Path) -> Result { // Check cache first if let Some(loaded) = self.loaded.get(plugin_id) { return Ok(loaded.clone()); } // Load manifest let manifest_path = plugin_path.join("plugin.toml"); if !manifest_path.exists() { return Err(PluginError::FileError(format!( "Plugin '{}' missing plugin.toml", plugin_id ))); } let manifest = PluginManifest::from_file(&manifest_path)?; let meta = manifest.to_meta(plugin_id.to_string())?; // Load and compile script let script_path = plugin_path.join("main.rhai"); if !script_path.exists() { return Err(PluginError::FileError(format!( "Plugin '{}' missing main.rhai", plugin_id ))); } let script = std::fs::read_to_string(&script_path) .map_err(|e| PluginError::FileError(format!("Failed to read script: {}", e)))?; let ast = self.engine.compile_plugin(plugin_id, &script)?; // Validate required functions based on plugin type self.validate_plugin_functions(plugin_id, &meta, &ast)?; let loaded = LoadedPlugin { meta, path: plugin_path.to_path_buf(), ast: Arc::new(ast), }; self.loaded.insert(plugin_id.to_string(), loaded.clone()); Ok(loaded) } /// Validates that a plugin has the required functions for its type. fn validate_plugin_functions( &self, plugin_id: &str, meta: &PluginMeta, ast: &AST, ) -> Result<()> { match &meta.plugin_type { goingson_core::PluginType::Import(_) => { // Import plugins must have describe() and parse(file_path, options) if !self.engine.has_function(ast, "describe", 0) { return Err(PluginError::missing_function(plugin_id, "describe")); } if !self.engine.has_function(ast, "parse", 2) { return Err(PluginError::missing_function(plugin_id, "parse")); } } goingson_core::PluginType::Export(_) => { // Export plugins must have describe() and export(data, options) if !self.engine.has_function(ast, "describe", 0) { return Err(PluginError::missing_function(plugin_id, "describe")); } if !self.engine.has_function(ast, "export", 2) { return Err(PluginError::missing_function(plugin_id, "export")); } } goingson_core::PluginType::Command => { // Command plugins must have describe() and execute(args) if !self.engine.has_function(ast, "describe", 0) { return Err(PluginError::missing_function(plugin_id, "describe")); } if !self.engine.has_function(ast, "execute", 1) { return Err(PluginError::missing_function(plugin_id, "execute")); } } goingson_core::PluginType::Hook => { // Hook plugins must have describe() and at least one hook handler if !self.engine.has_function(ast, "describe", 0) { return Err(PluginError::missing_function(plugin_id, "describe")); } } } Ok(()) } /// Reloads a plugin from disk. /// /// Removes the cached `LoadedPlugin` first so `load_plugin` doesn't /// short-circuit to the stale AST, then re-reads and recompiles from /// the same directory path. Rejects the reload if capabilities escalated. #[tracing::instrument(skip_all)] pub fn reload_plugin(&mut self, plugin_id: &str) -> Result { let old = self .loaded .get(plugin_id) .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?; let old_caps = old.meta.capabilities.clone(); let path = old.path.clone(); self.loaded.remove(plugin_id); let new_plugin = self.load_plugin(plugin_id, &path)?; // Detect escalation: any capability that was false and is now true let new_caps = &new_plugin.meta.capabilities; let mut escalated = Vec::new(); if !old_caps.file_read && new_caps.file_read { escalated.push("file_read"); } if !old_caps.database_write && new_caps.database_write { escalated.push("database_write"); } if !old_caps.network && new_caps.network { escalated.push("network"); } if !escalated.is_empty() { self.loaded.remove(plugin_id); return Err(PluginError::capability_escalation( plugin_id, escalated.join(", "), )); } Ok(new_plugin) } /// Gets a loaded plugin by ID. #[tracing::instrument(skip_all)] pub fn get_plugin(&self, plugin_id: &str) -> Option<&LoadedPlugin> { self.loaded.get(plugin_id) } /// Returns the shared engine. #[tracing::instrument(skip_all)] pub fn engine(&self) -> &Arc { &self.engine } /// Enables a plugin by creating a symlink from `enabled/` → `available/`. /// /// Uses the `available/` + `enabled/` directory pattern (similar to /// nginx `sites-available`/`sites-enabled`): plugin code lives in /// `available//`, and enabling creates a symlink in `enabled/` /// pointing to it. This keeps a clean separation between "installed" /// and "active" plugins without copying files. #[tracing::instrument(skip_all)] pub fn enable_plugin(&self, plugin_id: &str) -> Result<()> { let available_path = self.available_dir().join(plugin_id); let enabled_path = self.enabled_dir().join(plugin_id); if !available_path.exists() { return Err(PluginError::PluginNotFound(plugin_id.to_string())); } if enabled_path.exists() { return Ok(()); } // Unix uses fs::symlink; Windows uses symlink_dir (directory junctions). #[cfg(unix)] { std::os::unix::fs::symlink(&available_path, &enabled_path).map_err(|e| { PluginError::FileError(format!("Failed to create symlink: {}", e)) })?; } #[cfg(windows)] { std::os::windows::fs::symlink_dir(&available_path, &enabled_path).map_err(|e| { PluginError::FileError(format!("Failed to create symlink: {}", e)) })?; } Ok(()) } /// Disables a plugin by removing its symlink from enabled/. #[tracing::instrument(skip_all)] pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> { let enabled_path = self.enabled_dir().join(plugin_id); if enabled_path.exists() || enabled_path.is_symlink() { std::fs::remove_file(&enabled_path) .map_err(|e| PluginError::FileError(format!("Failed to remove symlink: {}", e)))?; } // Remove from cache self.loaded.remove(plugin_id); Ok(()) } /// Checks if a plugin is enabled. #[tracing::instrument(skip_all)] pub fn is_enabled(&self, plugin_id: &str) -> bool { let enabled_path = self.enabled_dir().join(plugin_id); enabled_path.exists() || enabled_path.is_symlink() } /// Returns a reference to the loaded plugins map. #[tracing::instrument(skip_all)] pub fn loaded(&self) -> &HashMap { &self.loaded } } #[cfg(test)] mod tests { use super::*; use crate::engine::SafetyLimits; use tempfile::TempDir; fn create_test_plugin(dir: &Path, name: &str) { let plugin_dir = dir.join("available").join(name); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = format!( r#" [plugin] name = "{}" version = "1.0.0" description = "Test plugin" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv"] entity_types = ["task"] [plugin.capabilities] file_read = true "#, name ); std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "Test", file_extensions: ["csv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); } #[test] fn test_discover_available() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "test-plugin"); let loader = PluginLoader::new(temp_dir.path()).unwrap(); let plugins = loader.discover_available().unwrap(); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].id, "test-plugin"); } #[test] fn test_enable_disable_plugin() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "test-plugin"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); // Initially not enabled assert!(!loader.is_enabled("test-plugin")); // Enable loader.enable_plugin("test-plugin").unwrap(); assert!(loader.is_enabled("test-plugin")); // Discover enabled let enabled = loader.discover_enabled().unwrap(); assert_eq!(enabled.len(), 1); // Disable loader.disable_plugin("test-plugin").unwrap(); assert!(!loader.is_enabled("test-plugin")); } // ============ Discovery: Non-Plugin Files ============ #[test] fn discover_available_skips_plain_files() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "real-plugin"); // Drop non-plugin files and dirs into available/ let available = temp_dir.path().join("available"); std::fs::write(available.join("README.md"), "# Plugins directory").unwrap(); std::fs::write(available.join(".DS_Store"), "").unwrap(); std::fs::write(available.join("notes.txt"), "some notes").unwrap(); let loader = PluginLoader::new(temp_dir.path()).unwrap(); let plugins = loader.discover_available().unwrap(); // Only the real plugin directory should be discovered assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].id, "real-plugin"); } #[test] fn discover_available_skips_dirs_without_manifest() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "good-plugin"); // Create a directory that looks like a plugin but has no plugin.toml let no_manifest = temp_dir.path().join("available").join("incomplete"); std::fs::create_dir_all(&no_manifest).unwrap(); std::fs::write(no_manifest.join("main.rhai"), "fn describe() {}").unwrap(); let loader = PluginLoader::new(temp_dir.path()).unwrap(); let plugins = loader.discover_available().unwrap(); assert_eq!(plugins.len(), 1); assert_eq!(plugins[0].id, "good-plugin"); } #[test] fn discover_enabled_skips_regular_files_in_enabled_dir() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "real-plugin"); // Enable the real plugin let loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.enable_plugin("real-plugin").unwrap(); // Drop a stray file in the enabled/ directory let enabled_dir = temp_dir.path().join("enabled"); std::fs::write(enabled_dir.join("stray-file.txt"), "not a plugin").unwrap(); // Re-create loader to force fresh discovery let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); let enabled = loader.discover_enabled().unwrap(); // Only the real symlinked plugin should be found assert_eq!(enabled.len(), 1); assert_eq!(enabled[0].id, "real-plugin"); } // ============ Corrupt Manifest During Load ============ #[test] fn load_plugin_with_corrupt_manifest_returns_error() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("available").join("corrupt"); std::fs::create_dir_all(&plugin_dir).unwrap(); // Write invalid TOML as the manifest std::fs::write(plugin_dir.join("plugin.toml"), "{{{{ garbage !@#$").unwrap(); std::fs::write(plugin_dir.join("main.rhai"), "fn describe() {}").unwrap(); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); let result = loader.load_plugin("corrupt", &plugin_dir); match result { Err(PluginError::InvalidManifest(msg)) => { assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg); } Err(other) => panic!("Expected InvalidManifest, got {:?}", other), Ok(_) => panic!("Expected error, got Ok"), } } #[test] fn load_plugin_missing_script_returns_error() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("available").join("no-script"); std::fs::create_dir_all(&plugin_dir).unwrap(); // Valid manifest but no main.rhai let manifest = r#" [plugin] name = "no-script" version = "1.0.0" description = "Missing script" [plugin.type] kind = "command" [plugin.capabilities] "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); let result = loader.load_plugin("no-script", &plugin_dir); match result { Err(PluginError::FileError(msg)) => { assert!(msg.contains("missing main.rhai"), "Unexpected: {}", msg); } Err(other) => panic!("Expected FileError, got {:?}", other), Ok(_) => panic!("Expected error, got Ok"), } } // ============ Reload (Hot-Reload) Edge Cases ============ #[test] fn reload_picks_up_modified_script() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "hot-reload"); let plugin_dir = temp_dir.path().join("available").join("hot-reload"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.load_plugin("hot-reload", &plugin_dir).unwrap(); // Verify original script has the parse function let original = loader.get_plugin("hot-reload").unwrap(); assert!(loader.engine().has_function(&original.ast, "parse", 2)); // Update the script with different content (still valid) let updated_script = r#" fn describe() { #{ name: "Updated", file_extensions: ["csv"] } } fn parse(file_path, options) { goingson::task_result([#{description: "updated item"}]) } "#; std::fs::write(plugin_dir.join("main.rhai"), updated_script).unwrap(); // Reload and verify the AST is fresh (new script compiled) let reloaded = loader.reload_plugin("hot-reload").unwrap(); assert_eq!(reloaded.meta.name, "hot-reload"); // Call describe() on the reloaded AST to verify the new code runs let engine = loader.engine(); let desc: rhai::Dynamic = engine.call_fn(&reloaded.ast, "hot-reload", "describe").unwrap(); let map = desc.try_cast::().unwrap(); assert_eq!( map.get("name").unwrap().clone().into_string().unwrap(), "Updated" ); } #[test] fn reload_with_now_invalid_script_returns_error() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "break-later"); let plugin_dir = temp_dir.path().join("available").join("break-later"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.load_plugin("break-later", &plugin_dir).unwrap(); // Overwrite with a syntactically invalid script std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap(); let result = loader.reload_plugin("break-later"); assert!(result.is_err()); } #[test] fn reload_with_removed_required_function_returns_error() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "lose-fn"); let plugin_dir = temp_dir.path().join("available").join("lose-fn"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.load_plugin("lose-fn", &plugin_dir).unwrap(); // Replace script with one missing the required parse() function let no_parse = r#" fn describe() { #{ name: "Broken", file_extensions: ["csv"] } } "#; std::fs::write(plugin_dir.join("main.rhai"), no_parse).unwrap(); let result = loader.reload_plugin("lose-fn"); match result { Err(PluginError::MissingFunction { plugin, function }) => { assert_eq!(plugin, "lose-fn"); assert_eq!(function, "parse"); } Err(other) => panic!("Expected MissingFunction, got {:?}", other), Ok(_) => panic!("Expected error, got Ok"), } } #[test] fn reload_nonexistent_plugin_returns_not_found() { let temp_dir = TempDir::new().unwrap(); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); let result = loader.reload_plugin("no-such-plugin"); match result { Err(PluginError::PluginNotFound(id)) => assert_eq!(id, "no-such-plugin"), Err(other) => panic!("Expected PluginNotFound, got {:?}", other), Ok(_) => panic!("Expected error, got Ok"), } } #[test] fn reload_updates_manifest_metadata() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "version-bump"); let plugin_dir = temp_dir.path().join("available").join("version-bump"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.load_plugin("version-bump", &plugin_dir).unwrap(); let original = loader.get_plugin("version-bump").unwrap(); assert_eq!(original.meta.version, "1.0.0"); // Bump version in manifest on disk let updated_manifest = r#" [plugin] name = "version-bump" version = "2.0.0" description = "Updated description" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv"] entity_types = ["task"] [plugin.capabilities] file_read = true "#; std::fs::write(plugin_dir.join("plugin.toml"), updated_manifest).unwrap(); let reloaded = loader.reload_plugin("version-bump").unwrap(); assert_eq!(reloaded.meta.version, "2.0.0"); assert_eq!(reloaded.meta.description, "Updated description"); } // ============ Cache Bypass on Reload ============ #[test] fn load_returns_cached_but_reload_bypasses_cache() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "cache-test"); let plugin_dir = temp_dir.path().join("available").join("cache-test"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); // First load loader.load_plugin("cache-test", &plugin_dir).unwrap(); let v1 = loader.get_plugin("cache-test").unwrap().meta.version.clone(); // Update manifest on disk let updated = r#" [plugin] name = "cache-test" version = "9.9.9" description = "Test plugin" [plugin.type] kind = "import" [plugin.import] file_extensions = ["csv"] entity_types = ["task"] [plugin.capabilities] file_read = true "#; std::fs::write(plugin_dir.join("plugin.toml"), updated).unwrap(); // load_plugin should return cached (stale) version let cached = loader.load_plugin("cache-test", &plugin_dir).unwrap(); assert_eq!(cached.meta.version, v1); // reload_plugin should pick up the new version let reloaded = loader.reload_plugin("cache-test").unwrap(); assert_eq!(reloaded.meta.version, "9.9.9"); } // ============ Full Plugin Lifecycle ============ /// Exercises the full plugin lifecycle: discover -> load -> execute -> error -> recover. /// /// This test uses a hook plugin with two functions: one that succeeds and one /// that throws. It verifies: /// 1. discover_available() finds the plugin on disk /// 2. load_plugin() compiles the script and validates functions /// 3. Calling a successful function produces the expected result /// 4. Calling a throwing function returns PluginError, does not panic /// 5. After the error, the plugin is still callable (recovery) #[test] fn full_plugin_lifecycle_discover_load_execute_error_recover() { let temp_dir = TempDir::new().unwrap(); // -- set up a hook plugin on disk -- let plugin_dir = temp_dir.path().join("available").join("task-hook"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "Task Hook" version = "1.0.0" description = "Reacts to task lifecycle events" [plugin.type] kind = "hook" [plugin.capabilities] "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "Task Hook", hooks: ["on_task_created", "on_task_completed"] } } fn on_task_created(task_id) { if task_id == "" { throw "task_id must not be empty"; } "created:" + task_id } fn on_task_completed(task_id) { "completed:" + task_id } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); // -- 1. Discover -- let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); let available = loader.discover_available().unwrap(); assert_eq!(available.len(), 1); assert_eq!(available[0].id, "task-hook"); assert_eq!(available[0].name, "Task Hook"); assert!(matches!( available[0].plugin_type, goingson_core::PluginType::Hook )); // -- 2. Load -- let loaded = loader.load_plugin("task-hook", &plugin_dir).unwrap(); assert_eq!(loaded.meta.version, "1.0.0"); let engine = loader.engine(); assert!(engine.has_function(&loaded.ast, "describe", 0)); assert!(engine.has_function(&loaded.ast, "on_task_created", 1)); assert!(engine.has_function(&loaded.ast, "on_task_completed", 1)); // -- 3. Execute (success) -- let result: String = engine .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-42".to_string()) .unwrap(); assert_eq!(result, "created:t-42"); let result2: String = engine .call_fn_1(&loaded.ast, "task-hook", "on_task_completed", "t-42".to_string()) .unwrap(); assert_eq!(result2, "completed:t-42"); // -- 4. Execute (error) -- empty task_id triggers throw let err_result: crate::error::Result = engine.call_fn_1( &loaded.ast, "task-hook", "on_task_created", "".to_string(), ); assert!(err_result.is_err()); match err_result.unwrap_err() { PluginError::ScriptError { plugin, message } => { assert_eq!(plugin, "task-hook"); assert!( message.contains("task_id must not be empty"), "Unexpected error message: {}", message ); } other => panic!("Expected ScriptError, got {:?}", other), } // -- 5. Recover -- plugin is still callable after the error let recovered: String = engine .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-99".to_string()) .unwrap(); assert_eq!(recovered, "created:t-99"); } /// Verifies that a plugin hitting the operation limit errors gracefully and /// does not prevent subsequent calls to cheaper functions. #[test] fn full_lifecycle_operation_limit_and_recovery() { let temp_dir = TempDir::new().unwrap(); let plugin_dir = temp_dir.path().join("available").join("ops-hook"); std::fs::create_dir_all(&plugin_dir).unwrap(); let manifest = r#" [plugin] name = "Ops Hook" version = "1.0.0" description = "Hook with expensive and cheap paths" [plugin.type] kind = "hook" [plugin.capabilities] "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); let script = r#" fn describe() { #{ name: "Ops Hook" } } fn on_task_created(task_id) { if task_id == "spin" { let x = 0; loop { x += 1; } } "ok:" + task_id } "#; std::fs::write(plugin_dir.join("main.rhai"), script).unwrap(); // Use a tight ops limit to trigger the safety check quickly let limits = SafetyLimits { max_operations: 200, ..Default::default() }; let engine = Arc::new(PluginEngine::with_limits(limits)); let mut loader = PluginLoader::with_engine(temp_dir.path(), engine).unwrap(); // Discover + load let available = loader.discover_available().unwrap(); assert_eq!(available.len(), 1); let loaded = loader.load_plugin("ops-hook", &plugin_dir).unwrap(); let engine = loader.engine(); // Trigger ops limit let err: crate::error::Result = engine.call_fn_1( &loaded.ast, "ops-hook", "on_task_created", "spin".to_string(), ); assert!(err.is_err()); match err.unwrap_err() { PluginError::SafetyLimitExceeded { plugin, .. } => { assert_eq!(plugin, "ops-hook"); } other => panic!("Expected SafetyLimitExceeded, got {:?}", other), } // Recover -- cheap path should still work let ok: String = engine .call_fn_1(&loaded.ast, "ops-hook", "on_task_created", "t-1".to_string()) .unwrap(); assert_eq!(ok, "ok:t-1"); } /// Ensures that hot-reloading a broken plugin does not corrupt the loader, /// and a subsequent reload with a fixed script succeeds. #[test] fn reload_broken_then_fixed_recovers() { let temp_dir = TempDir::new().unwrap(); create_test_plugin(temp_dir.path(), "fragile"); let plugin_dir = temp_dir.path().join("available").join("fragile"); let mut loader = PluginLoader::new(temp_dir.path()).unwrap(); loader.load_plugin("fragile", &plugin_dir).unwrap(); // Break the script std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap(); let bad_reload = loader.reload_plugin("fragile"); assert!(bad_reload.is_err()); // The plugin should no longer be in the cache after a failed reload // (reload_plugin removes the entry before attempting to re-load) assert!(loader.get_plugin("fragile").is_none()); // Fix the script let fixed_script = r#" fn describe() { #{ name: "Fragile Fixed", file_extensions: ["csv"] } } fn parse(file_path, options) { goingson::task_result([]) } "#; std::fs::write(plugin_dir.join("main.rhai"), fixed_script).unwrap(); // Re-load directly (not reload, since it was evicted from cache) let reloaded = loader.load_plugin("fragile", &plugin_dir).unwrap(); assert_eq!(reloaded.meta.name, "fragile"); // Verify the fixed script is callable let desc: rhai::Dynamic = loader .engine() .call_fn(&reloaded.ast, "fragile", "describe") .unwrap(); let map = desc.try_cast::().unwrap(); assert_eq!( map.get("name").unwrap().clone().into_string().unwrap(), "Fragile Fixed" ); } }