Skip to main content

max / balanced_breakfast

11.9 KB · 381 lines History Blame Raw
1 //! Plugin manager for Rhai script plugins
2 //!
3 //! Discovers and loads .rhai plugin scripts from the plugins directory.
4
5 use std::collections::HashMap;
6 use std::path::{Path, PathBuf};
7
8 use parking_lot::RwLock;
9
10 use bb_interface::{BusserCapabilities, BusserConfig, ConfigSchema, FetchResult};
11 use thiserror::Error;
12 use tracing::{debug, error, info, warn};
13
14 use bb_interface::StructuredError;
15
16 use crate::rhai_plugin::{classify_error, RhaiPluginError, RhaiPluginManager};
17
18 #[derive(Error, Debug)]
19 /// Errors from plugin loading, initialization, and fetching
20 pub enum PluginError {
21 /// A `.rhai` script could not be read from disk or compiled by the engine.
22 #[error("Failed to load plugin: {0}")]
23 LoadError(String),
24 /// No loaded plugin matches the requested plugin ID.
25 #[error("Plugin not found: {0}")]
26 NotFound(String),
27 /// Plugin exists but `initialize_plugin()` failed (e.g. bad config).
28 #[error("Plugin initialization failed: {0}")]
29 InitError(String),
30 /// The plugin's `fetch()` function returned an error at runtime.
31 #[error("Plugin fetch failed: {0}")]
32 FetchError(String),
33 /// Attempted to load a plugin whose ID is already registered.
34 #[error("Plugin already loaded: {0}")]
35 AlreadyLoaded(String),
36 /// Filesystem I/O error (e.g. plugins directory unreadable).
37 #[error("IO error: {0}")]
38 IoError(#[from] std::io::Error),
39 /// Wraps a lower-level [`RhaiPluginError`] from the Rhai runtime.
40 #[error("Rhai error: {0}")]
41 RhaiError(#[from] RhaiPluginError),
42 }
43
44 impl PluginError {
45 /// Convert this error to a [`StructuredError`] by classifying the inner message.
46 #[tracing::instrument(skip_all)]
47 pub fn to_structured(&self) -> StructuredError {
48 match self {
49 PluginError::RhaiError(e) => e.to_structured(),
50 PluginError::FetchError(msg) => classify_error(msg),
51 _ => classify_error(&self.to_string()),
52 }
53 }
54 }
55
56 /// Manages Rhai plugin loading and lifecycle
57 pub struct PluginManager {
58 plugins_dir: PathBuf,
59 rhai_manager: RhaiPluginManager,
60 plugin_configs: RwLock<HashMap<String, BusserConfig>>,
61 }
62
63 impl PluginManager {
64 /// Create a new plugin manager rooted at the given plugins directory.
65 #[tracing::instrument(skip_all)]
66 pub fn new(plugins_dir: impl AsRef<Path>) -> Self {
67 Self {
68 plugins_dir: plugins_dir.as_ref().to_path_buf(),
69 rhai_manager: RhaiPluginManager::new(),
70 plugin_configs: RwLock::new(HashMap::new()),
71 }
72 }
73
74 /// Get the plugins directory
75 #[tracing::instrument(skip_all)]
76 pub fn plugins_dir(&self) -> &Path {
77 &self.plugins_dir
78 }
79
80 /// Discover all .rhai plugin files in the plugins directory
81 #[tracing::instrument(skip_all)]
82 pub fn discover_plugins(&self) -> Result<Vec<PathBuf>, PluginError> {
83 let mut plugins = Vec::new();
84
85 if !self.plugins_dir.exists() {
86 info!(path = ?self.plugins_dir, "Creating plugins directory");
87 std::fs::create_dir_all(&self.plugins_dir)?;
88 return Ok(plugins);
89 }
90
91 for entry in std::fs::read_dir(&self.plugins_dir)? {
92 let entry = entry?;
93 let path = entry.path();
94
95 if Self::is_plugin_file(&path) {
96 plugins.push(path);
97 }
98 }
99
100 info!(count = plugins.len(), "Discovered Rhai plugins");
101 Ok(plugins)
102 }
103
104 /// Check if a path is a valid plugin file (.rhai extension)
105 fn is_plugin_file(path: &Path) -> bool {
106 path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("rhai")
107 }
108
109 /// Load a plugin from a .rhai file path
110 #[tracing::instrument(skip_all)]
111 pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> {
112 let path = path.as_ref();
113 info!(?path, "Loading Rhai plugin");
114
115 let id = self
116 .rhai_manager
117 .load_plugin(path)
118 .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?;
119
120 debug!(%id, "Loaded Rhai plugin");
121 Ok(id)
122 }
123
124 /// Load all discovered plugins
125 #[tracing::instrument(skip_all)]
126 pub fn load_all(&mut self) -> Result<Vec<String>, PluginError> {
127 let paths = self.discover_plugins()?;
128 let mut loaded = Vec::new();
129
130 for path in paths {
131 match self.load_plugin(&path) {
132 Ok(id) => loaded.push(id),
133 Err(e) => {
134 warn!(error = %e, ?path, "Failed to load plugin");
135 }
136 }
137 }
138
139 Ok(loaded)
140 }
141
142 /// Initialize a plugin with configuration
143 #[tracing::instrument(skip_all)]
144 pub fn initialize_plugin(
145 &self,
146 plugin_id: &str,
147 config: BusserConfig,
148 ) -> Result<(), PluginError> {
149 // Verify plugin exists
150 if self.rhai_manager.get(plugin_id).is_none() {
151 return Err(PluginError::NotFound(plugin_id.to_string()));
152 }
153
154 // Store config for later use in fetch
155 let mut configs = self.plugin_configs.write();
156 configs.insert(plugin_id.to_string(), config);
157
158 info!(%plugin_id, "Initialized plugin");
159 Ok(())
160 }
161
162 /// Fetch items from a plugin
163 #[tracing::instrument(skip_all)]
164 pub fn fetch(
165 &self,
166 plugin_id: &str,
167 cursor: Option<String>,
168 ) -> Result<FetchResult, PluginError> {
169 let plugin = self
170 .rhai_manager
171 .get(plugin_id)
172 .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
173
174 let configs = self.plugin_configs.read();
175 let config = configs
176 .get(plugin_id)
177 .ok_or_else(|| PluginError::InitError("Plugin not initialized".to_string()))?;
178
179 plugin
180 .fetch(config, cursor)
181 .map_err(|e| PluginError::FetchError(e.to_string()))
182 }
183
184 /// Shutdown a plugin (no-op for Rhai plugins, kept for API compatibility)
185 #[tracing::instrument(skip_all)]
186 pub fn shutdown_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
187 if self.rhai_manager.get(plugin_id).is_none() {
188 return Err(PluginError::NotFound(plugin_id.to_string()));
189 }
190
191 info!(%plugin_id, "Shutdown plugin");
192 Ok(())
193 }
194
195 /// Shutdown all plugins
196 #[tracing::instrument(skip_all)]
197 pub fn shutdown_all(&self) {
198 let plugin_ids = self.rhai_manager.list();
199 for id in plugin_ids {
200 if let Err(e) = self.shutdown_plugin(&id) {
201 error!(error = %e, %id, "Failed to shutdown plugin");
202 }
203 }
204 }
205
206 /// Get list of loaded plugin IDs
207 #[tracing::instrument(skip_all)]
208 pub fn list_plugins(&self) -> Vec<String> {
209 self.rhai_manager.list()
210 }
211
212 /// Get plugin info
213 #[tracing::instrument(skip_all)]
214 pub fn get_plugin_info(&self, plugin_id: &str) -> Option<(String, String, PathBuf)> {
215 self.rhai_manager.get_info(plugin_id)
216 }
217
218 /// Get plugin capabilities
219 #[tracing::instrument(skip_all)]
220 pub fn get_capabilities(&self, plugin_id: &str) -> Option<BusserCapabilities> {
221 self.rhai_manager.get(plugin_id).map(|p| p.capabilities())
222 }
223
224 /// Get plugin configuration schema
225 #[tracing::instrument(skip_all)]
226 pub fn get_config_schema(&self, plugin_id: &str) -> Option<ConfigSchema> {
227 self.rhai_manager.get(plugin_id).and_then(|p| {
228 match p.config_schema() {
229 Ok(s) => Some(s),
230 Err(e) => {
231 warn!(error = %e, %plugin_id, "Plugin config_schema() failed");
232 None
233 }
234 }
235 })
236 }
237 }
238
239 impl Drop for PluginManager {
240 fn drop(&mut self) {
241 self.shutdown_all();
242 }
243 }
244
245 #[cfg(test)]
246 mod tests {
247 use super::*;
248 use std::env::temp_dir;
249
250 #[test]
251 fn test_plugin_manager_creation() {
252 let dir = temp_dir().join("bb_test_plugins");
253 let manager = PluginManager::new(&dir);
254 assert_eq!(manager.plugins_dir(), dir);
255 }
256
257 #[test]
258 fn test_discover_empty_dir() {
259 let dir = temp_dir().join("bb_test_plugins_empty");
260 let _ = std::fs::remove_dir_all(&dir);
261 let manager = PluginManager::new(&dir);
262 let plugins = manager.discover_plugins().unwrap();
263 assert!(plugins.is_empty());
264 }
265
266 #[test]
267 fn test_is_plugin_file() {
268 // Create a temporary test file
269 let dir = temp_dir().join("bb_test_plugin_file");
270 let _ = std::fs::create_dir_all(&dir);
271 let rhai_path = dir.join("test.rhai");
272 let rs_path = dir.join("test.rs");
273 let dylib_path = dir.join("test.dylib");
274
275 // Create the files
276 std::fs::write(&rhai_path, "// test").unwrap();
277 std::fs::write(&rs_path, "// test").unwrap();
278 std::fs::write(&dylib_path, "test").unwrap();
279
280 assert!(PluginManager::is_plugin_file(&rhai_path));
281 assert!(!PluginManager::is_plugin_file(&rs_path));
282 assert!(!PluginManager::is_plugin_file(&dylib_path));
283
284 // Cleanup
285 let _ = std::fs::remove_dir_all(&dir);
286 }
287
288 #[test]
289 fn test_load_rhai_plugin() {
290 let dir = temp_dir().join("bb_test_rhai_plugin");
291 let _ = std::fs::create_dir_all(&dir);
292
293 // Write a simple test plugin
294 let plugin_code = r#"
295 fn id() { "test" }
296 fn name() { "Test Plugin" }
297 fn config_schema() {
298 #{
299 description: "Test plugin",
300 fields: []
301 }
302 }
303 fn fetch(config, cursor) {
304 #{
305 items: [],
306 has_more: false
307 }
308 }
309 "#;
310 let plugin_path = dir.join("test.rhai");
311 std::fs::write(&plugin_path, plugin_code).unwrap();
312
313 // Load the plugin
314 let mut manager = PluginManager::new(&dir);
315 let result = manager.load_plugin(&plugin_path);
316 assert!(result.is_ok());
317 assert_eq!(result.unwrap(), "test");
318
319 // Verify the plugin is loaded
320 let plugins = manager.list_plugins();
321 assert!(plugins.contains(&"test".to_string()));
322
323 // Verify we can get the schema
324 let schema = manager.get_config_schema("test");
325 assert!(schema.is_some());
326 assert_eq!(schema.unwrap().description, "Test plugin");
327
328 // Cleanup
329 let _ = std::fs::remove_dir_all(&dir);
330 }
331
332 #[test]
333 fn initialize_plugin_unknown_returns_not_found() {
334 let dir = temp_dir().join("bb_test_init_unknown");
335 let manager = PluginManager::new(&dir);
336 let result = manager.initialize_plugin("nonexistent", BusserConfig::new());
337 assert!(result.is_err());
338 assert!(result.unwrap_err().to_string().contains("not found"));
339 }
340
341 #[test]
342 fn fetch_without_init_returns_error() {
343 let dir = temp_dir().join("bb_test_fetch_no_init");
344 let _ = std::fs::create_dir_all(&dir);
345
346 let plugin_code = r#"
347 fn id() { "test_fetch" }
348 fn name() { "Test Fetch" }
349 fn config_schema() { #{ description: "test", fields: [] } }
350 fn fetch(config, cursor) { #{ items: [], has_more: false } }
351 "#;
352 let plugin_path = dir.join("test_fetch.rhai");
353 std::fs::write(&plugin_path, plugin_code).unwrap();
354
355 let mut manager = PluginManager::new(&dir);
356 manager.load_plugin(&plugin_path).unwrap();
357
358 // Fetch without calling initialize_plugin first
359 let result = manager.fetch("test_fetch", None);
360 assert!(result.is_err());
361
362 let _ = std::fs::remove_dir_all(&dir);
363 }
364
365 #[test]
366 fn shutdown_plugin_unknown_returns_not_found() {
367 let dir = temp_dir().join("bb_test_shutdown_unknown");
368 let manager = PluginManager::new(&dir);
369 let result = manager.shutdown_plugin("nonexistent");
370 assert!(result.is_err());
371 assert!(result.unwrap_err().to_string().contains("not found"));
372 }
373
374 #[test]
375 fn get_capabilities_unknown_returns_none() {
376 let dir = temp_dir().join("bb_test_caps_unknown");
377 let manager = PluginManager::new(&dir);
378 assert!(manager.get_capabilities("nonexistent").is_none());
379 }
380 }
381