Skip to main content

max / goingson

34.8 KB · 1026 lines History Blame Raw
1 //! Plugin discovery and loading.
2 //!
3 //! Scans plugin directories, loads manifests, and compiles scripts.
4
5 use std::collections::HashMap;
6 use std::path::{Path, PathBuf};
7 use std::sync::Arc;
8
9 use rhai::AST;
10
11 use crate::engine::PluginEngine;
12 use crate::error::{PluginError, Result};
13 use crate::manifest::PluginManifest;
14 use goingson_core::PluginMeta;
15
16 /// A loaded plugin with its compiled script.
17 #[derive(Debug, Clone)]
18 pub struct LoadedPlugin {
19 /// Plugin metadata from manifest.
20 pub meta: PluginMeta,
21 /// Path to the plugin directory.
22 pub path: PathBuf,
23 /// Compiled Rhai AST.
24 pub ast: Arc<AST>,
25 }
26
27 /// Plugin loader that discovers and loads plugins from the filesystem.
28 pub struct PluginLoader {
29 /// Base directory for plugins (e.g., ~/.config/goingson/plugins).
30 plugins_dir: PathBuf,
31 /// Shared engine for compilation.
32 engine: Arc<PluginEngine>,
33 /// Cache of loaded plugins.
34 loaded: HashMap<String, LoadedPlugin>,
35 }
36
37 impl PluginLoader {
38 /// Creates a new plugin loader for the given directory.
39 #[tracing::instrument(skip_all)]
40 pub fn new(plugins_dir: impl Into<PathBuf>) -> Result<Self> {
41 let plugins_dir = plugins_dir.into();
42
43 // Create directories if they don't exist
44 let enabled_dir = plugins_dir.join("enabled");
45 let available_dir = plugins_dir.join("available");
46
47 std::fs::create_dir_all(&enabled_dir)
48 .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?;
49 std::fs::create_dir_all(&available_dir)
50 .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?;
51
52 Ok(Self {
53 plugins_dir,
54 engine: Arc::new(PluginEngine::new()),
55 loaded: HashMap::new(),
56 })
57 }
58
59 /// Creates a plugin loader with a custom engine.
60 #[tracing::instrument(skip_all)]
61 pub fn with_engine(plugins_dir: impl Into<PathBuf>, engine: Arc<PluginEngine>) -> Result<Self> {
62 let plugins_dir = plugins_dir.into();
63
64 let enabled_dir = plugins_dir.join("enabled");
65 let available_dir = plugins_dir.join("available");
66
67 std::fs::create_dir_all(&enabled_dir)
68 .map_err(|e| PluginError::FileError(format!("Failed to create enabled dir: {}", e)))?;
69 std::fs::create_dir_all(&available_dir)
70 .map_err(|e| PluginError::FileError(format!("Failed to create available dir: {}", e)))?;
71
72 Ok(Self {
73 plugins_dir,
74 engine,
75 loaded: HashMap::new(),
76 })
77 }
78
79 /// Returns the base plugins directory.
80 #[tracing::instrument(skip_all)]
81 pub fn plugins_dir(&self) -> &Path {
82 &self.plugins_dir
83 }
84
85 /// Returns the enabled plugins directory.
86 #[tracing::instrument(skip_all)]
87 pub fn enabled_dir(&self) -> PathBuf {
88 self.plugins_dir.join("enabled")
89 }
90
91 /// Returns the available plugins directory.
92 #[tracing::instrument(skip_all)]
93 pub fn available_dir(&self) -> PathBuf {
94 self.plugins_dir.join("available")
95 }
96
97 /// Discovers all enabled plugins (symlinks in enabled/).
98 #[tracing::instrument(skip_all)]
99 pub fn discover_enabled(&mut self) -> Result<Vec<PluginMeta>> {
100 let enabled_dir = self.enabled_dir();
101 let mut plugins = Vec::new();
102
103 if !enabled_dir.exists() {
104 return Ok(plugins);
105 }
106
107 for entry in std::fs::read_dir(&enabled_dir)
108 .map_err(|e| PluginError::FileError(format!("Failed to read enabled dir: {}", e)))?
109 {
110 let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?;
111 let path = entry.path();
112
113 // Follow symlinks to get the actual plugin directory
114 let plugin_path = if path.is_symlink() {
115 let target = std::fs::read_link(&path)
116 .map_err(|e| PluginError::FileError(format!("Failed to read symlink: {}", e)))?;
117 // Resolve the symlink target to its canonical path.
118 // If the target doesn't exist (dangling symlink), skip it entirely
119 // to prevent sandbox escape via delayed target creation.
120 let canonical = match std::fs::canonicalize(&target) {
121 Ok(p) => p,
122 Err(_) => {
123 tracing::warn!(
124 "Skipping symlink '{}': target '{}' does not exist",
125 path.display(), target.display()
126 );
127 continue;
128 }
129 };
130 let available_canonical = std::fs::canonicalize(self.available_dir())
131 .unwrap_or_else(|_| self.available_dir());
132 if !canonical.starts_with(&available_canonical) {
133 tracing::warn!(
134 "Skipping symlink '{}': target '{}' is outside available/",
135 path.display(), canonical.display()
136 );
137 continue;
138 }
139 canonical
140 } else if path.is_dir() {
141 path.clone()
142 } else {
143 continue;
144 };
145
146 // The plugin ID is the directory name
147 let plugin_id = path
148 .file_name()
149 .and_then(|n| n.to_str())
150 .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))?
151 .to_string();
152
153 match self.load_plugin(&plugin_id, &plugin_path) {
154 Ok(loaded) => plugins.push(loaded.meta.clone()),
155 Err(e) => {
156 tracing::warn!("Failed to load plugin '{}': {}", plugin_id, e);
157 }
158 }
159 }
160
161 Ok(plugins)
162 }
163
164 /// Discovers all available plugins (directories in available/).
165 #[tracing::instrument(skip_all)]
166 pub fn discover_available(&self) -> Result<Vec<PluginMeta>> {
167 let available_dir = self.available_dir();
168 let mut plugins = Vec::new();
169
170 if !available_dir.exists() {
171 return Ok(plugins);
172 }
173
174 for entry in std::fs::read_dir(&available_dir)
175 .map_err(|e| PluginError::FileError(format!("Failed to read available dir: {}", e)))?
176 {
177 let entry = entry.map_err(|e| PluginError::FileError(e.to_string()))?;
178 let path = entry.path();
179
180 if !path.is_dir() {
181 continue;
182 }
183
184 let plugin_id = path
185 .file_name()
186 .and_then(|n| n.to_str())
187 .ok_or_else(|| PluginError::FileError("Invalid plugin path".to_string()))?
188 .to_string();
189
190 let manifest_path = path.join("plugin.toml");
191 if !manifest_path.exists() {
192 tracing::warn!("Plugin '{}' missing plugin.toml", plugin_id);
193 continue;
194 }
195
196 match PluginManifest::from_file(&manifest_path) {
197 Ok(manifest) => match manifest.to_meta(plugin_id.clone()) {
198 Ok(meta) => plugins.push(meta),
199 Err(e) => tracing::warn!("Invalid manifest for '{}': {}", plugin_id, e),
200 },
201 Err(e) => tracing::warn!("Failed to parse manifest for '{}': {}", plugin_id, e),
202 }
203 }
204
205 Ok(plugins)
206 }
207
208 /// Loads a plugin from a directory.
209 #[tracing::instrument(skip_all)]
210 pub fn load_plugin(&mut self, plugin_id: &str, plugin_path: &Path) -> Result<LoadedPlugin> {
211 // Check cache first
212 if let Some(loaded) = self.loaded.get(plugin_id) {
213 return Ok(loaded.clone());
214 }
215
216 // Load manifest
217 let manifest_path = plugin_path.join("plugin.toml");
218 if !manifest_path.exists() {
219 return Err(PluginError::FileError(format!(
220 "Plugin '{}' missing plugin.toml",
221 plugin_id
222 )));
223 }
224
225 let manifest = PluginManifest::from_file(&manifest_path)?;
226 let meta = manifest.to_meta(plugin_id.to_string())?;
227
228 // Load and compile script
229 let script_path = plugin_path.join("main.rhai");
230 if !script_path.exists() {
231 return Err(PluginError::FileError(format!(
232 "Plugin '{}' missing main.rhai",
233 plugin_id
234 )));
235 }
236
237 let script = std::fs::read_to_string(&script_path)
238 .map_err(|e| PluginError::FileError(format!("Failed to read script: {}", e)))?;
239
240 let ast = self.engine.compile_plugin(plugin_id, &script)?;
241
242 // Validate required functions based on plugin type
243 self.validate_plugin_functions(plugin_id, &meta, &ast)?;
244
245 let loaded = LoadedPlugin {
246 meta,
247 path: plugin_path.to_path_buf(),
248 ast: Arc::new(ast),
249 };
250
251 self.loaded.insert(plugin_id.to_string(), loaded.clone());
252
253 Ok(loaded)
254 }
255
256 /// Validates that a plugin has the required functions for its type.
257 fn validate_plugin_functions(
258 &self,
259 plugin_id: &str,
260 meta: &PluginMeta,
261 ast: &AST,
262 ) -> Result<()> {
263 match &meta.plugin_type {
264 goingson_core::PluginType::Import(_) => {
265 // Import plugins must have describe() and parse(file_path, options)
266 if !self.engine.has_function(ast, "describe", 0) {
267 return Err(PluginError::missing_function(plugin_id, "describe"));
268 }
269 if !self.engine.has_function(ast, "parse", 2) {
270 return Err(PluginError::missing_function(plugin_id, "parse"));
271 }
272 }
273 goingson_core::PluginType::Export(_) => {
274 // Export plugins must have describe() and export(data, options)
275 if !self.engine.has_function(ast, "describe", 0) {
276 return Err(PluginError::missing_function(plugin_id, "describe"));
277 }
278 if !self.engine.has_function(ast, "export", 2) {
279 return Err(PluginError::missing_function(plugin_id, "export"));
280 }
281 }
282 goingson_core::PluginType::Command => {
283 // Command plugins must have describe() and execute(args)
284 if !self.engine.has_function(ast, "describe", 0) {
285 return Err(PluginError::missing_function(plugin_id, "describe"));
286 }
287 if !self.engine.has_function(ast, "execute", 1) {
288 return Err(PluginError::missing_function(plugin_id, "execute"));
289 }
290 }
291 goingson_core::PluginType::Hook => {
292 // Hook plugins must have describe() and at least one hook handler
293 if !self.engine.has_function(ast, "describe", 0) {
294 return Err(PluginError::missing_function(plugin_id, "describe"));
295 }
296 }
297 }
298
299 Ok(())
300 }
301
302 /// Reloads a plugin from disk.
303 ///
304 /// Removes the cached `LoadedPlugin` first so `load_plugin` doesn't
305 /// short-circuit to the stale AST, then re-reads and recompiles from
306 /// the same directory path. Rejects the reload if capabilities escalated.
307 #[tracing::instrument(skip_all)]
308 pub fn reload_plugin(&mut self, plugin_id: &str) -> Result<LoadedPlugin> {
309 let old = self
310 .loaded
311 .get(plugin_id)
312 .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?;
313
314 let old_caps = old.meta.capabilities.clone();
315 let path = old.path.clone();
316
317 self.loaded.remove(plugin_id);
318
319 let new_plugin = self.load_plugin(plugin_id, &path)?;
320
321 // Detect escalation: any capability that was false and is now true
322 let new_caps = &new_plugin.meta.capabilities;
323 let mut escalated = Vec::new();
324 if !old_caps.file_read && new_caps.file_read { escalated.push("file_read"); }
325 if !old_caps.database_write && new_caps.database_write { escalated.push("database_write"); }
326 if !old_caps.network && new_caps.network { escalated.push("network"); }
327
328 if !escalated.is_empty() {
329 self.loaded.remove(plugin_id);
330 return Err(PluginError::capability_escalation(
331 plugin_id,
332 escalated.join(", "),
333 ));
334 }
335
336 Ok(new_plugin)
337 }
338
339 /// Gets a loaded plugin by ID.
340 #[tracing::instrument(skip_all)]
341 pub fn get_plugin(&self, plugin_id: &str) -> Option<&LoadedPlugin> {
342 self.loaded.get(plugin_id)
343 }
344
345 /// Returns the shared engine.
346 #[tracing::instrument(skip_all)]
347 pub fn engine(&self) -> &Arc<PluginEngine> {
348 &self.engine
349 }
350
351 /// Enables a plugin by creating a symlink from `enabled/` → `available/`.
352 ///
353 /// Uses the `available/` + `enabled/` directory pattern (similar to
354 /// nginx `sites-available`/`sites-enabled`): plugin code lives in
355 /// `available/<id>/`, and enabling creates a symlink in `enabled/<id>`
356 /// pointing to it. This keeps a clean separation between "installed"
357 /// and "active" plugins without copying files.
358 #[tracing::instrument(skip_all)]
359 pub fn enable_plugin(&self, plugin_id: &str) -> Result<()> {
360 let available_path = self.available_dir().join(plugin_id);
361 let enabled_path = self.enabled_dir().join(plugin_id);
362
363 if !available_path.exists() {
364 return Err(PluginError::PluginNotFound(plugin_id.to_string()));
365 }
366
367 if enabled_path.exists() {
368 return Ok(());
369 }
370
371 // Unix uses fs::symlink; Windows uses symlink_dir (directory junctions).
372 #[cfg(unix)]
373 {
374 std::os::unix::fs::symlink(&available_path, &enabled_path).map_err(|e| {
375 PluginError::FileError(format!("Failed to create symlink: {}", e))
376 })?;
377 }
378
379 #[cfg(windows)]
380 {
381 std::os::windows::fs::symlink_dir(&available_path, &enabled_path).map_err(|e| {
382 PluginError::FileError(format!("Failed to create symlink: {}", e))
383 })?;
384 }
385
386 Ok(())
387 }
388
389 /// Disables a plugin by removing its symlink from enabled/.
390 #[tracing::instrument(skip_all)]
391 pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> {
392 let enabled_path = self.enabled_dir().join(plugin_id);
393
394 if enabled_path.exists() || enabled_path.is_symlink() {
395 std::fs::remove_file(&enabled_path)
396 .map_err(|e| PluginError::FileError(format!("Failed to remove symlink: {}", e)))?;
397 }
398
399 // Remove from cache
400 self.loaded.remove(plugin_id);
401
402 Ok(())
403 }
404
405 /// Checks if a plugin is enabled.
406 #[tracing::instrument(skip_all)]
407 pub fn is_enabled(&self, plugin_id: &str) -> bool {
408 let enabled_path = self.enabled_dir().join(plugin_id);
409 enabled_path.exists() || enabled_path.is_symlink()
410 }
411
412 /// Returns a reference to the loaded plugins map.
413 #[tracing::instrument(skip_all)]
414 pub fn loaded(&self) -> &HashMap<String, LoadedPlugin> {
415 &self.loaded
416 }
417 }
418
419 #[cfg(test)]
420 mod tests {
421 use super::*;
422 use crate::engine::SafetyLimits;
423 use tempfile::TempDir;
424
425 fn create_test_plugin(dir: &Path, name: &str) {
426 let plugin_dir = dir.join("available").join(name);
427 std::fs::create_dir_all(&plugin_dir).unwrap();
428
429 let manifest = format!(
430 r#"
431 [plugin]
432 name = "{}"
433 version = "1.0.0"
434 description = "Test plugin"
435
436 [plugin.type]
437 kind = "import"
438
439 [plugin.import]
440 file_extensions = ["csv"]
441 entity_types = ["task"]
442
443 [plugin.capabilities]
444 file_read = true
445 "#,
446 name
447 );
448
449 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
450
451 let script = r#"
452 fn describe() {
453 #{
454 name: "Test",
455 file_extensions: ["csv"]
456 }
457 }
458
459 fn parse(file_path, options) {
460 goingson::task_result([])
461 }
462 "#;
463 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
464 }
465
466 #[test]
467 fn test_discover_available() {
468 let temp_dir = TempDir::new().unwrap();
469 create_test_plugin(temp_dir.path(), "test-plugin");
470
471 let loader = PluginLoader::new(temp_dir.path()).unwrap();
472 let plugins = loader.discover_available().unwrap();
473
474 assert_eq!(plugins.len(), 1);
475 assert_eq!(plugins[0].id, "test-plugin");
476 }
477
478 #[test]
479 fn test_enable_disable_plugin() {
480 let temp_dir = TempDir::new().unwrap();
481 create_test_plugin(temp_dir.path(), "test-plugin");
482
483 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
484
485 // Initially not enabled
486 assert!(!loader.is_enabled("test-plugin"));
487
488 // Enable
489 loader.enable_plugin("test-plugin").unwrap();
490 assert!(loader.is_enabled("test-plugin"));
491
492 // Discover enabled
493 let enabled = loader.discover_enabled().unwrap();
494 assert_eq!(enabled.len(), 1);
495
496 // Disable
497 loader.disable_plugin("test-plugin").unwrap();
498 assert!(!loader.is_enabled("test-plugin"));
499 }
500
501 // ============ Discovery: Non-Plugin Files ============
502
503 #[test]
504 fn discover_available_skips_plain_files() {
505 let temp_dir = TempDir::new().unwrap();
506 create_test_plugin(temp_dir.path(), "real-plugin");
507
508 // Drop non-plugin files and dirs into available/
509 let available = temp_dir.path().join("available");
510 std::fs::write(available.join("README.md"), "# Plugins directory").unwrap();
511 std::fs::write(available.join(".DS_Store"), "").unwrap();
512 std::fs::write(available.join("notes.txt"), "some notes").unwrap();
513
514 let loader = PluginLoader::new(temp_dir.path()).unwrap();
515 let plugins = loader.discover_available().unwrap();
516
517 // Only the real plugin directory should be discovered
518 assert_eq!(plugins.len(), 1);
519 assert_eq!(plugins[0].id, "real-plugin");
520 }
521
522 #[test]
523 fn discover_available_skips_dirs_without_manifest() {
524 let temp_dir = TempDir::new().unwrap();
525 create_test_plugin(temp_dir.path(), "good-plugin");
526
527 // Create a directory that looks like a plugin but has no plugin.toml
528 let no_manifest = temp_dir.path().join("available").join("incomplete");
529 std::fs::create_dir_all(&no_manifest).unwrap();
530 std::fs::write(no_manifest.join("main.rhai"), "fn describe() {}").unwrap();
531
532 let loader = PluginLoader::new(temp_dir.path()).unwrap();
533 let plugins = loader.discover_available().unwrap();
534
535 assert_eq!(plugins.len(), 1);
536 assert_eq!(plugins[0].id, "good-plugin");
537 }
538
539 #[test]
540 fn discover_enabled_skips_regular_files_in_enabled_dir() {
541 let temp_dir = TempDir::new().unwrap();
542 create_test_plugin(temp_dir.path(), "real-plugin");
543
544 // Enable the real plugin
545 let loader = PluginLoader::new(temp_dir.path()).unwrap();
546 loader.enable_plugin("real-plugin").unwrap();
547
548 // Drop a stray file in the enabled/ directory
549 let enabled_dir = temp_dir.path().join("enabled");
550 std::fs::write(enabled_dir.join("stray-file.txt"), "not a plugin").unwrap();
551
552 // Re-create loader to force fresh discovery
553 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
554 let enabled = loader.discover_enabled().unwrap();
555
556 // Only the real symlinked plugin should be found
557 assert_eq!(enabled.len(), 1);
558 assert_eq!(enabled[0].id, "real-plugin");
559 }
560
561 // ============ Corrupt Manifest During Load ============
562
563 #[test]
564 fn load_plugin_with_corrupt_manifest_returns_error() {
565 let temp_dir = TempDir::new().unwrap();
566 let plugin_dir = temp_dir.path().join("available").join("corrupt");
567 std::fs::create_dir_all(&plugin_dir).unwrap();
568
569 // Write invalid TOML as the manifest
570 std::fs::write(plugin_dir.join("plugin.toml"), "{{{{ garbage !@#$").unwrap();
571 std::fs::write(plugin_dir.join("main.rhai"), "fn describe() {}").unwrap();
572
573 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
574 let result = loader.load_plugin("corrupt", &plugin_dir);
575 match result {
576 Err(PluginError::InvalidManifest(msg)) => {
577 assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg);
578 }
579 Err(other) => panic!("Expected InvalidManifest, got {:?}", other),
580 Ok(_) => panic!("Expected error, got Ok"),
581 }
582 }
583
584 #[test]
585 fn load_plugin_missing_script_returns_error() {
586 let temp_dir = TempDir::new().unwrap();
587 let plugin_dir = temp_dir.path().join("available").join("no-script");
588 std::fs::create_dir_all(&plugin_dir).unwrap();
589
590 // Valid manifest but no main.rhai
591 let manifest = r#"
592 [plugin]
593 name = "no-script"
594 version = "1.0.0"
595 description = "Missing script"
596
597 [plugin.type]
598 kind = "command"
599
600 [plugin.capabilities]
601 "#;
602 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
603
604 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
605 let result = loader.load_plugin("no-script", &plugin_dir);
606 match result {
607 Err(PluginError::FileError(msg)) => {
608 assert!(msg.contains("missing main.rhai"), "Unexpected: {}", msg);
609 }
610 Err(other) => panic!("Expected FileError, got {:?}", other),
611 Ok(_) => panic!("Expected error, got Ok"),
612 }
613 }
614
615 // ============ Reload (Hot-Reload) Edge Cases ============
616
617 #[test]
618 fn reload_picks_up_modified_script() {
619 let temp_dir = TempDir::new().unwrap();
620 create_test_plugin(temp_dir.path(), "hot-reload");
621
622 let plugin_dir = temp_dir.path().join("available").join("hot-reload");
623 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
624 loader.load_plugin("hot-reload", &plugin_dir).unwrap();
625
626 // Verify original script has the parse function
627 let original = loader.get_plugin("hot-reload").unwrap();
628 assert!(loader.engine().has_function(&original.ast, "parse", 2));
629
630 // Update the script with different content (still valid)
631 let updated_script = r#"
632 fn describe() {
633 #{
634 name: "Updated",
635 file_extensions: ["csv"]
636 }
637 }
638
639 fn parse(file_path, options) {
640 goingson::task_result([#{description: "updated item"}])
641 }
642 "#;
643 std::fs::write(plugin_dir.join("main.rhai"), updated_script).unwrap();
644
645 // Reload and verify the AST is fresh (new script compiled)
646 let reloaded = loader.reload_plugin("hot-reload").unwrap();
647 assert_eq!(reloaded.meta.name, "hot-reload");
648
649 // Call describe() on the reloaded AST to verify the new code runs
650 let engine = loader.engine();
651 let desc: rhai::Dynamic = engine.call_fn(&reloaded.ast, "hot-reload", "describe").unwrap();
652 let map = desc.try_cast::<rhai::Map>().unwrap();
653 assert_eq!(
654 map.get("name").unwrap().clone().into_string().unwrap(),
655 "Updated"
656 );
657 }
658
659 #[test]
660 fn reload_with_now_invalid_script_returns_error() {
661 let temp_dir = TempDir::new().unwrap();
662 create_test_plugin(temp_dir.path(), "break-later");
663
664 let plugin_dir = temp_dir.path().join("available").join("break-later");
665 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
666 loader.load_plugin("break-later", &plugin_dir).unwrap();
667
668 // Overwrite with a syntactically invalid script
669 std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap();
670
671 let result = loader.reload_plugin("break-later");
672 assert!(result.is_err());
673 }
674
675 #[test]
676 fn reload_with_removed_required_function_returns_error() {
677 let temp_dir = TempDir::new().unwrap();
678 create_test_plugin(temp_dir.path(), "lose-fn");
679
680 let plugin_dir = temp_dir.path().join("available").join("lose-fn");
681 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
682 loader.load_plugin("lose-fn", &plugin_dir).unwrap();
683
684 // Replace script with one missing the required parse() function
685 let no_parse = r#"
686 fn describe() {
687 #{ name: "Broken", file_extensions: ["csv"] }
688 }
689 "#;
690 std::fs::write(plugin_dir.join("main.rhai"), no_parse).unwrap();
691
692 let result = loader.reload_plugin("lose-fn");
693 match result {
694 Err(PluginError::MissingFunction { plugin, function }) => {
695 assert_eq!(plugin, "lose-fn");
696 assert_eq!(function, "parse");
697 }
698 Err(other) => panic!("Expected MissingFunction, got {:?}", other),
699 Ok(_) => panic!("Expected error, got Ok"),
700 }
701 }
702
703 #[test]
704 fn reload_nonexistent_plugin_returns_not_found() {
705 let temp_dir = TempDir::new().unwrap();
706 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
707
708 let result = loader.reload_plugin("no-such-plugin");
709 match result {
710 Err(PluginError::PluginNotFound(id)) => assert_eq!(id, "no-such-plugin"),
711 Err(other) => panic!("Expected PluginNotFound, got {:?}", other),
712 Ok(_) => panic!("Expected error, got Ok"),
713 }
714 }
715
716 #[test]
717 fn reload_updates_manifest_metadata() {
718 let temp_dir = TempDir::new().unwrap();
719 create_test_plugin(temp_dir.path(), "version-bump");
720
721 let plugin_dir = temp_dir.path().join("available").join("version-bump");
722 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
723 loader.load_plugin("version-bump", &plugin_dir).unwrap();
724
725 let original = loader.get_plugin("version-bump").unwrap();
726 assert_eq!(original.meta.version, "1.0.0");
727
728 // Bump version in manifest on disk
729 let updated_manifest = r#"
730 [plugin]
731 name = "version-bump"
732 version = "2.0.0"
733 description = "Updated description"
734
735 [plugin.type]
736 kind = "import"
737
738 [plugin.import]
739 file_extensions = ["csv"]
740 entity_types = ["task"]
741
742 [plugin.capabilities]
743 file_read = true
744 "#;
745 std::fs::write(plugin_dir.join("plugin.toml"), updated_manifest).unwrap();
746
747 let reloaded = loader.reload_plugin("version-bump").unwrap();
748 assert_eq!(reloaded.meta.version, "2.0.0");
749 assert_eq!(reloaded.meta.description, "Updated description");
750 }
751
752 // ============ Cache Bypass on Reload ============
753
754 #[test]
755 fn load_returns_cached_but_reload_bypasses_cache() {
756 let temp_dir = TempDir::new().unwrap();
757 create_test_plugin(temp_dir.path(), "cache-test");
758
759 let plugin_dir = temp_dir.path().join("available").join("cache-test");
760 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
761
762 // First load
763 loader.load_plugin("cache-test", &plugin_dir).unwrap();
764 let v1 = loader.get_plugin("cache-test").unwrap().meta.version.clone();
765
766 // Update manifest on disk
767 let updated = r#"
768 [plugin]
769 name = "cache-test"
770 version = "9.9.9"
771 description = "Test plugin"
772
773 [plugin.type]
774 kind = "import"
775
776 [plugin.import]
777 file_extensions = ["csv"]
778 entity_types = ["task"]
779
780 [plugin.capabilities]
781 file_read = true
782 "#;
783 std::fs::write(plugin_dir.join("plugin.toml"), updated).unwrap();
784
785 // load_plugin should return cached (stale) version
786 let cached = loader.load_plugin("cache-test", &plugin_dir).unwrap();
787 assert_eq!(cached.meta.version, v1);
788
789 // reload_plugin should pick up the new version
790 let reloaded = loader.reload_plugin("cache-test").unwrap();
791 assert_eq!(reloaded.meta.version, "9.9.9");
792 }
793
794 // ============ Full Plugin Lifecycle ============
795
796 /// Exercises the full plugin lifecycle: discover -> load -> execute -> error -> recover.
797 ///
798 /// This test uses a hook plugin with two functions: one that succeeds and one
799 /// that throws. It verifies:
800 /// 1. discover_available() finds the plugin on disk
801 /// 2. load_plugin() compiles the script and validates functions
802 /// 3. Calling a successful function produces the expected result
803 /// 4. Calling a throwing function returns PluginError, does not panic
804 /// 5. After the error, the plugin is still callable (recovery)
805 #[test]
806 fn full_plugin_lifecycle_discover_load_execute_error_recover() {
807 let temp_dir = TempDir::new().unwrap();
808
809 // -- set up a hook plugin on disk --
810 let plugin_dir = temp_dir.path().join("available").join("task-hook");
811 std::fs::create_dir_all(&plugin_dir).unwrap();
812
813 let manifest = r#"
814 [plugin]
815 name = "Task Hook"
816 version = "1.0.0"
817 description = "Reacts to task lifecycle events"
818
819 [plugin.type]
820 kind = "hook"
821
822 [plugin.capabilities]
823 "#;
824 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
825
826 let script = r#"
827 fn describe() {
828 #{
829 name: "Task Hook",
830 hooks: ["on_task_created", "on_task_completed"]
831 }
832 }
833
834 fn on_task_created(task_id) {
835 if task_id == "" {
836 throw "task_id must not be empty";
837 }
838 "created:" + task_id
839 }
840
841 fn on_task_completed(task_id) {
842 "completed:" + task_id
843 }
844 "#;
845 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
846
847 // -- 1. Discover --
848 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
849 let available = loader.discover_available().unwrap();
850 assert_eq!(available.len(), 1);
851 assert_eq!(available[0].id, "task-hook");
852 assert_eq!(available[0].name, "Task Hook");
853 assert!(matches!(
854 available[0].plugin_type,
855 goingson_core::PluginType::Hook
856 ));
857
858 // -- 2. Load --
859 let loaded = loader.load_plugin("task-hook", &plugin_dir).unwrap();
860 assert_eq!(loaded.meta.version, "1.0.0");
861
862 let engine = loader.engine();
863 assert!(engine.has_function(&loaded.ast, "describe", 0));
864 assert!(engine.has_function(&loaded.ast, "on_task_created", 1));
865 assert!(engine.has_function(&loaded.ast, "on_task_completed", 1));
866
867 // -- 3. Execute (success) --
868 let result: String = engine
869 .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-42".to_string())
870 .unwrap();
871 assert_eq!(result, "created:t-42");
872
873 let result2: String = engine
874 .call_fn_1(&loaded.ast, "task-hook", "on_task_completed", "t-42".to_string())
875 .unwrap();
876 assert_eq!(result2, "completed:t-42");
877
878 // -- 4. Execute (error) -- empty task_id triggers throw
879 let err_result: crate::error::Result<String> = engine.call_fn_1(
880 &loaded.ast,
881 "task-hook",
882 "on_task_created",
883 "".to_string(),
884 );
885 assert!(err_result.is_err());
886 match err_result.unwrap_err() {
887 PluginError::ScriptError { plugin, message } => {
888 assert_eq!(plugin, "task-hook");
889 assert!(
890 message.contains("task_id must not be empty"),
891 "Unexpected error message: {}",
892 message
893 );
894 }
895 other => panic!("Expected ScriptError, got {:?}", other),
896 }
897
898 // -- 5. Recover -- plugin is still callable after the error
899 let recovered: String = engine
900 .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-99".to_string())
901 .unwrap();
902 assert_eq!(recovered, "created:t-99");
903 }
904
905 /// Verifies that a plugin hitting the operation limit errors gracefully and
906 /// does not prevent subsequent calls to cheaper functions.
907 #[test]
908 fn full_lifecycle_operation_limit_and_recovery() {
909 let temp_dir = TempDir::new().unwrap();
910
911 let plugin_dir = temp_dir.path().join("available").join("ops-hook");
912 std::fs::create_dir_all(&plugin_dir).unwrap();
913
914 let manifest = r#"
915 [plugin]
916 name = "Ops Hook"
917 version = "1.0.0"
918 description = "Hook with expensive and cheap paths"
919
920 [plugin.type]
921 kind = "hook"
922
923 [plugin.capabilities]
924 "#;
925 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
926
927 let script = r#"
928 fn describe() {
929 #{ name: "Ops Hook" }
930 }
931
932 fn on_task_created(task_id) {
933 if task_id == "spin" {
934 let x = 0;
935 loop { x += 1; }
936 }
937 "ok:" + task_id
938 }
939 "#;
940 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
941
942 // Use a tight ops limit to trigger the safety check quickly
943 let limits = SafetyLimits {
944 max_operations: 200,
945 ..Default::default()
946 };
947 let engine = Arc::new(PluginEngine::with_limits(limits));
948 let mut loader = PluginLoader::with_engine(temp_dir.path(), engine).unwrap();
949
950 // Discover + load
951 let available = loader.discover_available().unwrap();
952 assert_eq!(available.len(), 1);
953
954 let loaded = loader.load_plugin("ops-hook", &plugin_dir).unwrap();
955 let engine = loader.engine();
956
957 // Trigger ops limit
958 let err: crate::error::Result<String> = engine.call_fn_1(
959 &loaded.ast,
960 "ops-hook",
961 "on_task_created",
962 "spin".to_string(),
963 );
964 assert!(err.is_err());
965 match err.unwrap_err() {
966 PluginError::SafetyLimitExceeded { plugin, .. } => {
967 assert_eq!(plugin, "ops-hook");
968 }
969 other => panic!("Expected SafetyLimitExceeded, got {:?}", other),
970 }
971
972 // Recover -- cheap path should still work
973 let ok: String = engine
974 .call_fn_1(&loaded.ast, "ops-hook", "on_task_created", "t-1".to_string())
975 .unwrap();
976 assert_eq!(ok, "ok:t-1");
977 }
978
979 /// Ensures that hot-reloading a broken plugin does not corrupt the loader,
980 /// and a subsequent reload with a fixed script succeeds.
981 #[test]
982 fn reload_broken_then_fixed_recovers() {
983 let temp_dir = TempDir::new().unwrap();
984 create_test_plugin(temp_dir.path(), "fragile");
985
986 let plugin_dir = temp_dir.path().join("available").join("fragile");
987 let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
988 loader.load_plugin("fragile", &plugin_dir).unwrap();
989
990 // Break the script
991 std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap();
992 let bad_reload = loader.reload_plugin("fragile");
993 assert!(bad_reload.is_err());
994
995 // The plugin should no longer be in the cache after a failed reload
996 // (reload_plugin removes the entry before attempting to re-load)
997 assert!(loader.get_plugin("fragile").is_none());
998
999 // Fix the script
1000 let fixed_script = r#"
1001 fn describe() {
1002 #{ name: "Fragile Fixed", file_extensions: ["csv"] }
1003 }
1004 fn parse(file_path, options) {
1005 goingson::task_result([])
1006 }
1007 "#;
1008 std::fs::write(plugin_dir.join("main.rhai"), fixed_script).unwrap();
1009
1010 // Re-load directly (not reload, since it was evicted from cache)
1011 let reloaded = loader.load_plugin("fragile", &plugin_dir).unwrap();
1012 assert_eq!(reloaded.meta.name, "fragile");
1013
1014 // Verify the fixed script is callable
1015 let desc: rhai::Dynamic = loader
1016 .engine()
1017 .call_fn(&reloaded.ast, "fragile", "describe")
1018 .unwrap();
1019 let map = desc.try_cast::<rhai::Map>().unwrap();
1020 assert_eq!(
1021 map.get("name").unwrap().clone().into_string().unwrap(),
1022 "Fragile Fixed"
1023 );
1024 }
1025 }
1026