lean_ctx/core/plugins/
registry.rs1use 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 {
155 if let Some(dir) = std::env::var_os("LEAN_CTX_PLUGINS_DIR")
156 && !dir.is_empty()
157 {
158 return PathBuf::from(dir);
159 }
160 crate::core::paths::config_dir_member("plugins")
163 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/plugins"))
164}
165
166#[derive(Debug)]
167pub struct DiscoveryError {
168 pub path: PathBuf,
169 pub error: ManifestError,
170}
171
172#[derive(Debug, thiserror::Error)]
173pub enum RegistryError {
174 #[error("plugin not found: {0}")]
175 NotFound(String),
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::fs;
182
183 fn setup_test_dir() -> tempfile::TempDir {
184 let dir = tempfile::tempdir().unwrap();
185
186 let plugin_a = dir.path().join("plugin-a");
187 fs::create_dir_all(&plugin_a).unwrap();
188 fs::write(
189 plugin_a.join("plugin.toml"),
190 r#"
191[plugin]
192name = "plugin-a"
193version = "1.0.0"
194description = "First plugin"
195
196[hooks.on_session_start]
197command = "plugin-a-bin start"
198"#,
199 )
200 .unwrap();
201
202 let plugin_b = dir.path().join("plugin-b");
203 fs::create_dir_all(&plugin_b).unwrap();
204 fs::write(
205 plugin_b.join("plugin.toml"),
206 r#"
207[plugin]
208name = "plugin-b"
209version = "0.2.0"
210description = "Second plugin"
211author = "Test"
212
213[hooks.pre_read]
214command = "plugin-b-bin pre-read"
215timeout_ms = 2000
216"#,
217 )
218 .unwrap();
219
220 dir
221 }
222
223 #[test]
224 fn discover_finds_plugins() {
225 let dir = setup_test_dir();
226 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
227 let errors = registry.discover();
228 assert!(errors.is_empty());
229 assert_eq!(registry.list().len(), 2);
230 }
231
232 #[test]
233 fn enable_disable_persists() {
234 let dir = setup_test_dir();
235 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
236 registry.discover();
237
238 registry.disable("plugin-a").unwrap();
239 assert!(!registry.get("plugin-a").unwrap().enabled);
240
241 let mut registry2 = PluginRegistry::new(dir.path().to_path_buf());
242 registry2.discover();
243 assert!(!registry2.get("plugin-a").unwrap().enabled);
244 assert!(registry2.get("plugin-b").unwrap().enabled);
245
246 registry2.enable("plugin-a").unwrap();
247 assert!(registry2.get("plugin-a").unwrap().enabled);
248 }
249
250 #[test]
251 fn not_found_error() {
252 let dir = setup_test_dir();
253 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
254 registry.discover();
255 let err = registry.enable("nonexistent").unwrap_err();
256 assert!(err.to_string().contains("nonexistent"));
257 }
258
259 #[test]
260 fn skips_dirs_without_manifest() {
261 let dir = tempfile::tempdir().unwrap();
262 let empty_dir = dir.path().join("no-manifest");
263 fs::create_dir_all(&empty_dir).unwrap();
264
265 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
266 let errors = registry.discover();
267 assert!(errors.is_empty());
268 assert!(registry.list().is_empty());
269 }
270
271 #[test]
272 fn reports_parse_errors() {
273 let dir = tempfile::tempdir().unwrap();
274 let bad_plugin = dir.path().join("bad-plugin");
275 fs::create_dir_all(&bad_plugin).unwrap();
276 fs::write(bad_plugin.join("plugin.toml"), "not valid toml [[[").unwrap();
277
278 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
279 let errors = registry.discover();
280 assert_eq!(errors.len(), 1);
281 assert!(registry.list().is_empty());
282 }
283
284 #[test]
285 fn enabled_plugins_filter() {
286 let dir = setup_test_dir();
287 let mut registry = PluginRegistry::new(dir.path().to_path_buf());
288 registry.discover();
289 registry.disable("plugin-b").unwrap();
290 let enabled = registry.enabled_plugins();
291 assert_eq!(enabled.len(), 1);
292 assert_eq!(enabled[0].manifest.plugin.name, "plugin-a");
293 }
294}