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
148/// The directory the registry scans for plugin sub-directories.
149///
150/// `LEAN_CTX_PLUGINS_DIR` (the *root* containing plugin folders) overrides the
151/// default so containers, CI, and tests can point at an isolated location. Note
152/// this is distinct from the per-hook `LEAN_CTX_PLUGIN_DIR` the executor sets
153/// for a *single* plugin's child process.
154pub 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    // #594: resolve through the unified config base (matches `config.toml`),
161    // adopting any copy older builds left under `dirs::config_dir()`.
162    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}