use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub license: String,
pub commands: Vec<PluginCommand>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub min_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginCommand {
pub name: String,
pub description: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub arguments: Vec<PluginArgument>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginArgument {
pub name: String,
pub description: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginContext {
pub command: String,
pub arguments: HashMap<String, String>,
pub environment: HashMap<String, String>,
pub working_dir: String,
pub cli_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
#[serde(default)]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct Plugin {
pub metadata: PluginMetadata,
pub wasm_path: PathBuf,
pub enabled: bool,
}
impl Plugin {
pub fn load_from_dir(path: &Path) -> Result<Self> {
let metadata_path = path.join("plugin.toml");
let wasm_path = path.join("plugin.wasm");
if !metadata_path.exists() {
anyhow::bail!("Plugin metadata file not found: {:?}", metadata_path);
}
if !wasm_path.exists() {
anyhow::bail!("Plugin WASM module not found: {:?}", wasm_path);
}
let metadata_content =
fs::read_to_string(&metadata_path).context("Failed to read plugin metadata")?;
let metadata: PluginMetadata =
toml::from_str(&metadata_content).context("Failed to parse plugin metadata")?;
if metadata.name.is_empty() {
anyhow::bail!("Plugin name cannot be empty");
}
if metadata.version.is_empty() {
anyhow::bail!("Plugin version cannot be empty");
}
Ok(Plugin {
metadata,
wasm_path,
enabled: true,
})
}
pub async fn execute(&self, context: PluginContext) -> Result<PluginResult> {
debug!(
"Executing plugin command: {} - {}",
self.metadata.name, context.command
);
let context_json =
serde_json::to_string(&context).context("Failed to serialize plugin context")?;
let result = self.execute_wasm(&context_json).await?;
Ok(result)
}
async fn execute_wasm(&self, context_json: &str) -> Result<PluginResult> {
use mielin_wasm::executor::WasmExecutor;
debug!("Loading WASM module for plugin: {}", self.metadata.name);
let wasm_bytes = fs::read(&self.wasm_path).context("Failed to read WASM module file")?;
let executor = WasmExecutor::new()
.map_err(|e| anyhow::anyhow!("Failed to create WASM executor: {}", e))?;
let module = executor
.compile_module(&wasm_bytes)
.map_err(|e| anyhow::anyhow!("Failed to compile WASM module: {}", e))?;
let (instance, mut store) = executor
.instantiate(
&module,
mielin_hal::capabilities::HardwareCapabilities::NONE,
)
.map_err(|e| anyhow::anyhow!("Failed to instantiate WASM module: {}", e))?;
let possible_entry_points = vec!["main", "_start", "run", &self.metadata.name];
let mut stdout = String::new();
let mut stderr = String::new();
let mut exit_code = 0;
let mut executed = false;
for entry_point in possible_entry_points {
if let Some(func) = instance.get_func(&mut store, entry_point) {
debug!("Found entry point: {}", entry_point);
match func.call(&mut store, &[], &mut []) {
Ok(_) => {
stdout = format!(
"Plugin {} executed successfully\nContext: {}",
self.metadata.name, context_json
);
executed = true;
break;
}
Err(e) => {
stderr = format!("Execution error: {}", e);
exit_code = 1;
executed = true;
break;
}
}
}
}
if !executed {
stdout = format!(
"Plugin {} loaded successfully but no entry point found\nContext: {}",
self.metadata.name, context_json
);
}
Ok(PluginResult {
exit_code,
stdout,
stderr,
data: None,
})
}
}
pub struct PluginManager {
plugins: HashMap<String, Plugin>,
plugin_dir: PathBuf,
}
impl PluginManager {
pub fn new() -> Result<Self> {
let plugin_dir = Self::get_plugin_dir()?;
if !plugin_dir.exists() {
fs::create_dir_all(&plugin_dir).context("Failed to create plugin directory")?;
info!("Created plugin directory: {:?}", plugin_dir);
}
Ok(PluginManager {
plugins: HashMap::new(),
plugin_dir,
})
}
pub fn get_plugin_dir() -> Result<PathBuf> {
let config_dir =
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Failed to get config directory"))?;
Ok(config_dir.join("mielin").join("plugins"))
}
pub fn discover_plugins(&mut self) -> Result<usize> {
debug!("Discovering plugins in {:?}", self.plugin_dir);
let entries = fs::read_dir(&self.plugin_dir).context("Failed to read plugin directory")?;
let mut loaded_count = 0;
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warn!("Failed to read directory entry: {}", e);
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
match Plugin::load_from_dir(&path) {
Ok(plugin) => {
let name = plugin.metadata.name.clone();
info!("Loaded plugin: {} v{}", name, plugin.metadata.version);
self.plugins.insert(name, plugin);
loaded_count += 1;
}
Err(e) => {
warn!("Failed to load plugin from {:?}: {}", path, e);
}
}
}
info!("Discovered {} plugins", loaded_count);
Ok(loaded_count)
}
pub fn get_plugin(&self, name: &str) -> Option<&Plugin> {
self.plugins.get(name)
}
pub fn list_plugins(&self) -> Vec<&Plugin> {
self.plugins.values().collect()
}
pub async fn execute_command(
&self,
plugin_name: &str,
command_name: &str,
arguments: HashMap<String, String>,
) -> Result<PluginResult> {
let plugin = self
.get_plugin(plugin_name)
.ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", plugin_name))?;
if !plugin.enabled {
anyhow::bail!("Plugin is disabled: {}", plugin_name);
}
let command_exists =
plugin.metadata.commands.iter().any(|cmd| {
cmd.name == command_name || cmd.aliases.contains(&command_name.to_string())
});
if !command_exists {
anyhow::bail!("Command not found in plugin: {}", command_name);
}
let context = PluginContext {
command: command_name.to_string(),
arguments,
environment: std::env::vars().collect(),
working_dir: std::env::current_dir()
.context("Failed to get current directory")?
.to_string_lossy()
.to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
};
plugin.execute(context).await
}
pub fn install_plugin(&mut self, source_path: &Path) -> Result<()> {
let plugin =
Plugin::load_from_dir(source_path).context("Failed to load plugin from source")?;
let dest_path = self.plugin_dir.join(&plugin.metadata.name);
if dest_path.exists() {
anyhow::bail!("Plugin already installed: {}", plugin.metadata.name);
}
fs::create_dir_all(&dest_path).context("Failed to create plugin directory")?;
fs::copy(
source_path.join("plugin.toml"),
dest_path.join("plugin.toml"),
)
.context("Failed to copy plugin metadata")?;
fs::copy(
source_path.join("plugin.wasm"),
dest_path.join("plugin.wasm"),
)
.context("Failed to copy plugin WASM")?;
info!(
"Installed plugin: {} v{}",
plugin.metadata.name, plugin.metadata.version
);
self.discover_plugins()?;
Ok(())
}
pub fn uninstall_plugin(&mut self, name: &str) -> Result<()> {
if !self.plugins.contains_key(name) {
anyhow::bail!("Plugin not found: {}", name);
}
let plugin_path = self.plugin_dir.join(name);
if plugin_path.exists() {
fs::remove_dir_all(&plugin_path).context("Failed to remove plugin directory")?;
}
self.plugins.remove(name);
info!("Uninstalled plugin: {}", name);
Ok(())
}
pub fn enable_plugin(&mut self, name: &str) -> Result<()> {
let plugin = self
.plugins
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
plugin.enabled = true;
info!("Enabled plugin: {}", name);
Ok(())
}
pub fn disable_plugin(&mut self, name: &str) -> Result<()> {
let plugin = self
.plugins
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
plugin.enabled = false;
info!("Disabled plugin: {}", name);
Ok(())
}
}
impl Default for PluginManager {
fn default() -> Self {
Self::new().expect("Failed to create plugin manager")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
#[test]
fn test_plugin_metadata_serialization() {
let metadata = PluginMetadata {
name: "test-plugin".to_string(),
version: "1.0.0".to_string(),
description: "Test plugin".to_string(),
author: "Test Author".to_string(),
license: "MIT".to_string(),
commands: vec![PluginCommand {
name: "hello".to_string(),
description: "Say hello".to_string(),
aliases: vec!["hi".to_string()],
arguments: vec![],
}],
dependencies: vec![],
min_version: Some("0.1.0".to_string()),
};
let toml_str = toml::to_string(&metadata).unwrap();
let deserialized: PluginMetadata = toml::from_str(&toml_str).unwrap();
assert_eq!(metadata.name, deserialized.name);
assert_eq!(metadata.version, deserialized.version);
assert_eq!(metadata.commands.len(), deserialized.commands.len());
}
#[test]
fn test_plugin_context_serialization() {
let mut arguments = HashMap::new();
arguments.insert("name".to_string(), "world".to_string());
let mut environment = HashMap::new();
environment.insert("PATH".to_string(), "/usr/bin".to_string());
let context = PluginContext {
command: "hello".to_string(),
arguments,
environment,
working_dir: "/tmp".to_string(),
cli_version: "0.1.0".to_string(),
};
let json_str = serde_json::to_string(&context).unwrap();
let deserialized: PluginContext = serde_json::from_str(&json_str).unwrap();
assert_eq!(context.command, deserialized.command);
assert_eq!(context.working_dir, deserialized.working_dir);
}
#[test]
fn test_plugin_result_serialization() {
let result = PluginResult {
exit_code: 0,
stdout: "Hello, world!".to_string(),
stderr: String::new(),
data: Some(serde_json::json!({"status": "success"})),
};
let json_str = serde_json::to_string(&result).unwrap();
let deserialized: PluginResult = serde_json::from_str(&json_str).unwrap();
assert_eq!(result.exit_code, deserialized.exit_code);
assert_eq!(result.stdout, deserialized.stdout);
assert!(deserialized.data.is_some());
}
#[test]
fn test_plugin_manager_creation() {
let manager = PluginManager::new();
assert!(manager.is_ok());
let manager = manager.unwrap();
assert_eq!(manager.plugins.len(), 0);
}
#[tokio::test]
async fn test_plugin_load_from_invalid_dir() {
let temp_dir = env::temp_dir().join("test_invalid_plugin");
let _ = fs::create_dir_all(&temp_dir);
let result = Plugin::load_from_dir(&temp_dir);
assert!(result.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_plugin_argument_validation() {
let arg = PluginArgument {
name: "input".to_string(),
description: "Input file".to_string(),
required: true,
default: None,
};
assert_eq!(arg.name, "input");
assert!(arg.required);
assert!(arg.default.is_none());
}
}