Skip to main content

lean_ctx/core/plugins/
registry.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use super::manifest::{ManifestError, PluginManifest};
5
6#[derive(Debug, Clone)]
7pub struct Plugin {
8    pub manifest: PluginManifest,
9    pub enabled: bool,
10    pub path: PathBuf,
11}
12
13#[derive(Debug)]
14pub struct PluginRegistry {
15    plugins: HashMap<String, Plugin>,
16    plugin_dir: PathBuf,
17    state_file: PathBuf,
18}
19
20#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
21struct PluginState {
22    #[serde(default)]
23    disabled: Vec<String>,
24}
25
26impl PluginRegistry {
27    pub fn new(plugin_dir: PathBuf) -> Self {
28        let state_file = plugin_dir.join("plugin-state.json");
29        Self {
30            plugins: HashMap::new(),
31            plugin_dir,
32            state_file,
33        }
34    }
35
36    pub fn from_default_dir() -> Self {
37        let dir = default_plugin_dir();
38        Self::new(dir)
39    }
40
41    pub fn discover(&mut self) -> Vec<DiscoveryError> {
42        let mut errors = Vec::new();
43        self.plugins.clear();
44
45        let state = self.load_state();
46
47        let Ok(entries) = std::fs::read_dir(&self.plugin_dir) else {
48            return errors;
49        };
50
51        for entry in entries.flatten() {
52            let path = entry.path();
53            if !path.is_dir() {
54                continue;
55            }
56
57            let manifest_path = path.join("plugin.toml");
58            if !manifest_path.exists() {
59                continue;
60            }
61
62            match PluginManifest::from_file(&manifest_path) {
63                Ok(manifest) => {
64                    let name = manifest.plugin.name.clone();
65                    let enabled = !state.disabled.contains(&name);
66                    self.plugins.insert(
67                        name,
68                        Plugin {
69                            manifest,
70                            enabled,
71                            path,
72                        },
73                    );
74                }
75                Err(e) => {
76                    errors.push(DiscoveryError {
77                        path: manifest_path,
78                        error: e,
79                    });
80                }
81            }
82        }
83
84        errors
85    }
86
87    pub fn get(&self, name: &str) -> Option<&Plugin> {
88        self.plugins.get(name)
89    }
90
91    pub fn list(&self) -> Vec<&Plugin> {
92        let mut plugins: Vec<_> = self.plugins.values().collect();
93        plugins.sort_by(|a, b| a.manifest.plugin.name.cmp(&b.manifest.plugin.name));
94        plugins
95    }
96
97    pub fn enabled_plugins(&self) -> Vec<&Plugin> {
98        self.list().into_iter().filter(|p| p.enabled).collect()
99    }
100
101    pub fn enable(&mut self, name: &str) -> Result<(), RegistryError> {
102        let plugin = self
103            .plugins
104            .get_mut(name)
105            .ok_or_else(|| RegistryError::NotFound(name.to_string()))?;
106        plugin.enabled = true;
107        self.save_state();
108        Ok(())
109    }
110
111    pub fn disable(&mut self, name: &str) -> Result<(), RegistryError> {
112        let plugin = self
113            .plugins
114            .get_mut(name)
115            .ok_or_else(|| RegistryError::NotFound(name.to_string()))?;
116        plugin.enabled = false;
117        self.save_state();
118        Ok(())
119    }
120
121    pub fn plugin_dir(&self) -> &Path {
122        &self.plugin_dir
123    }
124
125    fn load_state(&self) -> PluginState {
126        std::fs::read_to_string(&self.state_file)
127            .ok()
128            .and_then(|s| serde_json::from_str(&s).ok())
129            .unwrap_or_default()
130    }
131
132    fn save_state(&self) {
133        let disabled: Vec<String> = self
134            .plugins
135            .iter()
136            .filter(|(_, p)| !p.enabled)
137            .map(|(name, _)| name.clone())
138            .collect();
139        let state = PluginState { disabled };
140        let _ = std::fs::create_dir_all(&self.plugin_dir);
141        let _ = std::fs::write(
142            &self.state_file,
143            serde_json::to_string_pretty(&state).unwrap_or_default(),
144        );
145    }
146}
147
148pub fn default_plugin_dir() -> PathBuf {
149    if let Some(config_dir) = dirs::config_dir() {
150        config_dir.join("lean-ctx").join("plugins")
151    } else {
152        PathBuf::from("~/.config/lean-ctx/plugins")
153    }
154}
155
156#[derive(Debug)]
157pub struct DiscoveryError {
158    pub path: PathBuf,
159    pub error: ManifestError,
160}
161
162#[derive(Debug, thiserror::Error)]
163pub enum RegistryError {
164    #[error("plugin not found: {0}")]
165    NotFound(String),
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::fs;
172
173    fn setup_test_dir() -> tempfile::TempDir {
174        let dir = tempfile::tempdir().unwrap();
175
176        let plugin_a = dir.path().join("plugin-a");
177        fs::create_dir_all(&plugin_a).unwrap();
178        fs::write(
179            plugin_a.join("plugin.toml"),
180            r#"
181[plugin]
182name = "plugin-a"
183version = "1.0.0"
184description = "First plugin"
185
186[hooks.on_session_start]
187command = "plugin-a-bin start"
188"#,
189        )
190        .unwrap();
191
192        let plugin_b = dir.path().join("plugin-b");
193        fs::create_dir_all(&plugin_b).unwrap();
194        fs::write(
195            plugin_b.join("plugin.toml"),
196            r#"
197[plugin]
198name = "plugin-b"
199version = "0.2.0"
200description = "Second plugin"
201author = "Test"
202
203[hooks.pre_read]
204command = "plugin-b-bin pre-read"
205timeout_ms = 2000
206"#,
207        )
208        .unwrap();
209
210        dir
211    }
212
213    #[test]
214    fn discover_finds_plugins() {
215        let dir = setup_test_dir();
216        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
217        let errors = registry.discover();
218        assert!(errors.is_empty());
219        assert_eq!(registry.list().len(), 2);
220    }
221
222    #[test]
223    fn enable_disable_persists() {
224        let dir = setup_test_dir();
225        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
226        registry.discover();
227
228        registry.disable("plugin-a").unwrap();
229        assert!(!registry.get("plugin-a").unwrap().enabled);
230
231        let mut registry2 = PluginRegistry::new(dir.path().to_path_buf());
232        registry2.discover();
233        assert!(!registry2.get("plugin-a").unwrap().enabled);
234        assert!(registry2.get("plugin-b").unwrap().enabled);
235
236        registry2.enable("plugin-a").unwrap();
237        assert!(registry2.get("plugin-a").unwrap().enabled);
238    }
239
240    #[test]
241    fn not_found_error() {
242        let dir = setup_test_dir();
243        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
244        registry.discover();
245        let err = registry.enable("nonexistent").unwrap_err();
246        assert!(err.to_string().contains("nonexistent"));
247    }
248
249    #[test]
250    fn skips_dirs_without_manifest() {
251        let dir = tempfile::tempdir().unwrap();
252        let empty_dir = dir.path().join("no-manifest");
253        fs::create_dir_all(&empty_dir).unwrap();
254
255        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
256        let errors = registry.discover();
257        assert!(errors.is_empty());
258        assert!(registry.list().is_empty());
259    }
260
261    #[test]
262    fn reports_parse_errors() {
263        let dir = tempfile::tempdir().unwrap();
264        let bad_plugin = dir.path().join("bad-plugin");
265        fs::create_dir_all(&bad_plugin).unwrap();
266        fs::write(bad_plugin.join("plugin.toml"), "not valid toml [[[").unwrap();
267
268        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
269        let errors = registry.discover();
270        assert_eq!(errors.len(), 1);
271        assert!(registry.list().is_empty());
272    }
273
274    #[test]
275    fn enabled_plugins_filter() {
276        let dir = setup_test_dir();
277        let mut registry = PluginRegistry::new(dir.path().to_path_buf());
278        registry.discover();
279        registry.disable("plugin-b").unwrap();
280        let enabled = registry.enabled_plugins();
281        assert_eq!(enabled.len(), 1);
282        assert_eq!(enabled[0].manifest.plugin.name, "plugin-a");
283    }
284}