Skip to main content

mneme/plugins/
manager.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::json;
4use tracing::{debug, warn};
5
6use crate::error::{MnemeError, Result};
7
8use super::manifest::{PluginManifest, PluginTool};
9
10// ── Internal representation of a loaded plugin ──────────────────────────────
11
12#[derive(Debug)]
13struct LoadedPlugin {
14    manifest: PluginManifest,
15    /// Raw WASM bytes, kept for re-instantiation per call.
16    #[allow(dead_code)]
17    wasm_bytes: Vec<u8>,
18}
19
20// ── PluginManager ────────────────────────────────────────────────────────────
21
22/// Manages WASM plugins loaded from disk.
23///
24/// Plugins are discovered from `~/.config/mneme/plugins/*.wasm` at startup.
25/// Each plugin is sandboxed and communicates via JSON over the extism ABI.
26#[derive(Debug)]
27pub struct PluginManager {
28    plugins: Vec<LoadedPlugin>,
29}
30
31impl PluginManager {
32    /// Returns an empty manager (no plugins loaded).
33    pub fn empty() -> Self {
34        Self {
35            plugins: Vec::new(),
36        }
37    }
38
39    /// Discover and load plugins from the default directory:
40    /// `~/.config/mneme/plugins/*.wasm`
41    pub fn load_from_default_dir() -> Result<Self> {
42        let dir = dirs::config_dir()
43            .map(|d| d.join("mneme").join("plugins"))
44            .ok_or_else(|| MnemeError::Plugin("cannot resolve config directory".into()))?;
45        Self::load_from_dir(&dir)
46    }
47
48    /// Load plugins from an explicit directory (useful for tests).
49    pub fn load_from_dir(dir: &Path) -> Result<Self> {
50        if !dir.exists() {
51            debug!(path = %dir.display(), "plugin directory does not exist, skipping");
52            return Ok(Self::empty());
53        }
54
55        let mut plugins = Vec::new();
56
57        let entries = std::fs::read_dir(dir)
58            .map_err(|e| MnemeError::Plugin(format!("cannot read plugin dir: {}", e)))?;
59
60        for entry in entries.flatten() {
61            let path = entry.path();
62            if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
63                continue;
64            }
65
66            match Self::load_one(&path) {
67                Ok(plugin) => {
68                    debug!(name = %plugin.manifest.name, path = %path.display(), "plugin loaded");
69                    plugins.push(plugin);
70                }
71                Err(e) => {
72                    warn!(path = %path.display(), error = %e, "failed to load plugin, skipping");
73                }
74            }
75        }
76
77        Ok(Self { plugins })
78    }
79
80    /// Load a single .wasm file and read its manifest.
81    fn load_one(path: &PathBuf) -> Result<LoadedPlugin> {
82        let wasm_bytes = std::fs::read(path)
83            .map_err(|e| MnemeError::Plugin(format!("cannot read {}: {}", path.display(), e)))?;
84
85        let manifest = Self::call_manifest(&wasm_bytes)?;
86
87        Ok(LoadedPlugin {
88            manifest,
89            wasm_bytes,
90        })
91    }
92
93    /// Invoke the `plugin_manifest` export and parse the result.
94    fn call_manifest(wasm_bytes: &[u8]) -> Result<PluginManifest> {
95        #[cfg(feature = "plugins")]
96        {
97            use extism::{Manifest as ExtismManifest, Plugin, Wasm};
98
99            let wasm = Wasm::data(wasm_bytes.to_vec());
100            let ext_manifest = ExtismManifest::new([wasm]);
101            let mut plugin = Plugin::new(&ext_manifest, [], true)
102                .map_err(|e| MnemeError::Plugin(format!("plugin init failed: {}", e)))?;
103
104            let raw: Vec<u8> = plugin
105                .call::<&[u8], Vec<u8>>("plugin_manifest", b"")
106                .map_err(|e| MnemeError::Plugin(format!("plugin_manifest call failed: {}", e)))?
107                .to_vec();
108
109            let manifest: PluginManifest = serde_json::from_slice(&raw)
110                .map_err(|e| MnemeError::Plugin(format!("invalid manifest JSON: {}", e)))?;
111
112            Ok(manifest)
113        }
114
115        #[cfg(not(feature = "plugins"))]
116        {
117            let _ = wasm_bytes;
118            Err(MnemeError::Plugin(
119                "compiled without 'plugins' feature".into(),
120            ))
121        }
122    }
123
124    // ── Public query API ──────────────────────────────────────────────────────
125
126    /// Returns `true` if no plugins are loaded.
127    pub fn is_empty(&self) -> bool {
128        self.plugins.is_empty()
129    }
130
131    /// All tools provided by loaded plugins.
132    pub fn plugin_tools(&self) -> Vec<PluginTool> {
133        self.plugins
134            .iter()
135            .flat_map(|p| p.manifest.tools.iter().cloned())
136            .collect()
137    }
138
139    /// Returns `true` if the given tool name belongs to any loaded plugin.
140    pub fn owns_tool(&self, tool_name: &str) -> bool {
141        self.plugins
142            .iter()
143            .any(|p| p.manifest.tools.iter().any(|t| t.name == tool_name))
144    }
145
146    // ── Dispatch ──────────────────────────────────────────────────────────────
147
148    /// Dispatch a tool call to the plugin that owns it.
149    ///
150    /// Returns `MnemeError::Plugin` if no plugin owns the tool.
151    pub fn call_tool(
152        &self,
153        tool_name: &str,
154        args: serde_json::Value,
155        project: &str,
156    ) -> Result<serde_json::Value> {
157        let plugin = self
158            .plugins
159            .iter()
160            .find(|p| p.manifest.tools.iter().any(|t| t.name == tool_name))
161            .ok_or_else(|| MnemeError::Plugin(format!("no plugin owns tool '{}'", tool_name)))?;
162
163        let payload = json!({
164            "tool": tool_name,
165            "args": args,
166            "project": project,
167        });
168
169        self.invoke_plugin(plugin, "call_tool", &payload)
170    }
171
172    /// Run the `pre_save` hook through all plugins that declare it.
173    ///
174    /// Plugins are chained: the output of one becomes the input of the next.
175    pub fn run_pre_save(&self, memory: serde_json::Value) -> Result<serde_json::Value> {
176        self.run_transform_hook("pre_save", memory)
177    }
178
179    /// Run the `post_get` hook through all plugins that declare it.
180    pub fn run_post_get(&self, memory: serde_json::Value) -> Result<serde_json::Value> {
181        self.run_transform_hook("post_get", memory)
182    }
183
184    // ── Internal helpers ──────────────────────────────────────────────────────
185
186    fn run_transform_hook(
187        &self,
188        hook: &str,
189        mut memory: serde_json::Value,
190    ) -> Result<serde_json::Value> {
191        for plugin in &self.plugins {
192            if !plugin.manifest.hooks.iter().any(|h| h == hook) {
193                continue;
194            }
195            let payload = json!({ "hook": hook, "memory": memory });
196            let result = self.invoke_plugin(plugin, "transform_memory", &payload)?;
197            memory = result.get("memory").cloned().unwrap_or(result);
198        }
199        Ok(memory)
200    }
201
202    fn invoke_plugin(
203        &self,
204        plugin: &LoadedPlugin,
205        func: &str,
206        payload: &serde_json::Value,
207    ) -> Result<serde_json::Value> {
208        #[cfg(feature = "plugins")]
209        {
210            use extism::{Manifest as ExtismManifest, Plugin, Wasm};
211
212            let input = serde_json::to_vec(payload)
213                .map_err(|e| MnemeError::Plugin(format!("serialize input: {}", e)))?;
214
215            let wasm = Wasm::data(plugin.wasm_bytes.clone());
216            let ext_manifest = ExtismManifest::new([wasm]);
217            let mut instance = Plugin::new(&ext_manifest, [], true)
218                .map_err(|e| MnemeError::Plugin(format!("plugin init: {}", e)))?;
219
220            let raw: Vec<u8> = instance
221                .call::<Vec<u8>, Vec<u8>>(func, input)
222                .map_err(|e| {
223                    MnemeError::Plugin(format!(
224                        "plugin '{}' call '{}' failed: {}",
225                        plugin.manifest.name, func, e
226                    ))
227                })?
228                .to_vec();
229
230            serde_json::from_slice(&raw)
231                .map_err(|e| MnemeError::Plugin(format!("invalid response JSON: {}", e)))
232        }
233
234        #[cfg(not(feature = "plugins"))]
235        {
236            let _ = (plugin, func, payload);
237            Err(MnemeError::Plugin(
238                "compiled without 'plugins' feature".into(),
239            ))
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_empty_manager_has_no_plugins() {
250        let mgr = PluginManager::empty();
251        assert!(mgr.plugins.is_empty());
252    }
253
254    #[test]
255    fn test_load_from_nonexistent_dir_returns_empty() {
256        let dir = PathBuf::from("/tmp/mneme_plugins_nonexistent_12345");
257        let mgr = PluginManager::load_from_dir(&dir).unwrap();
258        assert!(mgr.plugins.is_empty());
259    }
260
261    #[test]
262    fn test_load_from_empty_dir_returns_empty() {
263        let dir = std::env::temp_dir().join(format!(
264            "mneme_plugins_empty_{}",
265            std::time::SystemTime::now()
266                .duration_since(std::time::UNIX_EPOCH)
267                .unwrap()
268                .as_nanos()
269        ));
270        std::fs::create_dir_all(&dir).unwrap();
271        let mgr = PluginManager::load_from_dir(&dir).unwrap();
272        assert!(mgr.plugins.is_empty());
273        std::fs::remove_dir_all(&dir).ok();
274    }
275
276    #[test]
277    fn test_call_tool_on_empty_returns_error() {
278        let mgr = PluginManager::empty();
279        let result = mgr.call_tool("foo", serde_json::json!({}), "test");
280        assert!(result.is_err());
281    }
282
283    #[test]
284    fn test_pre_save_on_empty_returns_input() {
285        let mgr = PluginManager::empty();
286        let input = serde_json::json!({"title": "test"});
287        let result = mgr.run_pre_save(input.clone()).unwrap();
288        assert_eq!(result, input);
289    }
290
291    #[test]
292    fn test_post_get_on_empty_returns_input() {
293        let mgr = PluginManager::empty();
294        let input = serde_json::json!({"id": "123"});
295        let result = mgr.run_post_get(input.clone()).unwrap();
296        assert_eq!(result, input);
297    }
298}