Skip to main content

mur_core/plugin/
mod.rs

1//! WASM plugin system — load, sandbox, and run .wasm plugins for Commander.
2//!
3//! Plugins extend Commander with custom step types, validators, and integrations.
4//! Each plugin runs in a sandboxed WASM environment with limited host access.
5
6#[cfg(feature = "wasm-plugins")]
7pub mod host;
8#[cfg(feature = "wasm-plugins")]
9pub mod loader;
10#[cfg(feature = "wasm-plugins")]
11pub mod sandbox;
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Plugin manifest describing a WASM plugin's metadata and capabilities.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PluginManifest {
19    /// Unique plugin identifier.
20    pub id: String,
21    /// Human-readable name.
22    pub name: String,
23    /// Plugin version (semver).
24    pub version: String,
25    /// Plugin description.
26    pub description: String,
27    /// Author or organization.
28    pub author: String,
29    /// Minimum Commander version required.
30    pub min_commander_version: Option<String>,
31    /// Capabilities this plugin requests.
32    pub permissions: PluginPermissions,
33    /// Custom configuration values.
34    pub config: HashMap<String, String>,
35}
36
37/// Permissions a plugin may request from the host.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct PluginPermissions {
40    /// Can read environment variables.
41    pub read_vars: bool,
42    /// Can set environment variables.
43    pub set_vars: bool,
44    /// Can make HTTP requests.
45    pub http: bool,
46    /// Can write to the log.
47    pub log: bool,
48}
49
50/// Result of a plugin execution.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct PluginResult {
53    /// Whether the plugin execution succeeded.
54    pub success: bool,
55    /// Output produced by the plugin.
56    pub output: String,
57    /// Error message, if any.
58    pub error: Option<String>,
59}
60
61/// Trait that all plugins must implement (host-side representation).
62pub trait Plugin: Send + Sync {
63    /// Return the plugin's manifest.
64    fn manifest(&self) -> &PluginManifest;
65
66    /// Initialize the plugin with the given configuration.
67    fn initialize(&mut self, config: &HashMap<String, String>) -> anyhow::Result<()>;
68
69    /// Execute the plugin's main function with the given input.
70    fn execute(&mut self, input: &str) -> anyhow::Result<PluginResult>;
71
72    /// Clean up resources held by the plugin.
73    fn shutdown(&mut self) -> anyhow::Result<()>;
74}
75
76/// Registry of loaded plugins.
77pub struct PluginRegistry {
78    plugins: HashMap<String, Box<dyn Plugin>>,
79}
80
81impl PluginRegistry {
82    /// Create an empty plugin registry.
83    pub fn new() -> Self {
84        Self {
85            plugins: HashMap::new(),
86        }
87    }
88
89    /// Register a plugin. Returns error if a plugin with the same ID already exists.
90    pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
91        let id = plugin.manifest().id.clone();
92        if self.plugins.contains_key(&id) {
93            anyhow::bail!("Plugin '{}' is already registered", id);
94        }
95        self.plugins.insert(id, plugin);
96        Ok(())
97    }
98
99    /// Unregister and shut down a plugin by ID.
100    pub fn unregister(&mut self, id: &str) -> anyhow::Result<()> {
101        if let Some(mut plugin) = self.plugins.remove(id) {
102            plugin.shutdown()?;
103        }
104        Ok(())
105    }
106
107    /// Get a reference to a registered plugin.
108    pub fn get(&self, id: &str) -> Option<&dyn Plugin> {
109        self.plugins.get(id).map(|p| p.as_ref())
110    }
111
112    /// Get a mutable reference to a registered plugin.
113    pub fn get_mut(&mut self, id: &str) -> Option<&mut (dyn Plugin + 'static)> {
114        self.plugins.get_mut(id).map(|p| &mut **p)
115    }
116
117    /// List all registered plugin manifests.
118    pub fn list(&self) -> Vec<&PluginManifest> {
119        self.plugins.values().map(|p| p.manifest()).collect()
120    }
121
122    /// Execute a plugin by ID with the given input.
123    pub fn execute(&mut self, id: &str, input: &str) -> anyhow::Result<PluginResult> {
124        let plugin = self
125            .plugins
126            .get_mut(id)
127            .ok_or_else(|| anyhow::anyhow!("Plugin '{}' not found", id))?;
128        plugin.execute(input)
129    }
130}
131
132impl Default for PluginRegistry {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    struct MockPlugin {
143        manifest: PluginManifest,
144        initialized: bool,
145    }
146
147    impl MockPlugin {
148        fn new(id: &str) -> Self {
149            Self {
150                manifest: PluginManifest {
151                    id: id.to_string(),
152                    name: format!("Mock {}", id),
153                    version: "1.0.0".to_string(),
154                    description: "A mock plugin".to_string(),
155                    author: "test".to_string(),
156                    min_commander_version: None,
157                    permissions: PluginPermissions::default(),
158                    config: HashMap::new(),
159                },
160                initialized: false,
161            }
162        }
163    }
164
165    impl Plugin for MockPlugin {
166        fn manifest(&self) -> &PluginManifest {
167            &self.manifest
168        }
169
170        fn initialize(&mut self, _config: &HashMap<String, String>) -> anyhow::Result<()> {
171            self.initialized = true;
172            Ok(())
173        }
174
175        fn execute(&mut self, input: &str) -> anyhow::Result<PluginResult> {
176            Ok(PluginResult {
177                success: true,
178                output: format!("Processed: {}", input),
179                error: None,
180            })
181        }
182
183        fn shutdown(&mut self) -> anyhow::Result<()> {
184            self.initialized = false;
185            Ok(())
186        }
187    }
188
189    #[test]
190    fn test_plugin_manifest_serialization() {
191        let manifest = PluginManifest {
192            id: "test-plugin".into(),
193            name: "Test Plugin".into(),
194            version: "1.0.0".into(),
195            description: "A test".into(),
196            author: "tester".into(),
197            min_commander_version: Some("0.1.0".into()),
198            permissions: PluginPermissions {
199                read_vars: true,
200                set_vars: false,
201                http: true,
202                log: true,
203            },
204            config: HashMap::new(),
205        };
206        let json = serde_json::to_string(&manifest).unwrap();
207        let deserialized: PluginManifest = serde_json::from_str(&json).unwrap();
208        assert_eq!(deserialized.id, "test-plugin");
209        assert!(deserialized.permissions.read_vars);
210        assert!(!deserialized.permissions.set_vars);
211    }
212
213    #[test]
214    fn test_registry_register_and_list() {
215        let mut registry = PluginRegistry::new();
216        registry.register(Box::new(MockPlugin::new("p1"))).unwrap();
217        registry.register(Box::new(MockPlugin::new("p2"))).unwrap();
218
219        let list = registry.list();
220        assert_eq!(list.len(), 2);
221    }
222
223    #[test]
224    fn test_registry_duplicate_register() {
225        let mut registry = PluginRegistry::new();
226        registry.register(Box::new(MockPlugin::new("p1"))).unwrap();
227        assert!(registry.register(Box::new(MockPlugin::new("p1"))).is_err());
228    }
229
230    #[test]
231    fn test_registry_execute() {
232        let mut registry = PluginRegistry::new();
233        registry.register(Box::new(MockPlugin::new("p1"))).unwrap();
234
235        let result = registry.execute("p1", "hello").unwrap();
236        assert!(result.success);
237        assert_eq!(result.output, "Processed: hello");
238    }
239
240    #[test]
241    fn test_registry_execute_not_found() {
242        let mut registry = PluginRegistry::new();
243        assert!(registry.execute("nonexistent", "test").is_err());
244    }
245
246    #[test]
247    fn test_registry_unregister() {
248        let mut registry = PluginRegistry::new();
249        registry.register(Box::new(MockPlugin::new("p1"))).unwrap();
250        assert!(registry.get("p1").is_some());
251
252        registry.unregister("p1").unwrap();
253        assert!(registry.get("p1").is_none());
254    }
255}