Skip to main content

sen_plugin_host/
discovery.rs

1//! Plugin discovery and directory scanning
2//!
3//! Automatically discovers and loads plugins from filesystem directories.
4
5use crate::{LoadedPlugin, LoaderError, PluginLoader};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9/// Errors that can occur during plugin discovery
10#[derive(Debug, Error)]
11pub enum DiscoveryError {
12    #[error("Directory not found: {0}")]
13    DirectoryNotFound(PathBuf),
14
15    #[error("Failed to read directory: {0}")]
16    ReadDirectory(#[source] std::io::Error),
17
18    #[error("Failed to load plugin {path}: {source}")]
19    LoadPlugin {
20        path: PathBuf,
21        #[source]
22        source: LoaderError,
23    },
24}
25
26/// Result of plugin discovery
27pub struct DiscoveryResult {
28    /// Successfully loaded plugins
29    pub plugins: Vec<LoadedPlugin>,
30
31    /// Plugins that failed to load (with errors)
32    pub failures: Vec<(PathBuf, DiscoveryError)>,
33}
34
35impl DiscoveryResult {
36    /// Returns true if all plugins loaded successfully
37    pub fn is_success(&self) -> bool {
38        self.failures.is_empty()
39    }
40
41    /// Total number of plugin files found
42    pub fn total_found(&self) -> usize {
43        self.plugins.len() + self.failures.len()
44    }
45}
46
47/// Plugin directory scanner
48pub struct PluginScanner {
49    loader: PluginLoader,
50}
51
52impl PluginScanner {
53    /// Create a new plugin scanner
54    pub fn new() -> Result<Self, LoaderError> {
55        Ok(Self {
56            loader: PluginLoader::new()?,
57        })
58    }
59
60    /// Create with an existing loader
61    pub fn with_loader(loader: PluginLoader) -> Self {
62        Self { loader }
63    }
64
65    /// Scan a directory for .wasm plugin files
66    pub fn scan_directory(&self, dir: impl AsRef<Path>) -> Result<DiscoveryResult, DiscoveryError> {
67        let dir = dir.as_ref();
68
69        if !dir.exists() {
70            return Err(DiscoveryError::DirectoryNotFound(dir.to_path_buf()));
71        }
72
73        if !dir.is_dir() {
74            return Err(DiscoveryError::DirectoryNotFound(dir.to_path_buf()));
75        }
76
77        let entries = std::fs::read_dir(dir).map_err(DiscoveryError::ReadDirectory)?;
78
79        let mut plugins = Vec::new();
80        let mut failures = Vec::new();
81
82        for entry in entries {
83            let entry = match entry {
84                Ok(e) => e,
85                Err(e) => {
86                    failures.push((dir.to_path_buf(), DiscoveryError::ReadDirectory(e)));
87                    continue;
88                }
89            };
90
91            let path = entry.path();
92
93            // Only process .wasm files
94            if path.extension().map(|e| e == "wasm").unwrap_or(false) {
95                match self.load_plugin(&path) {
96                    Ok(plugin) => plugins.push(plugin),
97                    Err(e) => failures.push((path, e)),
98                }
99            }
100        }
101
102        Ok(DiscoveryResult { plugins, failures })
103    }
104
105    /// Scan multiple directories
106    pub fn scan_directories(
107        &self,
108        dirs: impl IntoIterator<Item = impl AsRef<Path>>,
109    ) -> DiscoveryResult {
110        let mut all_plugins = Vec::new();
111        let mut all_failures = Vec::new();
112
113        for dir in dirs {
114            match self.scan_directory(dir) {
115                Ok(result) => {
116                    all_plugins.extend(result.plugins);
117                    all_failures.extend(result.failures);
118                }
119                Err(e) => {
120                    // Directory-level errors
121                    if let DiscoveryError::DirectoryNotFound(path) = &e {
122                        all_failures.push((path.clone(), e));
123                    }
124                }
125            }
126        }
127
128        DiscoveryResult {
129            plugins: all_plugins,
130            failures: all_failures,
131        }
132    }
133
134    /// Load a single plugin file
135    fn load_plugin(&self, path: &Path) -> Result<LoadedPlugin, DiscoveryError> {
136        let wasm_bytes = std::fs::read(path).map_err(|e| DiscoveryError::LoadPlugin {
137            path: path.to_path_buf(),
138            source: LoaderError::MemoryAccess(format!("Failed to read file: {}", e)),
139        })?;
140
141        self.loader
142            .load(&wasm_bytes)
143            .map_err(|e| DiscoveryError::LoadPlugin {
144                path: path.to_path_buf(),
145                source: e,
146            })
147    }
148}
149
150/// Get default plugin directories for the current platform
151pub fn default_plugin_dirs(app_name: &str) -> Vec<PathBuf> {
152    let mut dirs = Vec::new();
153
154    // User-local plugins
155    if let Some(data_dir) = dirs::data_local_dir() {
156        dirs.push(data_dir.join(app_name).join("plugins"));
157    }
158
159    // Current directory plugins
160    dirs.push(PathBuf::from("plugins"));
161
162    dirs
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::fs;
169    use tempfile::TempDir;
170
171    #[test]
172    fn test_scan_empty_directory() {
173        let temp = TempDir::new().unwrap();
174        let scanner = PluginScanner::new().unwrap();
175
176        let result = scanner.scan_directory(temp.path()).unwrap();
177        assert!(result.plugins.is_empty());
178        assert!(result.failures.is_empty());
179        assert!(result.is_success());
180    }
181
182    #[test]
183    fn test_scan_nonexistent_directory() {
184        let scanner = PluginScanner::new().unwrap();
185        let result = scanner.scan_directory("/nonexistent/path/to/plugins");
186
187        assert!(result.is_err());
188        match result {
189            Err(DiscoveryError::DirectoryNotFound(_)) => {}
190            _ => panic!("Expected DirectoryNotFound error"),
191        }
192    }
193
194    #[test]
195    fn test_scan_with_wasm_file() {
196        let temp = TempDir::new().unwrap();
197
198        // Copy hello plugin to temp directory
199        let wasm_bytes = include_bytes!(
200            "../../examples/hello-plugin/target/wasm32-unknown-unknown/release/hello_plugin.wasm"
201        );
202        let plugin_path = temp.path().join("hello.wasm");
203        fs::write(&plugin_path, wasm_bytes).unwrap();
204
205        let scanner = PluginScanner::new().unwrap();
206        let result = scanner.scan_directory(temp.path()).unwrap();
207
208        assert_eq!(result.plugins.len(), 1);
209        assert!(result.failures.is_empty());
210        assert_eq!(result.plugins[0].manifest.command.name, "hello");
211    }
212
213    #[test]
214    fn test_scan_ignores_non_wasm_files() {
215        let temp = TempDir::new().unwrap();
216
217        // Create non-wasm files
218        fs::write(temp.path().join("readme.txt"), "Hello").unwrap();
219        fs::write(temp.path().join("config.json"), "{}").unwrap();
220
221        let scanner = PluginScanner::new().unwrap();
222        let result = scanner.scan_directory(temp.path()).unwrap();
223
224        assert!(result.plugins.is_empty());
225        assert!(result.failures.is_empty());
226    }
227
228    #[test]
229    fn test_default_plugin_dirs() {
230        let dirs = default_plugin_dirs("myapp");
231        assert!(!dirs.is_empty());
232        assert!(dirs.iter().any(|d| d.ends_with("plugins")));
233    }
234}