1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PluginMetadata {
17 pub name: String,
19 pub version: String,
21 pub description: String,
23 pub author: String,
25 pub license: String,
27 pub commands: Vec<PluginCommand>,
29 #[serde(default)]
31 pub dependencies: Vec<String>,
32 #[serde(default)]
34 pub min_version: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PluginCommand {
40 pub name: String,
42 pub description: String,
44 #[serde(default)]
46 pub aliases: Vec<String>,
47 #[serde(default)]
49 pub arguments: Vec<PluginArgument>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PluginArgument {
55 pub name: String,
57 pub description: String,
59 #[serde(default)]
61 pub required: bool,
62 #[serde(default)]
64 pub default: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PluginContext {
70 pub command: String,
72 pub arguments: HashMap<String, String>,
74 pub environment: HashMap<String, String>,
76 pub working_dir: String,
78 pub cli_version: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PluginResult {
85 pub exit_code: i32,
87 pub stdout: String,
89 pub stderr: String,
91 #[serde(default)]
93 pub data: Option<serde_json::Value>,
94}
95
96#[derive(Debug, Clone)]
98pub struct Plugin {
99 pub metadata: PluginMetadata,
101 pub wasm_path: PathBuf,
103 pub enabled: bool,
105}
106
107impl Plugin {
108 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 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 pub async fn execute(&self, context: PluginContext) -> Result<PluginResult> {
145 debug!(
146 "Executing plugin command: {} - {}",
147 self.metadata.name, context.command
148 );
149
150 let context_json =
152 serde_json::to_string(&context).context("Failed to serialize plugin context")?;
153
154 let result = self.execute_wasm(&context_json).await?;
156
157 Ok(result)
158 }
159
160 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 let wasm_bytes = fs::read(&self.wasm_path).context("Failed to read WASM module file")?;
168
169 let executor = WasmExecutor::new()
171 .map_err(|e| anyhow::anyhow!("Failed to create WASM executor: {}", e))?;
172
173 let module = executor
175 .compile_module(&wasm_bytes)
176 .map_err(|e| anyhow::anyhow!("Failed to compile WASM module: {}", e))?;
177
178 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 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 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 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 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
238pub struct PluginManager {
240 plugins: HashMap<String, Plugin>,
242 plugin_dir: PathBuf,
244}
245
246impl PluginManager {
247 pub fn new() -> Result<Self> {
249 let plugin_dir = Self::get_plugin_dir()?;
250
251 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 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 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 pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
311 self.plugins.get(name)
312 }
313
314 pub fn list_plugins(&self) -> Vec<&Plugin> {
316 self.plugins.values().collect()
317 }
318
319 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 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 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 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 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 self.discover_plugins()?;
392
393 Ok(())
394 }
395
396 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 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 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}