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 {
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}