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#[derive(Debug)]
13struct LoadedPlugin {
14 manifest: PluginManifest,
15 #[allow(dead_code)]
17 wasm_bytes: Vec<u8>,
18}
19
20#[derive(Debug)]
27pub struct PluginManager {
28 plugins: Vec<LoadedPlugin>,
29}
30
31impl PluginManager {
32 pub fn empty() -> Self {
34 Self {
35 plugins: Vec::new(),
36 }
37 }
38
39 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 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 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 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 pub fn is_empty(&self) -> bool {
128 self.plugins.is_empty()
129 }
130
131 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 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 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 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 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 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}