Skip to main content

ai_model_vault/
plugins.rs

1//! Plugin system — dynamic extension loading for custom commands and hooks.
2//!
3//! Plugins are shared libraries (`.so` / `.dll` / `.dylib`) placed in
4//! `$XDG_DATA_HOME/ai/models/plugins/`.  Each plugin exports a set of
5//! well-known symbols that register custom commands, event subscribers, or
6//! storage backends.
7//!
8//! This module provides the plugin manifest, discovery, and registry — actual
9//! dynamic loading requires the `libloading` crate (not included to avoid
10//! adding a mandatory dependency; users can integrate via the registry API).
11
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::{Result, VaultError};
19
20// ── Types ────────────────────────────────────────────────────────────────────
21
22/// Plugin manifest (discovered from `plugin.json` inside the plugin dir).
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PluginManifest {
25    /// Unique plugin identifier (e.g. "my-company.custom-converter").
26    pub id: String,
27    /// Display name.
28    pub name: String,
29    /// Semantic version.
30    pub version: String,
31    /// Short description.
32    pub description: String,
33    /// Author / organisation.
34    pub author: Option<String>,
35    /// Minimum AIM version required.
36    pub min_aim_version: Option<String>,
37    /// Capabilities provided.
38    pub capabilities: Vec<String>,
39    /// Entry-point library filename (e.g. "libplugin.so").
40    pub entry_point: Option<String>,
41}
42
43/// Runtime state for a loaded plugin.
44#[derive(Debug, Clone, Serialize)]
45pub struct PluginInfo {
46    pub manifest: PluginManifest,
47    pub path: PathBuf,
48    pub loaded: bool,
49}
50
51// ── Registry ─────────────────────────────────────────────────────────────────
52
53/// Plugin registry — discovers, loads, and tracks plugins.
54pub struct PluginRegistry {
55    plugins_dir: PathBuf,
56    plugins: BTreeMap<String, PluginInfo>,
57}
58
59impl PluginRegistry {
60    /// Create a new registry scanning the given directory.
61    pub fn new(plugins_dir: &Path) -> Result<Self> {
62        let mut registry = Self {
63            plugins_dir: plugins_dir.to_path_buf(),
64            plugins: BTreeMap::new(),
65        };
66        registry.discover()?;
67        Ok(registry)
68    }
69
70    /// Scan the plugins directory for manifests.
71    pub fn discover(&mut self) -> Result<usize> {
72        self.plugins.clear();
73
74        if !self.plugins_dir.is_dir() {
75            return Ok(0);
76        }
77
78        let mut count = 0;
79        for entry in fs::read_dir(&self.plugins_dir)? {
80            let entry = entry?;
81            let path = entry.path();
82
83            // Each plugin lives in a subdirectory with a plugin.json
84            let manifest_path = if path.is_dir() {
85                path.join("plugin.json")
86            } else if path.extension().and_then(|e| e.to_str()) == Some("json")
87                && path
88                    .file_stem()
89                    .and_then(|s| s.to_str())
90                    .map(|s| s.ends_with(".plugin"))
91                    .unwrap_or(false)
92            {
93                path.clone()
94            } else {
95                continue;
96            };
97
98            if manifest_path.exists() {
99                match self.load_manifest(&manifest_path) {
100                    Ok(manifest) => {
101                        let info = PluginInfo {
102                            manifest: manifest.clone(),
103                            path: manifest_path
104                                .parent()
105                                .unwrap_or(&self.plugins_dir)
106                                .to_path_buf(),
107                            loaded: false,
108                        };
109                        self.plugins.insert(manifest.id.clone(), info);
110                        count += 1;
111                    }
112                    Err(e) => {
113                        tracing::warn!("Skipping plugin at {}: {}", manifest_path.display(), e);
114                    }
115                }
116            }
117        }
118
119        Ok(count)
120    }
121
122    fn load_manifest(&self, path: &Path) -> Result<PluginManifest> {
123        let data = fs::read_to_string(path)?;
124        let manifest: PluginManifest = serde_json::from_str(&data)?;
125
126        if manifest.id.is_empty() {
127            return Err(VaultError::InvalidInput(
128                "Plugin manifest missing 'id' field".into(),
129            ));
130        }
131
132        Ok(manifest)
133    }
134
135    /// List all discovered plugins.
136    pub fn list(&self) -> Vec<&PluginInfo> {
137        self.plugins.values().collect()
138    }
139
140    /// Get a plugin by ID.
141    pub fn get(&self, id: &str) -> Option<&PluginInfo> {
142        self.plugins.get(id)
143    }
144
145    /// Install a plugin from a manifest struct and optional files.
146    pub fn install(&mut self, manifest: PluginManifest) -> Result<()> {
147        let plugin_dir = self.plugins_dir.join(&manifest.id);
148        fs::create_dir_all(&plugin_dir)?;
149
150        let manifest_path = plugin_dir.join("plugin.json");
151        let json = serde_json::to_string_pretty(&manifest)?;
152        fs::write(&manifest_path, json)?;
153
154        let info = PluginInfo {
155            manifest: manifest.clone(),
156            path: plugin_dir,
157            loaded: false,
158        };
159        self.plugins.insert(manifest.id, info);
160        Ok(())
161    }
162
163    /// Uninstall a plugin by ID (removes its directory).
164    pub fn uninstall(&mut self, id: &str) -> Result<bool> {
165        if let Some(info) = self.plugins.remove(id) {
166            if info.path.is_dir() {
167                fs::remove_dir_all(&info.path)?;
168            }
169            Ok(true)
170        } else {
171            Ok(false)
172        }
173    }
174
175    /// Display a summary.
176    pub fn display(&self) -> String {
177        let mut out = String::new();
178        if self.plugins.is_empty() {
179            out.push_str("No plugins installed.\n");
180            return out;
181        }
182
183        out.push_str(&format!(
184            "{:<30} {:<10} {}\n",
185            "ID", "Version", "Description"
186        ));
187        out.push_str(&format!("{}\n", "─".repeat(60)));
188
189        for info in self.plugins.values() {
190            out.push_str(&format!(
191                "{:<30} {:<10} {}\n",
192                info.manifest.id, info.manifest.version, info.manifest.description
193            ));
194        }
195        out
196    }
197}
198
199// ── Tests ────────────────────────────────────────────────────────────────────
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_empty_registry() {
207        let dir = tempfile::tempdir().unwrap();
208        let plugins_dir = dir.path().join("plugins");
209        fs::create_dir_all(&plugins_dir).unwrap();
210        let registry = PluginRegistry::new(&plugins_dir).unwrap();
211        assert_eq!(registry.list().len(), 0);
212    }
213
214    #[test]
215    fn test_discover_plugin() {
216        let dir = tempfile::tempdir().unwrap();
217        let plugins_dir = dir.path().join("plugins");
218        let plugin_dir = plugins_dir.join("test-plugin");
219        fs::create_dir_all(&plugin_dir).unwrap();
220
221        let manifest = PluginManifest {
222            id: "test-plugin".into(),
223            name: "Test Plugin".into(),
224            version: "1.0.0".into(),
225            description: "A test plugin".into(),
226            author: Some("Test".into()),
227            min_aim_version: None,
228            capabilities: vec!["custom-command".into()],
229            entry_point: None,
230        };
231
232        let json = serde_json::to_string_pretty(&manifest).unwrap();
233        fs::write(plugin_dir.join("plugin.json"), json).unwrap();
234
235        let registry = PluginRegistry::new(&plugins_dir).unwrap();
236        assert_eq!(registry.list().len(), 1);
237        assert_eq!(
238            registry.get("test-plugin").unwrap().manifest.name,
239            "Test Plugin"
240        );
241    }
242
243    #[test]
244    fn test_install_and_uninstall() {
245        let dir = tempfile::tempdir().unwrap();
246        let plugins_dir = dir.path().join("plugins");
247        fs::create_dir_all(&plugins_dir).unwrap();
248
249        let mut registry = PluginRegistry::new(&plugins_dir).unwrap();
250
251        let manifest = PluginManifest {
252            id: "my-plugin".into(),
253            name: "My Plugin".into(),
254            version: "0.1.0".into(),
255            description: "Custom converter".into(),
256            author: None,
257            min_aim_version: Some("1.3.0".into()),
258            capabilities: vec![],
259            entry_point: None,
260        };
261
262        registry.install(manifest).unwrap();
263        assert_eq!(registry.list().len(), 1);
264
265        assert!(registry.uninstall("my-plugin").unwrap());
266        assert_eq!(registry.list().len(), 0);
267    }
268
269    #[test]
270    fn test_display() {
271        let dir = tempfile::tempdir().unwrap();
272        let plugins_dir = dir.path().join("plugins");
273        fs::create_dir_all(&plugins_dir).unwrap();
274
275        let mut registry = PluginRegistry::new(&plugins_dir).unwrap();
276        registry
277            .install(PluginManifest {
278                id: "demo".into(),
279                name: "Demo".into(),
280                version: "1.0.0".into(),
281                description: "Demo plugin".into(),
282                author: None,
283                min_aim_version: None,
284                capabilities: vec![],
285                entry_point: None,
286            })
287            .unwrap();
288
289        let text = registry.display();
290        assert!(text.contains("demo"));
291    }
292}