mielin_cli/
plugin.rs

1//! Plugin System for MielinCTL
2//!
3//! Provides a secure, extensible plugin architecture using WASM modules.
4//! Plugins can add custom commands, extend functionality, and integrate
5//! with external tools.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, info, warn};
13
14/// Plugin metadata describing the plugin's capabilities
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PluginMetadata {
17    /// Plugin name (must be unique)
18    pub name: String,
19    /// Plugin version (semver)
20    pub version: String,
21    /// Short description
22    pub description: String,
23    /// Author information
24    pub author: String,
25    /// License identifier
26    pub license: String,
27    /// Commands provided by this plugin
28    pub commands: Vec<PluginCommand>,
29    /// Plugin dependencies (other plugins)
30    #[serde(default)]
31    pub dependencies: Vec<String>,
32    /// Minimum MielinCTL version required
33    #[serde(default)]
34    pub min_version: Option<String>,
35}
36
37/// Command provided by a plugin
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PluginCommand {
40    /// Command name (will be accessible as `mielinctl plugin <name> <command>`)
41    pub name: String,
42    /// Command description
43    pub description: String,
44    /// Command aliases
45    #[serde(default)]
46    pub aliases: Vec<String>,
47    /// Arguments specification
48    #[serde(default)]
49    pub arguments: Vec<PluginArgument>,
50}
51
52/// Argument specification for plugin commands
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PluginArgument {
55    /// Argument name
56    pub name: String,
57    /// Argument description
58    pub description: String,
59    /// Whether argument is required
60    #[serde(default)]
61    pub required: bool,
62    /// Default value if not provided
63    #[serde(default)]
64    pub default: Option<String>,
65}
66
67/// Plugin execution context passed to plugin
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PluginContext {
70    /// Command name being executed
71    pub command: String,
72    /// Arguments provided to the command
73    pub arguments: HashMap<String, String>,
74    /// Environment variables
75    pub environment: HashMap<String, String>,
76    /// Current working directory
77    pub working_dir: String,
78    /// MielinCTL version
79    pub cli_version: String,
80}
81
82/// Plugin execution result
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PluginResult {
85    /// Exit code (0 = success)
86    pub exit_code: i32,
87    /// Standard output
88    pub stdout: String,
89    /// Standard error
90    pub stderr: String,
91    /// JSON data for structured output
92    #[serde(default)]
93    pub data: Option<serde_json::Value>,
94}
95
96/// Represents a loaded plugin
97#[derive(Debug, Clone)]
98pub struct Plugin {
99    /// Plugin metadata
100    pub metadata: PluginMetadata,
101    /// Path to the WASM module
102    pub wasm_path: PathBuf,
103    /// Plugin enabled state
104    pub enabled: bool,
105}
106
107impl Plugin {
108    /// Load a plugin from a directory
109    pub fn load_from_dir(path: &Path) -> Result<Self> {
110        let metadata_path = path.join("plugin.toml");
111        let wasm_path = path.join("plugin.wasm");
112
113        if !metadata_path.exists() {
114            anyhow::bail!("Plugin metadata file not found: {:?}", metadata_path);
115        }
116
117        if !wasm_path.exists() {
118            anyhow::bail!("Plugin WASM module not found: {:?}", wasm_path);
119        }
120
121        let metadata_content =
122            fs::read_to_string(&metadata_path).context("Failed to read plugin metadata")?;
123
124        let metadata: PluginMetadata =
125            toml::from_str(&metadata_content).context("Failed to parse plugin metadata")?;
126
127        // Validate metadata
128        if metadata.name.is_empty() {
129            anyhow::bail!("Plugin name cannot be empty");
130        }
131
132        if metadata.version.is_empty() {
133            anyhow::bail!("Plugin version cannot be empty");
134        }
135
136        Ok(Plugin {
137            metadata,
138            wasm_path,
139            enabled: true,
140        })
141    }
142
143    /// Execute a plugin command
144    pub async fn execute(&self, context: PluginContext) -> Result<PluginResult> {
145        debug!(
146            "Executing plugin command: {} - {}",
147            self.metadata.name, context.command
148        );
149
150        // Serialize context to JSON for passing to WASM
151        let context_json =
152            serde_json::to_string(&context).context("Failed to serialize plugin context")?;
153
154        // Load and execute WASM module using mielin-wasm
155        let result = self.execute_wasm(&context_json).await?;
156
157        Ok(result)
158    }
159
160    /// Execute WASM module using mielin-wasm
161    async fn execute_wasm(&self, context_json: &str) -> Result<PluginResult> {
162        use mielin_wasm::executor::WasmExecutor;
163
164        debug!("Loading WASM module for plugin: {}", self.metadata.name);
165
166        // Read the WASM module from file
167        let wasm_bytes = fs::read(&self.wasm_path).context("Failed to read WASM module file")?;
168
169        // Create WASM executor
170        let executor = WasmExecutor::new()
171            .map_err(|e| anyhow::anyhow!("Failed to create WASM executor: {}", e))?;
172
173        // Compile the module
174        let module = executor
175            .compile_module(&wasm_bytes)
176            .map_err(|e| anyhow::anyhow!("Failed to compile WASM module: {}", e))?;
177
178        // Instantiate the module
179        let (instance, mut store) = executor
180            .instantiate(
181                &module,
182                mielin_hal::capabilities::HardwareCapabilities::NONE,
183            )
184            .map_err(|e| anyhow::anyhow!("Failed to instantiate WASM module: {}", e))?;
185
186        // Try to find and call the main entry point function
187        // Common entry points: "main", "_start", "run", or the command name
188        let possible_entry_points = vec!["main", "_start", "run", &self.metadata.name];
189
190        let mut stdout = String::new();
191        let mut stderr = String::new();
192        let mut exit_code = 0;
193
194        // Try each possible entry point
195        let mut executed = false;
196        for entry_point in possible_entry_points {
197            if let Some(func) = instance.get_func(&mut store, entry_point) {
198                debug!("Found entry point: {}", entry_point);
199
200                // For now, we'll call the function without arguments
201                // In a full implementation, we would pass the context_json through memory
202                match func.call(&mut store, &[], &mut []) {
203                    Ok(_) => {
204                        stdout = format!(
205                            "Plugin {} executed successfully\nContext: {}",
206                            self.metadata.name, context_json
207                        );
208                        executed = true;
209                        break;
210                    }
211                    Err(e) => {
212                        stderr = format!("Execution error: {}", e);
213                        exit_code = 1;
214                        executed = true;
215                        break;
216                    }
217                }
218            }
219        }
220
221        if !executed {
222            // No entry point found, but module loaded successfully
223            stdout = format!(
224                "Plugin {} loaded successfully but no entry point found\nContext: {}",
225                self.metadata.name, context_json
226            );
227        }
228
229        Ok(PluginResult {
230            exit_code,
231            stdout,
232            stderr,
233            data: None,
234        })
235    }
236}
237
238/// Plugin manager for loading and managing plugins
239pub struct PluginManager {
240    /// Loaded plugins indexed by name
241    plugins: HashMap<String, Plugin>,
242    /// Plugin directory path
243    plugin_dir: PathBuf,
244}
245
246impl PluginManager {
247    /// Create a new plugin manager
248    pub fn new() -> Result<Self> {
249        let plugin_dir = Self::get_plugin_dir()?;
250
251        // Create plugin directory if it doesn't exist
252        if !plugin_dir.exists() {
253            fs::create_dir_all(&plugin_dir).context("Failed to create plugin directory")?;
254            info!("Created plugin directory: {:?}", plugin_dir);
255        }
256
257        Ok(PluginManager {
258            plugins: HashMap::new(),
259            plugin_dir,
260        })
261    }
262
263    /// Get the default plugin directory
264    pub fn get_plugin_dir() -> Result<PathBuf> {
265        let config_dir =
266            dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Failed to get config directory"))?;
267        Ok(config_dir.join("mielin").join("plugins"))
268    }
269
270    /// Discover and load all plugins from the plugin directory
271    pub fn discover_plugins(&mut self) -> Result<usize> {
272        debug!("Discovering plugins in {:?}", self.plugin_dir);
273
274        let entries = fs::read_dir(&self.plugin_dir).context("Failed to read plugin directory")?;
275
276        let mut loaded_count = 0;
277
278        for entry in entries {
279            let entry = match entry {
280                Ok(e) => e,
281                Err(e) => {
282                    warn!("Failed to read directory entry: {}", e);
283                    continue;
284                }
285            };
286
287            let path = entry.path();
288            if !path.is_dir() {
289                continue;
290            }
291
292            match Plugin::load_from_dir(&path) {
293                Ok(plugin) => {
294                    let name = plugin.metadata.name.clone();
295                    info!("Loaded plugin: {} v{}", name, plugin.metadata.version);
296                    self.plugins.insert(name, plugin);
297                    loaded_count += 1;
298                }
299                Err(e) => {
300                    warn!("Failed to load plugin from {:?}: {}", path, e);
301                }
302            }
303        }
304
305        info!("Discovered {} plugins", loaded_count);
306        Ok(loaded_count)
307    }
308
309    /// Get a plugin by name
310    pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
311        self.plugins.get(name)
312    }
313
314    /// List all loaded plugins
315    pub fn list_plugins(&self) -> Vec<&Plugin> {
316        self.plugins.values().collect()
317    }
318
319    /// Execute a plugin command
320    pub async fn execute_command(
321        &self,
322        plugin_name: &str,
323        command_name: &str,
324        arguments: HashMap<String, String>,
325    ) -> Result<PluginResult> {
326        let plugin = self
327            .get_plugin(plugin_name)
328            .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", plugin_name))?;
329
330        if !plugin.enabled {
331            anyhow::bail!("Plugin is disabled: {}", plugin_name);
332        }
333
334        // Verify command exists
335        let command_exists =
336            plugin.metadata.commands.iter().any(|cmd| {
337                cmd.name == command_name || cmd.aliases.contains(&command_name.to_string())
338            });
339
340        if !command_exists {
341            anyhow::bail!("Command not found in plugin: {}", command_name);
342        }
343
344        // Build execution context
345        let context = PluginContext {
346            command: command_name.to_string(),
347            arguments,
348            environment: std::env::vars().collect(),
349            working_dir: std::env::current_dir()
350                .context("Failed to get current directory")?
351                .to_string_lossy()
352                .to_string(),
353            cli_version: env!("CARGO_PKG_VERSION").to_string(),
354        };
355
356        plugin.execute(context).await
357    }
358
359    /// Install a plugin from a path
360    pub fn install_plugin(&mut self, source_path: &Path) -> Result<()> {
361        let plugin =
362            Plugin::load_from_dir(source_path).context("Failed to load plugin from source")?;
363
364        let dest_path = self.plugin_dir.join(&plugin.metadata.name);
365
366        if dest_path.exists() {
367            anyhow::bail!("Plugin already installed: {}", plugin.metadata.name);
368        }
369
370        // Copy plugin directory
371        fs::create_dir_all(&dest_path).context("Failed to create plugin directory")?;
372
373        fs::copy(
374            source_path.join("plugin.toml"),
375            dest_path.join("plugin.toml"),
376        )
377        .context("Failed to copy plugin metadata")?;
378
379        fs::copy(
380            source_path.join("plugin.wasm"),
381            dest_path.join("plugin.wasm"),
382        )
383        .context("Failed to copy plugin WASM")?;
384
385        info!(
386            "Installed plugin: {} v{}",
387            plugin.metadata.name, plugin.metadata.version
388        );
389
390        // Reload plugins
391        self.discover_plugins()?;
392
393        Ok(())
394    }
395
396    /// Uninstall a plugin
397    pub fn uninstall_plugin(&mut self, name: &str) -> Result<()> {
398        if !self.plugins.contains_key(name) {
399            anyhow::bail!("Plugin not found: {}", name);
400        }
401
402        let plugin_path = self.plugin_dir.join(name);
403        if plugin_path.exists() {
404            fs::remove_dir_all(&plugin_path).context("Failed to remove plugin directory")?;
405        }
406
407        self.plugins.remove(name);
408        info!("Uninstalled plugin: {}", name);
409
410        Ok(())
411    }
412
413    /// Enable a plugin
414    pub fn enable_plugin(&mut self, name: &str) -> Result<()> {
415        let plugin = self
416            .plugins
417            .get_mut(name)
418            .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
419
420        plugin.enabled = true;
421        info!("Enabled plugin: {}", name);
422        Ok(())
423    }
424
425    /// Disable a plugin
426    pub fn disable_plugin(&mut self, name: &str) -> Result<()> {
427        let plugin = self
428            .plugins
429            .get_mut(name)
430            .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
431
432        plugin.enabled = false;
433        info!("Disabled plugin: {}", name);
434        Ok(())
435    }
436}
437
438impl Default for PluginManager {
439    fn default() -> Self {
440        Self::new().expect("Failed to create plugin manager")
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use std::env;
448    use std::fs;
449
450    #[test]
451    fn test_plugin_metadata_serialization() {
452        let metadata = PluginMetadata {
453            name: "test-plugin".to_string(),
454            version: "1.0.0".to_string(),
455            description: "Test plugin".to_string(),
456            author: "Test Author".to_string(),
457            license: "MIT".to_string(),
458            commands: vec![PluginCommand {
459                name: "hello".to_string(),
460                description: "Say hello".to_string(),
461                aliases: vec!["hi".to_string()],
462                arguments: vec![],
463            }],
464            dependencies: vec![],
465            min_version: Some("0.1.0".to_string()),
466        };
467
468        let toml_str = toml::to_string(&metadata).unwrap();
469        let deserialized: PluginMetadata = toml::from_str(&toml_str).unwrap();
470
471        assert_eq!(metadata.name, deserialized.name);
472        assert_eq!(metadata.version, deserialized.version);
473        assert_eq!(metadata.commands.len(), deserialized.commands.len());
474    }
475
476    #[test]
477    fn test_plugin_context_serialization() {
478        let mut arguments = HashMap::new();
479        arguments.insert("name".to_string(), "world".to_string());
480
481        let mut environment = HashMap::new();
482        environment.insert("PATH".to_string(), "/usr/bin".to_string());
483
484        let context = PluginContext {
485            command: "hello".to_string(),
486            arguments,
487            environment,
488            working_dir: "/tmp".to_string(),
489            cli_version: "0.1.0".to_string(),
490        };
491
492        let json_str = serde_json::to_string(&context).unwrap();
493        let deserialized: PluginContext = serde_json::from_str(&json_str).unwrap();
494
495        assert_eq!(context.command, deserialized.command);
496        assert_eq!(context.working_dir, deserialized.working_dir);
497    }
498
499    #[test]
500    fn test_plugin_result_serialization() {
501        let result = PluginResult {
502            exit_code: 0,
503            stdout: "Hello, world!".to_string(),
504            stderr: String::new(),
505            data: Some(serde_json::json!({"status": "success"})),
506        };
507
508        let json_str = serde_json::to_string(&result).unwrap();
509        let deserialized: PluginResult = serde_json::from_str(&json_str).unwrap();
510
511        assert_eq!(result.exit_code, deserialized.exit_code);
512        assert_eq!(result.stdout, deserialized.stdout);
513        assert!(deserialized.data.is_some());
514    }
515
516    #[test]
517    fn test_plugin_manager_creation() {
518        let manager = PluginManager::new();
519        assert!(manager.is_ok());
520
521        let manager = manager.unwrap();
522        assert_eq!(manager.plugins.len(), 0);
523    }
524
525    #[tokio::test]
526    async fn test_plugin_load_from_invalid_dir() {
527        let temp_dir = env::temp_dir().join("test_invalid_plugin");
528        let _ = fs::create_dir_all(&temp_dir);
529
530        let result = Plugin::load_from_dir(&temp_dir);
531        assert!(result.is_err());
532
533        let _ = fs::remove_dir_all(&temp_dir);
534    }
535
536    #[test]
537    fn test_plugin_argument_validation() {
538        let arg = PluginArgument {
539            name: "input".to_string(),
540            description: "Input file".to_string(),
541            required: true,
542            default: None,
543        };
544
545        assert_eq!(arg.name, "input");
546        assert!(arg.required);
547        assert!(arg.default.is_none());
548    }
549}