Skip to main content

max / goingson

38.5 KB · 1244 lines History Blame Raw
1 //! Plugin registry for managing and executing plugins.
2 //!
3 //! Provides high-level operations for listing, previewing, and executing imports.
4
5 use std::sync::Arc;
6
7 use rhai::{Dynamic, Map};
8
9 use crate::api::{dynamic_to_import_result, PluginApi, PluginApiContext};
10 use crate::engine::PluginEngine;
11 use crate::error::{PluginError, Result};
12 use crate::loader::{LoadedPlugin, PluginLoader};
13 use goingson_core::{
14 ImportOptions, ImportParseResult, PluginMeta, PluginType,
15 };
16
17 /// Registry for managing active plugins.
18 pub struct PluginRegistry {
19 loader: PluginLoader,
20 }
21
22 impl PluginRegistry {
23 /// Creates a new plugin registry.
24 #[tracing::instrument(skip_all)]
25 pub fn new(plugins_dir: impl Into<std::path::PathBuf>) -> Result<Self> {
26 let loader = PluginLoader::new(plugins_dir)?;
27 Ok(Self { loader })
28 }
29
30 /// Creates a registry with a custom engine.
31 #[tracing::instrument(skip_all)]
32 pub fn with_engine(
33 plugins_dir: impl Into<std::path::PathBuf>,
34 engine: Arc<PluginEngine>,
35 ) -> Result<Self> {
36 let loader = PluginLoader::with_engine(plugins_dir, engine)?;
37 Ok(Self { loader })
38 }
39
40 /// Returns the plugin loader.
41 #[tracing::instrument(skip_all)]
42 pub fn loader(&self) -> &PluginLoader {
43 &self.loader
44 }
45
46 /// Returns a mutable reference to the plugin loader.
47 #[tracing::instrument(skip_all)]
48 pub fn loader_mut(&mut self) -> &mut PluginLoader {
49 &mut self.loader
50 }
51
52 /// Initializes the registry by discovering enabled plugins.
53 #[tracing::instrument(skip_all)]
54 pub fn initialize(&mut self) -> Result<Vec<PluginMeta>> {
55 self.loader.discover_enabled()
56 }
57
58 /// Lists all available import plugins.
59 #[tracing::instrument(skip_all)]
60 pub fn list_import_plugins(&self) -> Result<Vec<PluginMeta>> {
61 let available = self.loader.discover_available()?;
62 Ok(available
63 .into_iter()
64 .filter(|p| matches!(p.plugin_type, PluginType::Import(_)))
65 .collect())
66 }
67
68 /// Lists all enabled import plugins.
69 #[tracing::instrument(skip_all)]
70 pub fn list_enabled_import_plugins(&self) -> Vec<PluginMeta> {
71 self.loader
72 .loaded()
73 .iter()
74 .filter(|(_, p)| matches!(p.meta.plugin_type, PluginType::Import(_)))
75 .map(|(_, p)| p.meta.clone())
76 .collect()
77 }
78
79 /// Gets plugins that can handle a specific file extension.
80 #[tracing::instrument(skip_all)]
81 pub fn get_plugins_for_extension(&self, extension: &str) -> Vec<PluginMeta> {
82 let ext_lower = extension.to_lowercase();
83 self.loader
84 .loaded()
85 .iter()
86 .filter_map(|(_, p)| {
87 if let PluginType::Import(config) = &p.meta.plugin_type {
88 if config.file_extensions.iter().any(|e| e.to_lowercase() == ext_lower) {
89 return Some(p.meta.clone());
90 }
91 }
92 None
93 })
94 .collect()
95 }
96
97 /// Previews an import by running the plugin's parse function.
98 #[tracing::instrument(skip_all)]
99 pub fn preview_import(
100 &self,
101 plugin_id: &str,
102 file_path: &str,
103 options: ImportOptions,
104 projects: Vec<(String, String)>,
105 ) -> Result<ImportParseResult> {
106 let plugin = self
107 .loader
108 .get_plugin(plugin_id)
109 .ok_or_else(|| PluginError::PluginNotFound(plugin_id.to_string()))?;
110
111 // Verify it's an import plugin
112 if !matches!(plugin.meta.plugin_type, PluginType::Import(_)) {
113 return Err(PluginError::InvalidManifest(format!(
114 "'{}' is not an import plugin",
115 plugin_id
116 )));
117 }
118
119 // Set up context
120 let ctx = PluginApiContext {
121 import_file_path: Some(file_path.to_string()),
122 can_read_files: plugin.meta.capabilities.file_read,
123 can_write_db: plugin.meta.capabilities.database_write,
124 logs: Vec::new(),
125 progress: None,
126 projects,
127 };
128 PluginApiContext::set(ctx);
129
130 // Guard ensures context is cleared even if call_fn_2 errors
131 struct ContextGuard;
132 impl Drop for ContextGuard {
133 fn drop(&mut self) {
134 PluginApiContext::clear();
135 }
136 }
137 let _guard = ContextGuard;
138
139 // Convert options to Rhai map
140 let options_map = options_to_rhai_map(&options);
141
142 // Call parse function
143 let engine = self.loader.engine();
144 let result = engine.call_fn_2::<String, Map, Dynamic>(
145 &plugin.ast,
146 plugin_id,
147 "parse",
148 file_path.to_string(),
149 options_map,
150 )?;
151
152 // Log any messages
153 let logs = PluginApi::get_logs();
154 for (level, msg) in &logs {
155 match level.as_str() {
156 "info" => tracing::info!("[{}] {}", plugin_id, msg),
157 "warn" => tracing::warn!("[{}] {}", plugin_id, msg),
158 "error" => tracing::error!("[{}] {}", plugin_id, msg),
159 _ => {}
160 }
161 }
162
163 // Convert result
164 dynamic_to_import_result(result, plugin_id)
165 }
166
167 /// Enables a plugin.
168 #[tracing::instrument(skip_all)]
169 pub fn enable_plugin(&mut self, plugin_id: &str) -> Result<()> {
170 self.loader.enable_plugin(plugin_id)?;
171 // Load the plugin
172 let available_path = self.loader.available_dir().join(plugin_id);
173 self.loader.load_plugin(plugin_id, &available_path)?;
174 Ok(())
175 }
176
177 /// Disables a plugin.
178 #[tracing::instrument(skip_all)]
179 pub fn disable_plugin(&mut self, plugin_id: &str) -> Result<()> {
180 self.loader.disable_plugin(plugin_id)
181 }
182
183 /// Reloads a plugin from disk.
184 #[tracing::instrument(skip_all)]
185 pub fn reload_plugin(&mut self, plugin_id: &str) -> Result<PluginMeta> {
186 let loaded = self.loader.reload_plugin(plugin_id)?;
187 Ok(loaded.meta)
188 }
189 }
190
191 // Allow access to internal loaded plugins for iteration
192 impl PluginRegistry {
193 /// Iterates over loaded plugins.
194 #[tracing::instrument(skip_all)]
195 pub fn iter_loaded(&self) -> impl Iterator<Item = (&String, &LoadedPlugin)> {
196 self.loader.loaded().iter()
197 }
198 }
199
200 fn options_to_rhai_map(options: &ImportOptions) -> Map {
201 let mut map = Map::new();
202 map.insert("has_header".into(), Dynamic::from(options.has_header));
203
204 if let Some(delimiter) = options.delimiter {
205 map.insert("delimiter".into(), Dynamic::from(delimiter.to_string()));
206 }
207
208 if let Some(ref date_format) = options.date_format {
209 map.insert("date_format".into(), Dynamic::from(date_format.clone()));
210 }
211
212 for (key, value) in &options.extra {
213 map.insert(key.clone().into(), Dynamic::from(value.clone()));
214 }
215
216 map
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222 use std::path::Path;
223 use crate::ImportEntityType;
224 use tempfile::TempDir;
225
226 fn create_csv_import_plugin(dir: &Path) {
227 let plugin_dir = dir.join("available").join("csv-import");
228 std::fs::create_dir_all(&plugin_dir).unwrap();
229
230 let manifest = r#"
231 [plugin]
232 name = "CSV Import"
233 version = "1.0.0"
234 description = "Import tasks from CSV files"
235
236 [plugin.type]
237 kind = "import"
238
239 [plugin.import]
240 file_extensions = ["csv"]
241 entity_types = ["task"]
242
243 [plugin.capabilities]
244 file_read = true
245 database_write = true
246 "#;
247 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
248
249 let script = r#"
250 fn describe() {
251 #{
252 name: "CSV Import",
253 file_extensions: ["csv"]
254 }
255 }
256
257 fn parse(file_path, options) {
258 let content = goingson::read_file(file_path);
259 let rows = goingson::parse_csv(content, options);
260
261 let tasks = [];
262 for row in rows {
263 tasks.push(#{
264 description: row.description,
265 due: row.due,
266 priority: row.priority
267 });
268 }
269
270 goingson::task_result(tasks)
271 }
272 "#;
273 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
274 }
275
276 #[test]
277 fn test_list_import_plugins() {
278 let temp_dir = TempDir::new().unwrap();
279 create_csv_import_plugin(temp_dir.path());
280
281 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
282 let plugins = registry.list_import_plugins().unwrap();
283
284 assert_eq!(plugins.len(), 1);
285 assert_eq!(plugins[0].name, "CSV Import");
286 }
287
288 #[test]
289 fn test_preview_import() {
290 let temp_dir = TempDir::new().unwrap();
291 create_csv_import_plugin(temp_dir.path());
292
293 // Create test CSV file
294 let csv_path = temp_dir.path().join("test.csv");
295 std::fs::write(
296 &csv_path,
297 "description,due,priority\nBuy milk,2024-02-20,High\n",
298 )
299 .unwrap();
300
301 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
302
303 // Enable the plugin
304 registry.enable_plugin("csv-import").unwrap();
305
306 // Preview import
307 let result = registry
308 .preview_import(
309 "csv-import",
310 csv_path.to_str().unwrap(),
311 ImportOptions::default(),
312 Vec::new(),
313 )
314 .unwrap();
315
316 assert_eq!(result.entity_type, ImportEntityType::Task);
317 assert!(!result.items.is_empty(), "Expected at least 1 parsed item, got {}", result.items.len());
318 }
319
320 // --- Helpers for additional plugin types ---
321
322 /// Creates a JSON import plugin that handles .json files.
323 fn create_json_import_plugin(dir: &Path) {
324 let plugin_dir = dir.join("available").join("json-import");
325 std::fs::create_dir_all(&plugin_dir).unwrap();
326
327 let manifest = r#"
328 [plugin]
329 name = "JSON Import"
330 version = "2.0.0"
331 description = "Import tasks from JSON files"
332
333 [plugin.type]
334 kind = "import"
335
336 [plugin.import]
337 file_extensions = ["json"]
338 entity_types = ["task"]
339
340 [plugin.capabilities]
341 file_read = true
342 database_write = false
343 "#;
344 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
345
346 let script = r#"
347 fn describe() {
348 #{
349 name: "JSON Import",
350 file_extensions: ["json"]
351 }
352 }
353
354 fn parse(file_path, options) {
355 goingson::task_result([])
356 }
357 "#;
358 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
359 }
360
361 /// Creates a command plugin (not an import plugin).
362 fn create_command_plugin(dir: &Path) {
363 let plugin_dir = dir.join("available").join("my-command");
364 std::fs::create_dir_all(&plugin_dir).unwrap();
365
366 let manifest = r#"
367 [plugin]
368 name = "My Command"
369 version = "1.0.0"
370 description = "A custom command plugin"
371
372 [plugin.type]
373 kind = "command"
374
375 [plugin.capabilities]
376 "#;
377 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
378
379 let script = r#"
380 fn describe() {
381 #{
382 name: "My Command"
383 }
384 }
385
386 fn execute(args) {
387 42
388 }
389 "#;
390 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
391 }
392
393 // --- list_enabled_import_plugins ---
394
395 #[test]
396 fn list_enabled_import_plugins_empty_when_none_loaded() {
397 let temp_dir = TempDir::new().unwrap();
398 create_csv_import_plugin(temp_dir.path());
399
400 // Registry exists but no plugins have been enabled/loaded
401 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
402 let enabled = registry.list_enabled_import_plugins();
403 assert!(enabled.is_empty());
404 }
405
406 #[test]
407 fn list_enabled_import_plugins_returns_loaded_imports() {
408 let temp_dir = TempDir::new().unwrap();
409 create_csv_import_plugin(temp_dir.path());
410 create_json_import_plugin(temp_dir.path());
411
412 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
413 registry.enable_plugin("csv-import").unwrap();
414 registry.enable_plugin("json-import").unwrap();
415
416 let enabled = registry.list_enabled_import_plugins();
417 assert_eq!(enabled.len(), 2);
418
419 let names: Vec<&str> = enabled.iter().map(|p| p.name.as_str()).collect();
420 assert!(names.contains(&"CSV Import"));
421 assert!(names.contains(&"JSON Import"));
422 }
423
424 #[test]
425 fn list_enabled_import_plugins_excludes_non_import() {
426 let temp_dir = TempDir::new().unwrap();
427 create_csv_import_plugin(temp_dir.path());
428 create_command_plugin(temp_dir.path());
429
430 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
431 registry.enable_plugin("csv-import").unwrap();
432 registry.enable_plugin("my-command").unwrap();
433
434 let enabled = registry.list_enabled_import_plugins();
435 assert_eq!(enabled.len(), 1);
436 assert_eq!(enabled[0].name, "CSV Import");
437 }
438
439 // --- get_plugins_for_extension ---
440
441 #[test]
442 fn get_plugins_for_extension_matches() {
443 let temp_dir = TempDir::new().unwrap();
444 create_csv_import_plugin(temp_dir.path());
445
446 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
447 registry.enable_plugin("csv-import").unwrap();
448
449 let plugins = registry.get_plugins_for_extension("csv");
450 assert_eq!(plugins.len(), 1);
451 assert_eq!(plugins[0].name, "CSV Import");
452 }
453
454 #[test]
455 fn get_plugins_for_extension_case_insensitive() {
456 let temp_dir = TempDir::new().unwrap();
457 create_csv_import_plugin(temp_dir.path());
458
459 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
460 registry.enable_plugin("csv-import").unwrap();
461
462 let plugins = registry.get_plugins_for_extension("CSV");
463 assert_eq!(plugins.len(), 1);
464 assert_eq!(plugins[0].name, "CSV Import");
465 }
466
467 #[test]
468 fn get_plugins_for_extension_no_match() {
469 let temp_dir = TempDir::new().unwrap();
470 create_csv_import_plugin(temp_dir.path());
471
472 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
473 registry.enable_plugin("csv-import").unwrap();
474
475 let plugins = registry.get_plugins_for_extension("xlsx");
476 assert!(plugins.is_empty());
477 }
478
479 #[test]
480 fn get_plugins_for_extension_multiple_plugins_same_ext() {
481 let temp_dir = TempDir::new().unwrap();
482 create_csv_import_plugin(temp_dir.path());
483
484 // Create a second CSV plugin
485 let plugin_dir = temp_dir.path().join("available").join("csv-import-v2");
486 std::fs::create_dir_all(&plugin_dir).unwrap();
487 let manifest = r#"
488 [plugin]
489 name = "CSV Import V2"
490 version = "2.0.0"
491 description = "Another CSV importer"
492
493 [plugin.type]
494 kind = "import"
495
496 [plugin.import]
497 file_extensions = ["csv", "tsv"]
498 entity_types = ["task"]
499
500 [plugin.capabilities]
501 file_read = true
502 "#;
503 std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
504 let script = r#"
505 fn describe() { #{ name: "CSV Import V2", file_extensions: ["csv", "tsv"] } }
506 fn parse(file_path, options) { goingson::task_result([]) }
507 "#;
508 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
509
510 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
511 registry.enable_plugin("csv-import").unwrap();
512 registry.enable_plugin("csv-import-v2").unwrap();
513
514 let plugins = registry.get_plugins_for_extension("csv");
515 assert_eq!(plugins.len(), 2);
516
517 // Only the v2 plugin handles tsv
518 let tsv_plugins = registry.get_plugins_for_extension("tsv");
519 assert_eq!(tsv_plugins.len(), 1);
520 assert_eq!(tsv_plugins[0].name, "CSV Import V2");
521 }
522
523 #[test]
524 fn get_plugins_for_extension_ignores_non_import_plugins() {
525 let temp_dir = TempDir::new().unwrap();
526 create_command_plugin(temp_dir.path());
527
528 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
529 registry.enable_plugin("my-command").unwrap();
530
531 // Command plugins have no file_extensions, should never match
532 let plugins = registry.get_plugins_for_extension("csv");
533 assert!(plugins.is_empty());
534 }
535
536 // --- enable_plugin / disable_plugin ---
537
538 #[test]
539 fn enable_and_disable_plugin() {
540 let temp_dir = TempDir::new().unwrap();
541 create_csv_import_plugin(temp_dir.path());
542
543 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
544
545 // Enable
546 registry.enable_plugin("csv-import").unwrap();
547 assert!(registry.loader().get_plugin("csv-import").is_some());
548
549 // Disable
550 registry.disable_plugin("csv-import").unwrap();
551 assert!(registry.loader().get_plugin("csv-import").is_none());
552 }
553
554 #[test]
555 fn enable_plugin_not_found() {
556 let temp_dir = TempDir::new().unwrap();
557 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
558
559 let result = registry.enable_plugin("nonexistent");
560 assert!(result.is_err());
561
562 match result.unwrap_err() {
563 PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"),
564 other => panic!("Expected PluginNotFound, got {:?}", other),
565 }
566 }
567
568 #[test]
569 fn disable_plugin_not_loaded_is_ok() {
570 let temp_dir = TempDir::new().unwrap();
571 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
572
573 // Disabling a plugin that was never enabled should not error
574 let result = registry.disable_plugin("never-existed");
575 assert!(result.is_ok());
576 }
577
578 // --- reload_plugin (hot-reload) ---
579
580 #[test]
581 fn reload_plugin_picks_up_disk_changes() {
582 let temp_dir = TempDir::new().unwrap();
583 create_csv_import_plugin(temp_dir.path());
584
585 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
586 registry.enable_plugin("csv-import").unwrap();
587
588 // Verify initial version
589 let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone();
590 assert_eq!(meta.version, "1.0.0");
591
592 // Update the manifest version on disk
593 let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml");
594 let updated_manifest = r#"
595 [plugin]
596 name = "CSV Import"
597 version = "1.1.0"
598 description = "Import tasks from CSV files (updated)"
599
600 [plugin.type]
601 kind = "import"
602
603 [plugin.import]
604 file_extensions = ["csv"]
605 entity_types = ["task"]
606
607 [plugin.capabilities]
608 file_read = true
609 database_write = true
610 "#;
611 std::fs::write(&manifest_path, updated_manifest).unwrap();
612
613 // Reload and verify new metadata
614 let reloaded_meta = registry.reload_plugin("csv-import").unwrap();
615 assert_eq!(reloaded_meta.version, "1.1.0");
616 assert_eq!(reloaded_meta.description, "Import tasks from CSV files (updated)");
617 }
618
619 #[test]
620 fn reload_plugin_picks_up_script_changes() {
621 let temp_dir = TempDir::new().unwrap();
622 create_csv_import_plugin(temp_dir.path());
623
624 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
625 registry.enable_plugin("csv-import").unwrap();
626
627 // Update the script on disk — add a new function signature won't break
628 // but the AST should be different (re-compiled)
629 let script_path = temp_dir.path().join("available/csv-import/main.rhai");
630 let updated_script = r#"
631 fn describe() {
632 #{
633 name: "CSV Import Updated",
634 file_extensions: ["csv"]
635 }
636 }
637
638 fn parse(file_path, options) {
639 goingson::task_result([])
640 }
641 "#;
642 std::fs::write(&script_path, updated_script).unwrap();
643
644 // Reload — should succeed with the new script
645 let result = registry.reload_plugin("csv-import");
646 assert!(result.is_ok());
647 }
648
649 #[test]
650 fn reload_plugin_not_loaded_is_error() {
651 let temp_dir = TempDir::new().unwrap();
652 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
653
654 let result = registry.reload_plugin("nonexistent");
655 assert!(result.is_err());
656
657 match result.unwrap_err() {
658 PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"),
659 other => panic!("Expected PluginNotFound, got {:?}", other),
660 }
661 }
662
663 #[test]
664 fn reload_plugin_with_broken_script_is_error() {
665 let temp_dir = TempDir::new().unwrap();
666 create_csv_import_plugin(temp_dir.path());
667
668 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
669 registry.enable_plugin("csv-import").unwrap();
670
671 // Break the script on disk
672 let script_path = temp_dir.path().join("available/csv-import/main.rhai");
673 std::fs::write(&script_path, "fn broken( { }").unwrap();
674
675 let result = registry.reload_plugin("csv-import");
676 assert!(result.is_err());
677 }
678
679 #[test]
680 fn reload_plugin_with_missing_required_fn_is_error() {
681 let temp_dir = TempDir::new().unwrap();
682 create_csv_import_plugin(temp_dir.path());
683
684 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
685 registry.enable_plugin("csv-import").unwrap();
686
687 // Replace script with one missing the required parse() function
688 let script_path = temp_dir.path().join("available/csv-import/main.rhai");
689 let script_no_parse = r#"
690 fn describe() {
691 #{ name: "Broken", file_extensions: ["csv"] }
692 }
693 "#;
694 std::fs::write(&script_path, script_no_parse).unwrap();
695
696 let result = registry.reload_plugin("csv-import");
697 assert!(result.is_err());
698
699 match result.unwrap_err() {
700 PluginError::MissingFunction { plugin, function } => {
701 assert_eq!(plugin, "csv-import");
702 assert_eq!(function, "parse");
703 }
704 other => panic!("Expected MissingFunction, got {:?}", other),
705 }
706 }
707
708 // --- preview_import error paths ---
709
710 #[test]
711 fn preview_import_plugin_not_found() {
712 let temp_dir = TempDir::new().unwrap();
713 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
714
715 let result = registry.preview_import(
716 "nonexistent",
717 "/tmp/test.csv",
718 ImportOptions::default(),
719 Vec::new(),
720 );
721 assert!(result.is_err());
722
723 match result.unwrap_err() {
724 PluginError::PluginNotFound(id) => assert_eq!(id, "nonexistent"),
725 other => panic!("Expected PluginNotFound, got {:?}", other),
726 }
727 }
728
729 #[test]
730 fn preview_import_rejects_non_import_plugin() {
731 let temp_dir = TempDir::new().unwrap();
732 create_command_plugin(temp_dir.path());
733
734 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
735 registry.enable_plugin("my-command").unwrap();
736
737 let result = registry.preview_import(
738 "my-command",
739 "/tmp/test.csv",
740 ImportOptions::default(),
741 Vec::new(),
742 );
743 assert!(result.is_err());
744
745 match result.unwrap_err() {
746 PluginError::InvalidManifest(msg) => {
747 assert!(msg.contains("not an import plugin"), "Unexpected message: {}", msg);
748 }
749 other => panic!("Expected InvalidManifest, got {:?}", other),
750 }
751 }
752
753 // --- iter_loaded ---
754
755 #[test]
756 fn iter_loaded_empty_initially() {
757 let temp_dir = TempDir::new().unwrap();
758 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
759 assert_eq!(registry.iter_loaded().count(), 0);
760 }
761
762 #[test]
763 fn iter_loaded_reflects_enabled_plugins() {
764 let temp_dir = TempDir::new().unwrap();
765 create_csv_import_plugin(temp_dir.path());
766 create_json_import_plugin(temp_dir.path());
767 create_command_plugin(temp_dir.path());
768
769 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
770 registry.enable_plugin("csv-import").unwrap();
771 registry.enable_plugin("json-import").unwrap();
772 registry.enable_plugin("my-command").unwrap();
773
774 let loaded: Vec<_> = registry.iter_loaded().collect();
775 assert_eq!(loaded.len(), 3);
776
777 let ids: Vec<&str> = loaded.iter().map(|(id, _)| id.as_str()).collect();
778 assert!(ids.contains(&"csv-import"));
779 assert!(ids.contains(&"json-import"));
780 assert!(ids.contains(&"my-command"));
781 }
782
783 #[test]
784 fn iter_loaded_shrinks_after_disable() {
785 let temp_dir = TempDir::new().unwrap();
786 create_csv_import_plugin(temp_dir.path());
787 create_json_import_plugin(temp_dir.path());
788
789 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
790 registry.enable_plugin("csv-import").unwrap();
791 registry.enable_plugin("json-import").unwrap();
792 assert_eq!(registry.iter_loaded().count(), 2);
793
794 registry.disable_plugin("csv-import").unwrap();
795 assert_eq!(registry.iter_loaded().count(), 1);
796
797 let remaining: Vec<_> = registry.iter_loaded().collect();
798 assert_eq!(remaining[0].0, "json-import");
799 }
800
801 // --- initialize ---
802
803 #[test]
804 fn initialize_discovers_enabled_plugins() {
805 let temp_dir = TempDir::new().unwrap();
806 create_csv_import_plugin(temp_dir.path());
807
808 // Manually create symlink to simulate a previously-enabled plugin
809 let available = temp_dir.path().join("available/csv-import");
810 let enabled = temp_dir.path().join("enabled/csv-import");
811 std::fs::create_dir_all(temp_dir.path().join("enabled")).unwrap();
812
813 #[cfg(unix)]
814 std::os::unix::fs::symlink(&available, &enabled).unwrap();
815
816 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
817 let plugins = registry.initialize().unwrap();
818 assert_eq!(plugins.len(), 1);
819 assert_eq!(plugins[0].name, "CSV Import");
820 }
821
822 #[test]
823 fn initialize_empty_when_no_enabled() {
824 let temp_dir = TempDir::new().unwrap();
825 create_csv_import_plugin(temp_dir.path());
826
827 // Plugin exists in available but not enabled
828 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
829 let plugins = registry.initialize().unwrap();
830 assert!(plugins.is_empty());
831 }
832
833 // --- options_to_rhai_map ---
834
835 #[test]
836 fn options_to_rhai_map_defaults() {
837 let options = ImportOptions::default();
838 let map = options_to_rhai_map(&options);
839
840 // ImportOptions derives Default, so has_header is false (the
841 // serde default_true function only applies during deserialization).
842 assert!(!map.get("has_header").unwrap().as_bool().unwrap());
843 assert!(!map.contains_key("delimiter"));
844 assert!(!map.contains_key("date_format"));
845 }
846
847 #[test]
848 fn options_to_rhai_map_all_fields() {
849 let mut extra = std::collections::HashMap::new();
850 extra.insert("encoding".to_string(), "utf-8".to_string());
851 extra.insert("skip_rows".to_string(), "2".to_string());
852
853 let options = ImportOptions {
854 has_header: false,
855 delimiter: Some('\t'),
856 date_format: Some("%d/%m/%Y".to_string()),
857 extra,
858 };
859
860 let map = options_to_rhai_map(&options);
861
862 assert!(!map.get("has_header").unwrap().as_bool().unwrap());
863 assert_eq!(
864 map.get("delimiter").unwrap().clone().into_string().unwrap(),
865 "\t"
866 );
867 assert_eq!(
868 map.get("date_format").unwrap().clone().into_string().unwrap(),
869 "%d/%m/%Y"
870 );
871 assert_eq!(
872 map.get("encoding").unwrap().clone().into_string().unwrap(),
873 "utf-8"
874 );
875 assert_eq!(
876 map.get("skip_rows").unwrap().clone().into_string().unwrap(),
877 "2"
878 );
879 }
880
881 #[test]
882 fn options_to_rhai_map_empty_extra() {
883 let options = ImportOptions {
884 has_header: true,
885 delimiter: None,
886 date_format: None,
887 extra: std::collections::HashMap::new(),
888 };
889
890 let map = options_to_rhai_map(&options);
891 // Only has_header should be present
892 assert_eq!(map.len(), 1);
893 assert!(map.contains_key("has_header"));
894 }
895
896 // ============ User Plugin Overrides Bundled ============
897
898 /// Simulates the case where a user installs a plugin with the same ID as
899 /// a previously-loaded one (e.g. a bundled default). Loading the same ID
900 /// twice should allow reload to replace the original.
901 #[test]
902 fn user_plugin_overrides_same_id_via_reload() {
903 let temp_dir = TempDir::new().unwrap();
904 create_csv_import_plugin(temp_dir.path());
905
906 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
907 registry.enable_plugin("csv-import").unwrap();
908
909 // Verify the original
910 let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone();
911 assert_eq!(meta.version, "1.0.0");
912 assert_eq!(meta.description, "Import tasks from CSV files");
913
914 // User "updates" the same plugin on disk (new version, new description)
915 let plugin_dir = temp_dir.path().join("available").join("csv-import");
916 let user_manifest = r#"
917 [plugin]
918 name = "CSV Import"
919 version = "2.0.0"
920 description = "User-customized CSV import"
921
922 [plugin.type]
923 kind = "import"
924
925 [plugin.import]
926 file_extensions = ["csv", "tsv"]
927 entity_types = ["task"]
928
929 [plugin.capabilities]
930 file_read = true
931 database_write = true
932 "#;
933 std::fs::write(plugin_dir.join("plugin.toml"), user_manifest).unwrap();
934
935 let user_script = r#"
936 fn describe() {
937 #{
938 name: "CSV Import (User)",
939 file_extensions: ["csv", "tsv"]
940 }
941 }
942
943 fn parse(file_path, options) {
944 goingson::task_result([])
945 }
946 "#;
947 std::fs::write(plugin_dir.join("main.rhai"), user_script).unwrap();
948
949 // Reload to pick up user version
950 let reloaded = registry.reload_plugin("csv-import").unwrap();
951 assert_eq!(reloaded.version, "2.0.0");
952 assert_eq!(reloaded.description, "User-customized CSV import");
953
954 // The updated plugin should handle tsv now
955 let tsv_plugins = registry.get_plugins_for_extension("tsv");
956 assert_eq!(tsv_plugins.len(), 1);
957 assert_eq!(tsv_plugins[0].version, "2.0.0");
958 }
959
960 // ============ Reload While Running (Sequential Safety) ============
961
962 /// Verifies that after a reload, the old AST is no longer returned by
963 /// the loader, and the new AST produces different results.
964 #[test]
965 fn reload_replaces_ast_atomically() {
966 let temp_dir = TempDir::new().unwrap();
967 create_csv_import_plugin(temp_dir.path());
968
969 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
970 registry.enable_plugin("csv-import").unwrap();
971
972 // Capture the Arc<AST> from the first load
973 let old_ast = {
974 let plugin = registry.loader().get_plugin("csv-import").unwrap();
975 plugin.ast.clone()
976 };
977
978 // Modify the script to return a different describe name
979 let plugin_dir = temp_dir.path().join("available").join("csv-import");
980 let new_script = r#"
981 fn describe() {
982 #{
983 name: "CSV Import V2",
984 file_extensions: ["csv"]
985 }
986 }
987
988 fn parse(file_path, options) {
989 goingson::task_result([])
990 }
991 "#;
992 std::fs::write(plugin_dir.join("main.rhai"), new_script).unwrap();
993
994 registry.reload_plugin("csv-import").unwrap();
995
996 // The registry now holds a different AST
997 let new_ast = {
998 let plugin = registry.loader().get_plugin("csv-import").unwrap();
999 plugin.ast.clone()
1000 };
1001
1002 // Verify new AST produces different output
1003 let engine = registry.loader().engine();
1004 let old_desc: Dynamic = engine.call_fn(&old_ast, "csv-import", "describe").unwrap();
1005 let new_desc: Dynamic = engine.call_fn(&new_ast, "csv-import", "describe").unwrap();
1006
1007 let old_name = old_desc
1008 .try_cast::<Map>()
1009 .unwrap()
1010 .get("name")
1011 .unwrap()
1012 .clone()
1013 .into_string()
1014 .unwrap();
1015 let new_name = new_desc
1016 .try_cast::<Map>()
1017 .unwrap()
1018 .get("name")
1019 .unwrap()
1020 .clone()
1021 .into_string()
1022 .unwrap();
1023
1024 assert_eq!(old_name, "CSV Import");
1025 assert_eq!(new_name, "CSV Import V2");
1026 }
1027
1028 // ============ Corrupt Manifest After Initial Load ============
1029
1030 #[test]
1031 fn reload_with_corrupt_manifest_returns_error() {
1032 let temp_dir = TempDir::new().unwrap();
1033 create_csv_import_plugin(temp_dir.path());
1034
1035 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1036 registry.enable_plugin("csv-import").unwrap();
1037
1038 // Corrupt the manifest on disk
1039 let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml");
1040 std::fs::write(&manifest_path, "{{{{ not valid TOML !@#$").unwrap();
1041
1042 let result = registry.reload_plugin("csv-import");
1043 assert!(result.is_err());
1044 match result.unwrap_err() {
1045 PluginError::InvalidManifest(msg) => {
1046 assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg);
1047 }
1048 other => panic!("Expected InvalidManifest, got {:?}", other),
1049 }
1050 }
1051
1052 // ============ Permission Escalation ============
1053
1054 /// A plugin that initially has no capabilities should not gain them on
1055 /// Reload rejects capability escalation (false → true).
1056 #[test]
1057 fn reload_rejects_capability_escalation() {
1058 let temp_dir = TempDir::new().unwrap();
1059
1060 let plugin_dir = temp_dir.path().join("available").join("sneaky");
1061 std::fs::create_dir_all(&plugin_dir).unwrap();
1062
1063 let safe_manifest = r#"
1064 [plugin]
1065 name = "Sneaky Plugin"
1066 version = "1.0.0"
1067 description = "Starts safe"
1068
1069 [plugin.type]
1070 kind = "command"
1071
1072 [plugin.capabilities]
1073 file_read = false
1074 database_write = false
1075 network = false
1076 "#;
1077 std::fs::write(plugin_dir.join("plugin.toml"), safe_manifest).unwrap();
1078 let script = r#"
1079 fn describe() { #{ name: "Sneaky" } }
1080 fn execute(args) { 42 }
1081 "#;
1082 std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
1083
1084 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1085 registry.enable_plugin("sneaky").unwrap();
1086
1087 assert!(!registry.loader().get_plugin("sneaky").unwrap().meta.capabilities.file_read);
1088
1089 // Attacker modifies manifest to escalate permissions
1090 let escalated_manifest = r#"
1091 [plugin]
1092 name = "Sneaky Plugin"
1093 version = "1.0.1"
1094 description = "Now wants everything"
1095
1096 [plugin.type]
1097 kind = "command"
1098
1099 [plugin.capabilities]
1100 file_read = true
1101 database_write = true
1102 network = true
1103 "#;
1104 std::fs::write(plugin_dir.join("plugin.toml"), escalated_manifest).unwrap();
1105
1106 // Reload must reject the escalation
1107 let err = registry.reload_plugin("sneaky").unwrap_err();
1108 match err {
1109 PluginError::CapabilityEscalation { plugin, details } => {
1110 assert_eq!(plugin, "sneaky");
1111 assert!(details.contains("file_read"));
1112 assert!(details.contains("database_write"));
1113 assert!(details.contains("network"));
1114 }
1115 other => panic!("Expected CapabilityEscalation, got {:?}", other),
1116 }
1117
1118 // Plugin should be evicted from cache after escalation rejection
1119 assert!(registry.loader().get_plugin("sneaky").is_none());
1120 }
1121
1122 /// Reload allows de-escalation (removing capabilities).
1123 #[test]
1124 fn reload_allows_deescalation() {
1125 let temp_dir = TempDir::new().unwrap();
1126
1127 let plugin_dir = temp_dir.path().join("available").join("generous");
1128 std::fs::create_dir_all(&plugin_dir).unwrap();
1129
1130 let full_manifest = r#"
1131 [plugin]
1132 name = "Generous"
1133 version = "1.0.0"
1134 description = "Has all capabilities"
1135
1136 [plugin.type]
1137 kind = "command"
1138
1139 [plugin.capabilities]
1140 file_read = true
1141 database_write = true
1142 network = true
1143 "#;
1144 std::fs::write(plugin_dir.join("plugin.toml"), full_manifest).unwrap();
1145 std::fs::write(plugin_dir.join("main.rhai"), "fn describe() { #{} }\nfn execute(a) { 0 }").unwrap();
1146
1147 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1148 registry.enable_plugin("generous").unwrap();
1149
1150 // De-escalate: remove all capabilities
1151 let reduced_manifest = r#"
1152 [plugin]
1153 name = "Generous"
1154 version = "1.1.0"
1155 description = "Reduced capabilities"
1156
1157 [plugin.type]
1158 kind = "command"
1159
1160 [plugin.capabilities]
1161 file_read = false
1162 database_write = false
1163 network = false
1164 "#;
1165 std::fs::write(plugin_dir.join("plugin.toml"), reduced_manifest).unwrap();
1166
1167 let reloaded = registry.reload_plugin("generous").unwrap();
1168 assert!(!reloaded.capabilities.file_read);
1169 assert!(!reloaded.capabilities.database_write);
1170 assert!(!reloaded.capabilities.network);
1171 }
1172
1173 /// Reload succeeds when capabilities are unchanged.
1174 #[test]
1175 fn reload_succeeds_unchanged_capabilities() {
1176 let temp_dir = TempDir::new().unwrap();
1177 create_csv_import_plugin(temp_dir.path());
1178
1179 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1180 registry.enable_plugin("csv-import").unwrap();
1181
1182 let original_caps = registry.loader().get_plugin("csv-import").unwrap().meta.capabilities.clone();
1183
1184 let reloaded = registry.reload_plugin("csv-import").unwrap();
1185 assert_eq!(reloaded.capabilities, original_caps);
1186 }
1187
1188 // --- with_engine constructor ---
1189
1190 #[test]
1191 fn with_engine_uses_custom_engine() {
1192 let temp_dir = TempDir::new().unwrap();
1193 create_csv_import_plugin(temp_dir.path());
1194
1195 let custom_engine = Arc::new(PluginEngine::new());
1196 let registry =
1197 PluginRegistry::with_engine(temp_dir.path(), custom_engine).unwrap();
1198
1199 let plugins = registry.list_import_plugins().unwrap();
1200 assert_eq!(plugins.len(), 1);
1201 }
1202
1203 // --- list_import_plugins filtering ---
1204
1205 #[test]
1206 fn list_import_plugins_excludes_non_import() {
1207 let temp_dir = TempDir::new().unwrap();
1208 create_csv_import_plugin(temp_dir.path());
1209 create_command_plugin(temp_dir.path());
1210
1211 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
1212 let import_plugins = registry.list_import_plugins().unwrap();
1213
1214 assert_eq!(import_plugins.len(), 1);
1215 assert_eq!(import_plugins[0].name, "CSV Import");
1216 }
1217
1218 #[test]
1219 fn list_import_plugins_empty_dir() {
1220 let temp_dir = TempDir::new().unwrap();
1221 let registry = PluginRegistry::new(temp_dir.path()).unwrap();
1222 let plugins = registry.list_import_plugins().unwrap();
1223 assert!(plugins.is_empty());
1224 }
1225
1226 // --- loader accessors ---
1227
1228 #[test]
1229 fn loader_mut_allows_direct_manipulation() {
1230 let temp_dir = TempDir::new().unwrap();
1231 create_csv_import_plugin(temp_dir.path());
1232
1233 let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1234
1235 // Load directly through the mutable loader
1236 let available = temp_dir.path().join("available/csv-import");
1237 let loaded = registry.loader_mut().load_plugin("csv-import", &available).unwrap();
1238 assert_eq!(loaded.meta.name, "CSV Import");
1239
1240 // Verify it shows up via the registry
1241 assert_eq!(registry.iter_loaded().count(), 1);
1242 }
1243 }
1244