vanguard_plugin/
loader.rs

1use std::{collections::HashMap, fs, path::PathBuf, sync::Arc};
2
3use async_trait::async_trait;
4use semver::Version;
5use thiserror::Error;
6use tokio::sync::RwLock;
7
8use crate::{PluginMetadata, ValidationResult, VanguardPlugin};
9
10/// A test plugin implementation for development and testing
11#[derive(Debug)]
12struct TestPlugin {
13    metadata: PluginMetadata,
14}
15
16#[async_trait]
17impl VanguardPlugin for TestPlugin {
18    fn metadata(&self) -> &PluginMetadata {
19        &self.metadata
20    }
21
22    async fn validate(&self) -> ValidationResult {
23        ValidationResult::Passed
24    }
25
26    async fn initialize(&self) -> Result<(), String> {
27        Ok(())
28    }
29
30    async fn cleanup(&self) -> Result<(), String> {
31        Ok(())
32    }
33}
34
35/// Errors that can occur during plugin loading
36#[derive(Error, Debug)]
37pub enum LoaderError {
38    /// Plugin not found
39    #[error("Plugin not found: {0}")]
40    NotFound(String),
41
42    /// Plugin already loaded
43    #[error("Plugin already loaded: {0}")]
44    AlreadyLoaded(String),
45
46    /// Plugin loading failed
47    #[error("Failed to load plugin: {0}")]
48    LoadFailed(String),
49
50    /// Plugin validation failed
51    #[error("Plugin validation failed: {0}")]
52    ValidationFailed(String),
53
54    /// Plugin dependency error
55    #[error("Plugin dependency error: {name} requires {dependency} {version}")]
56    DependencyError {
57        /// Name of the plugin that has the dependency
58        name: String,
59        /// Name of the required dependency
60        dependency: String,
61        /// Required version of the dependency
62        version: String,
63    },
64
65    /// IO error
66    #[error("IO error: {0}")]
67    Io(#[from] std::io::Error),
68}
69
70/// Configuration for the plugin loader
71#[derive(Debug, Clone)]
72pub struct LoaderConfig {
73    /// Base directory for plugin discovery
74    pub plugin_dir: PathBuf,
75    /// Current Vanguard version
76    pub vanguard_version: Version,
77    /// Whether to validate plugins on load
78    pub validate_on_load: bool,
79    /// Whether to check dependencies on load
80    pub check_dependencies: bool,
81}
82
83impl Default for LoaderConfig {
84    fn default() -> Self {
85        Self {
86            plugin_dir: PathBuf::from(".vanguard/plugins"),
87            vanguard_version: Version::new(0, 1, 0),
88            validate_on_load: true,
89            check_dependencies: true,
90        }
91    }
92}
93
94/// A plugin loader that manages plugin discovery, loading, and lifecycle
95#[derive(Debug)]
96pub struct PluginLoader {
97    /// Configuration for the plugin loader
98    config: LoaderConfig,
99    /// Map of loaded plugins by name
100    plugins: RwLock<HashMap<String, Arc<dyn VanguardPlugin>>>,
101}
102
103impl PluginLoader {
104    /// Create a new plugin loader with the given configuration
105    pub fn new(config: LoaderConfig) -> Self {
106        Self {
107            config,
108            plugins: RwLock::new(HashMap::new()),
109        }
110    }
111
112    /// Get the current configuration
113    pub fn config(&self) -> &LoaderConfig {
114        &self.config
115    }
116
117    /// Load a plugin by name
118    pub async fn load_plugin(&self, name: &str) -> Result<Arc<dyn VanguardPlugin>, LoaderError> {
119        // Check if already loaded before acquiring write lock
120        {
121            let plugins = self.plugins.read().await;
122            if plugins.contains_key(name) {
123                return Err(LoaderError::AlreadyLoaded(name.to_string()));
124            }
125        }
126
127        // Find plugin metadata file
128        let plugin_path = self.config.plugin_dir.join(format!("{}.json", name));
129        if !plugin_path.exists() {
130            return Err(LoaderError::NotFound(name.to_string()));
131        }
132
133        // Read and parse plugin metadata
134        let content = fs::read_to_string(&plugin_path).map_err(LoaderError::Io)?;
135
136        let metadata: PluginMetadata = serde_json::from_str(&content).map_err(|e| {
137            LoaderError::ValidationFailed(format!("Invalid plugin metadata: {}", e))
138        })?;
139
140        // Create test plugin instance for now
141        // TODO: Replace with actual plugin loading from dynamic library
142        let plugin = Arc::new(TestPlugin { metadata }) as Arc<dyn VanguardPlugin>;
143
144        // Validate plugin if enabled
145        if self.config.validate_on_load {
146            match plugin.validate().await {
147                ValidationResult::Passed => {}
148                ValidationResult::Failed(reason) => {
149                    return Err(LoaderError::ValidationFailed(reason));
150                }
151            }
152        }
153
154        // Check dependencies if enabled
155        if self.config.check_dependencies {
156            self.check_dependencies(plugin.as_ref()).await?;
157        }
158
159        // Only acquire write lock after all validation is done
160        let mut plugins = self.plugins.write().await;
161
162        // Double-check it wasn't loaded while we were validating
163        if plugins.contains_key(name) {
164            return Err(LoaderError::AlreadyLoaded(name.to_string()));
165        }
166
167        // Store and return the plugin
168        plugins.insert(name.to_string(), plugin.clone());
169        Ok(plugin)
170    }
171
172    /// Get a loaded plugin by name
173    pub async fn get_plugin(&self, name: &str) -> Option<Arc<dyn VanguardPlugin>> {
174        self.plugins.read().await.get(name).cloned()
175    }
176
177    /// List all loaded plugins
178    pub async fn list_plugins(&self) -> Vec<PluginMetadata> {
179        self.plugins
180            .read()
181            .await
182            .values()
183            .map(|p| p.metadata().clone())
184            .collect()
185    }
186
187    /// Unload a plugin by name
188    pub async fn unload_plugin(&self, name: &str) -> Result<(), LoaderError> {
189        let mut plugins = self.plugins.write().await;
190
191        if let Some(plugin) = plugins.remove(name) {
192            // Clean up plugin resources
193            if let Err(e) = plugin.cleanup().await {
194                // Re-insert the plugin if cleanup fails
195                plugins.insert(name.to_string(), plugin);
196                return Err(LoaderError::LoadFailed(format!(
197                    "Failed to cleanup plugin: {}",
198                    e
199                )));
200            }
201            Ok(())
202        } else {
203            Err(LoaderError::NotFound(name.to_string()))
204        }
205    }
206
207    /// Check if all plugin dependencies are satisfied
208    #[allow(dead_code)] // Will be used when implementing plugin loading
209    async fn check_dependencies(&self, plugin: &dyn VanguardPlugin) -> Result<(), LoaderError> {
210        // Get a snapshot of current dependencies to avoid holding the lock
211        let dependencies: Vec<_> = {
212            let plugins = self.plugins.read().await;
213            plugin
214                .metadata()
215                .dependencies
216                .iter()
217                .map(|dep| {
218                    let loaded_version =
219                        plugins.get(&dep.name).map(|p| p.metadata().version.clone());
220                    (dep.clone(), loaded_version)
221                })
222                .collect()
223        };
224
225        // Check each dependency without holding the lock
226        for (dep, loaded_version) in dependencies {
227            match loaded_version {
228                Some(version) => {
229                    // Simple version match for now, can be enhanced with semver requirements later
230                    if version != dep.version {
231                        return Err(LoaderError::DependencyError {
232                            name: plugin.metadata().name.clone(),
233                            dependency: dep.name,
234                            version: dep.version,
235                        });
236                    }
237                }
238                None => {
239                    return Err(LoaderError::DependencyError {
240                        name: plugin.metadata().name.clone(),
241                        dependency: dep.name,
242                        version: dep.version,
243                    });
244                }
245            }
246        }
247
248        Ok(())
249    }
250
251    /// Discover available plugins in the plugin directory
252    pub async fn discover_plugins(&self) -> Result<Vec<PluginMetadata>, LoaderError> {
253        let mut discovered = Vec::new();
254
255        // Create plugin directory if it doesn't exist
256        if !self.config.plugin_dir.exists() {
257            fs::create_dir_all(&self.config.plugin_dir).map_err(LoaderError::Io)?;
258            return Ok(discovered);
259        }
260
261        // Read plugin directory
262        let entries = fs::read_dir(&self.config.plugin_dir).map_err(LoaderError::Io)?;
263
264        // Process each entry
265        for entry in entries {
266            let entry = entry.map_err(LoaderError::Io)?;
267            let path = entry.path();
268
269            // Only process .json files
270            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
271                continue;
272            }
273
274            // Read and parse plugin metadata
275            let content = fs::read_to_string(&path).map_err(LoaderError::Io)?;
276
277            match serde_json::from_str::<PluginMetadata>(&content) {
278                Ok(metadata) => {
279                    discovered.push(metadata);
280                }
281                Err(e) => {
282                    // Log invalid plugin but continue processing others
283                    eprintln!("Failed to parse plugin metadata from {:?}: {}", path, e);
284                }
285            }
286        }
287
288        Ok(discovered)
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use tempfile::TempDir;
296
297    #[allow(dead_code)]
298    fn create_test_plugin(name: &str, version: &str) -> TestPlugin {
299        TestPlugin {
300            metadata: PluginMetadata {
301                name: name.to_string(),
302                version: version.to_string(),
303                description: "Test Plugin".to_string(),
304                author: "Test Author".to_string(),
305                min_vanguard_version: Some("0.1.0".to_string()),
306                max_vanguard_version: Some("2.0.0".to_string()),
307                dependencies: vec![],
308            },
309        }
310    }
311
312    #[tokio::test]
313    async fn test_loader_config() {
314        let config = LoaderConfig {
315            plugin_dir: PathBuf::from("/test/plugins"),
316            vanguard_version: Version::new(1, 0, 0),
317            validate_on_load: true,
318            check_dependencies: true,
319        };
320
321        let loader = PluginLoader::new(config.clone());
322        assert_eq!(loader.config().plugin_dir, PathBuf::from("/test/plugins"));
323        assert_eq!(loader.config().vanguard_version, Version::new(1, 0, 0));
324    }
325
326    #[tokio::test]
327    async fn test_plugin_not_found() {
328        let loader = PluginLoader::new(LoaderConfig::default());
329        let result = loader.load_plugin("nonexistent").await;
330        assert!(matches!(result, Err(LoaderError::NotFound(_))));
331    }
332
333    #[tokio::test]
334    async fn test_list_plugins() {
335        let loader = PluginLoader::new(LoaderConfig::default());
336        let plugins = loader.list_plugins().await;
337        assert!(plugins.is_empty());
338    }
339
340    #[tokio::test]
341    async fn test_unload_nonexistent() {
342        let loader = PluginLoader::new(LoaderConfig::default());
343        let result = loader.unload_plugin("nonexistent").await;
344        assert!(matches!(result, Err(LoaderError::NotFound(_))));
345    }
346
347    #[tokio::test]
348    async fn test_discover_plugins() {
349        let temp_dir = TempDir::new().unwrap();
350        let plugin_dir = temp_dir.path().join("plugins");
351        fs::create_dir_all(&plugin_dir).unwrap();
352
353        // Create a mock plugin file
354        let plugin_path = plugin_dir.join("test-plugin.json");
355        let plugin_meta = serde_json::json!({
356            "name": "test-plugin",
357            "version": "1.0.0",
358            "author": "Test Author",
359            "description": "Test Plugin",
360            "license": "MIT",
361            "min_vanguard_version": "0.1.0",
362            "max_vanguard_version": null,
363            "supported_platforms": [
364                { "os": "linux", "arch": "x86_64" }
365            ],
366            "dependencies": []
367        });
368        fs::write(&plugin_path, plugin_meta.to_string()).unwrap();
369
370        let config = LoaderConfig {
371            plugin_dir,
372            vanguard_version: Version::new(0, 1, 0),
373            validate_on_load: true,
374            check_dependencies: true,
375        };
376
377        let loader = PluginLoader::new(config);
378        let discovered = loader.discover_plugins().await.unwrap();
379
380        assert_eq!(discovered.len(), 1);
381        assert_eq!(discovered[0].name, "test-plugin");
382    }
383
384    #[tokio::test]
385    async fn test_load_plugin_validation() {
386        let temp_dir = TempDir::new().unwrap();
387        let plugin_dir = temp_dir.path().join("plugins");
388        fs::create_dir_all(&plugin_dir).unwrap();
389
390        // Create an invalid plugin (missing required fields)
391        let plugin_path = plugin_dir.join("invalid-plugin.json");
392        let plugin_meta = serde_json::json!({
393            "name": "invalid-plugin"
394        });
395        fs::write(&plugin_path, plugin_meta.to_string()).unwrap();
396
397        let config = LoaderConfig {
398            plugin_dir,
399            vanguard_version: Version::new(0, 1, 0),
400            validate_on_load: true,
401            check_dependencies: true,
402        };
403
404        let loader = PluginLoader::new(config);
405        let result = loader.load_plugin("invalid-plugin").await;
406
407        assert!(matches!(result, Err(LoaderError::ValidationFailed(_))));
408    }
409
410    #[tokio::test]
411    async fn test_load_plugin_dependencies() {
412        let temp_dir = TempDir::new().unwrap();
413        let plugin_dir = temp_dir.path().join("plugins");
414        fs::create_dir_all(&plugin_dir).unwrap();
415
416        // Create a plugin with a dependency
417        let plugin_path = plugin_dir.join("dependent-plugin.json");
418        let plugin_meta = serde_json::json!({
419            "name": "dependent-plugin",
420            "version": "1.0.0",
421            "author": "Test Author",
422            "description": "Test Plugin",
423            "license": "MIT",
424            "min_vanguard_version": "0.1.0",
425            "max_vanguard_version": null,
426            "dependencies": [
427                {
428                    "name": "base-plugin",
429                    "version": "1.0.0"
430                }
431            ]
432        });
433        fs::write(&plugin_path, plugin_meta.to_string()).unwrap();
434
435        let config = LoaderConfig {
436            plugin_dir,
437            vanguard_version: Version::new(0, 1, 0),
438            validate_on_load: true,
439            check_dependencies: true,
440        };
441
442        let loader = PluginLoader::new(config);
443        let result = loader.load_plugin("dependent-plugin").await;
444
445        assert!(matches!(result, Err(LoaderError::DependencyError { .. })));
446    }
447}