//! Plugin manager for Rhai script plugins //! //! Discovers and loads .rhai plugin scripts from the plugins directory. use std::collections::HashMap; use std::path::{Path, PathBuf}; use parking_lot::RwLock; use bb_interface::{BusserCapabilities, BusserConfig, ConfigSchema, FetchResult}; use thiserror::Error; use tracing::{debug, error, info, warn}; use bb_interface::StructuredError; use crate::rhai_plugin::{classify_error, RhaiPluginError, RhaiPluginManager}; #[derive(Error, Debug)] /// Errors from plugin loading, initialization, and fetching pub enum PluginError { /// A `.rhai` script could not be read from disk or compiled by the engine. #[error("Failed to load plugin: {0}")] LoadError(String), /// No loaded plugin matches the requested plugin ID. #[error("Plugin not found: {0}")] NotFound(String), /// Plugin exists but `initialize_plugin()` failed (e.g. bad config). #[error("Plugin initialization failed: {0}")] InitError(String), /// The plugin's `fetch()` function returned an error at runtime. #[error("Plugin fetch failed: {0}")] FetchError(String), /// Attempted to load a plugin whose ID is already registered. #[error("Plugin already loaded: {0}")] AlreadyLoaded(String), /// Filesystem I/O error (e.g. plugins directory unreadable). #[error("IO error: {0}")] IoError(#[from] std::io::Error), /// Wraps a lower-level [`RhaiPluginError`] from the Rhai runtime. #[error("Rhai error: {0}")] RhaiError(#[from] RhaiPluginError), } impl PluginError { /// Convert this error to a [`StructuredError`] by classifying the inner message. #[tracing::instrument(skip_all)] pub fn to_structured(&self) -> StructuredError { match self { PluginError::RhaiError(e) => e.to_structured(), PluginError::FetchError(msg) => classify_error(msg), _ => classify_error(&self.to_string()), } } } /// Manages Rhai plugin loading and lifecycle pub struct PluginManager { plugins_dir: PathBuf, rhai_manager: RhaiPluginManager, plugin_configs: RwLock>, } impl PluginManager { /// Create a new plugin manager rooted at the given plugins directory. #[tracing::instrument(skip_all)] pub fn new(plugins_dir: impl AsRef) -> Self { Self { plugins_dir: plugins_dir.as_ref().to_path_buf(), rhai_manager: RhaiPluginManager::new(), plugin_configs: RwLock::new(HashMap::new()), } } /// Get the plugins directory #[tracing::instrument(skip_all)] pub fn plugins_dir(&self) -> &Path { &self.plugins_dir } /// Discover all .rhai plugin files in the plugins directory #[tracing::instrument(skip_all)] pub fn discover_plugins(&self) -> Result, PluginError> { let mut plugins = Vec::new(); if !self.plugins_dir.exists() { info!(path = ?self.plugins_dir, "Creating plugins directory"); std::fs::create_dir_all(&self.plugins_dir)?; return Ok(plugins); } for entry in std::fs::read_dir(&self.plugins_dir)? { let entry = entry?; let path = entry.path(); if Self::is_plugin_file(&path) { plugins.push(path); } } info!(count = plugins.len(), "Discovered Rhai plugins"); Ok(plugins) } /// Check if a path is a valid plugin file (.rhai extension) fn is_plugin_file(path: &Path) -> bool { path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("rhai") } /// Load a plugin from a .rhai file path #[tracing::instrument(skip_all)] pub fn load_plugin(&mut self, path: impl AsRef) -> Result { let path = path.as_ref(); info!(?path, "Loading Rhai plugin"); let id = self .rhai_manager .load_plugin(path) .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?; debug!(%id, "Loaded Rhai plugin"); Ok(id) } /// Load all discovered plugins #[tracing::instrument(skip_all)] pub fn load_all(&mut self) -> Result, PluginError> { let paths = self.discover_plugins()?; let mut loaded = Vec::new(); for path in paths { match self.load_plugin(&path) { Ok(id) => loaded.push(id), Err(e) => { warn!(error = %e, ?path, "Failed to load plugin"); } } } Ok(loaded) } /// Initialize a plugin with configuration #[tracing::instrument(skip_all)] pub fn initialize_plugin( &self, plugin_id: &str, config: BusserConfig, ) -> Result<(), PluginError> { // Verify plugin exists if self.rhai_manager.get(plugin_id).is_none() { return Err(PluginError::NotFound(plugin_id.to_string())); } // Store config for later use in fetch let mut configs = self.plugin_configs.write(); configs.insert(plugin_id.to_string(), config); info!(%plugin_id, "Initialized plugin"); Ok(()) } /// Fetch items from a plugin #[tracing::instrument(skip_all)] pub fn fetch( &self, plugin_id: &str, cursor: Option, ) -> Result { let plugin = self .rhai_manager .get(plugin_id) .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; let configs = self.plugin_configs.read(); let config = configs .get(plugin_id) .ok_or_else(|| PluginError::InitError("Plugin not initialized".to_string()))?; plugin .fetch(config, cursor) .map_err(|e| PluginError::FetchError(e.to_string())) } /// Shutdown a plugin (no-op for Rhai plugins, kept for API compatibility) #[tracing::instrument(skip_all)] pub fn shutdown_plugin(&self, plugin_id: &str) -> Result<(), PluginError> { if self.rhai_manager.get(plugin_id).is_none() { return Err(PluginError::NotFound(plugin_id.to_string())); } info!(%plugin_id, "Shutdown plugin"); Ok(()) } /// Shutdown all plugins #[tracing::instrument(skip_all)] pub fn shutdown_all(&self) { let plugin_ids = self.rhai_manager.list(); for id in plugin_ids { if let Err(e) = self.shutdown_plugin(&id) { error!(error = %e, %id, "Failed to shutdown plugin"); } } } /// Get list of loaded plugin IDs #[tracing::instrument(skip_all)] pub fn list_plugins(&self) -> Vec { self.rhai_manager.list() } /// Get plugin info #[tracing::instrument(skip_all)] pub fn get_plugin_info(&self, plugin_id: &str) -> Option<(String, String, PathBuf)> { self.rhai_manager.get_info(plugin_id) } /// Get plugin capabilities #[tracing::instrument(skip_all)] pub fn get_capabilities(&self, plugin_id: &str) -> Option { self.rhai_manager.get(plugin_id).map(|p| p.capabilities()) } /// Get plugin configuration schema #[tracing::instrument(skip_all)] pub fn get_config_schema(&self, plugin_id: &str) -> Option { self.rhai_manager.get(plugin_id).and_then(|p| { match p.config_schema() { Ok(s) => Some(s), Err(e) => { warn!(error = %e, %plugin_id, "Plugin config_schema() failed"); None } } }) } } impl Drop for PluginManager { fn drop(&mut self) { self.shutdown_all(); } } #[cfg(test)] mod tests { use super::*; use std::env::temp_dir; #[test] fn test_plugin_manager_creation() { let dir = temp_dir().join("bb_test_plugins"); let manager = PluginManager::new(&dir); assert_eq!(manager.plugins_dir(), dir); } #[test] fn test_discover_empty_dir() { let dir = temp_dir().join("bb_test_plugins_empty"); let _ = std::fs::remove_dir_all(&dir); let manager = PluginManager::new(&dir); let plugins = manager.discover_plugins().unwrap(); assert!(plugins.is_empty()); } #[test] fn test_is_plugin_file() { // Create a temporary test file let dir = temp_dir().join("bb_test_plugin_file"); let _ = std::fs::create_dir_all(&dir); let rhai_path = dir.join("test.rhai"); let rs_path = dir.join("test.rs"); let dylib_path = dir.join("test.dylib"); // Create the files std::fs::write(&rhai_path, "// test").unwrap(); std::fs::write(&rs_path, "// test").unwrap(); std::fs::write(&dylib_path, "test").unwrap(); assert!(PluginManager::is_plugin_file(&rhai_path)); assert!(!PluginManager::is_plugin_file(&rs_path)); assert!(!PluginManager::is_plugin_file(&dylib_path)); // Cleanup let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_load_rhai_plugin() { let dir = temp_dir().join("bb_test_rhai_plugin"); let _ = std::fs::create_dir_all(&dir); // Write a simple test plugin let plugin_code = r#" fn id() { "test" } fn name() { "Test Plugin" } fn config_schema() { #{ description: "Test plugin", fields: [] } } fn fetch(config, cursor) { #{ items: [], has_more: false } } "#; let plugin_path = dir.join("test.rhai"); std::fs::write(&plugin_path, plugin_code).unwrap(); // Load the plugin let mut manager = PluginManager::new(&dir); let result = manager.load_plugin(&plugin_path); assert!(result.is_ok()); assert_eq!(result.unwrap(), "test"); // Verify the plugin is loaded let plugins = manager.list_plugins(); assert!(plugins.contains(&"test".to_string())); // Verify we can get the schema let schema = manager.get_config_schema("test"); assert!(schema.is_some()); assert_eq!(schema.unwrap().description, "Test plugin"); // Cleanup let _ = std::fs::remove_dir_all(&dir); } #[test] fn initialize_plugin_unknown_returns_not_found() { let dir = temp_dir().join("bb_test_init_unknown"); let manager = PluginManager::new(&dir); let result = manager.initialize_plugin("nonexistent", BusserConfig::new()); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } #[test] fn fetch_without_init_returns_error() { let dir = temp_dir().join("bb_test_fetch_no_init"); let _ = std::fs::create_dir_all(&dir); let plugin_code = r#" fn id() { "test_fetch" } fn name() { "Test Fetch" } fn config_schema() { #{ description: "test", fields: [] } } fn fetch(config, cursor) { #{ items: [], has_more: false } } "#; let plugin_path = dir.join("test_fetch.rhai"); std::fs::write(&plugin_path, plugin_code).unwrap(); let mut manager = PluginManager::new(&dir); manager.load_plugin(&plugin_path).unwrap(); // Fetch without calling initialize_plugin first let result = manager.fetch("test_fetch", None); assert!(result.is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn shutdown_plugin_unknown_returns_not_found() { let dir = temp_dir().join("bb_test_shutdown_unknown"); let manager = PluginManager::new(&dir); let result = manager.shutdown_plugin("nonexistent"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } #[test] fn get_capabilities_unknown_returns_none() { let dir = temp_dir().join("bb_test_caps_unknown"); let manager = PluginManager::new(&dir); assert!(manager.get_capabilities("nonexistent").is_none()); } }