Skip to main content

codineer_plugins/
manager.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::path::PathBuf;
4
5use serde_json::Value;
6
7use crate::constants::{
8    BUNDLED_MARKETPLACE, EXTERNAL_MARKETPLACE, REGISTRY_FILE_NAME, SETTINGS_FILE_NAME,
9};
10use crate::definition::{builtin_plugins, load_plugin_definition};
11use crate::error::PluginError;
12use crate::install::{
13    copy_dir_all, describe_install_source, discover_plugin_dirs, ensure_object, materialize_source,
14    parse_install_source, plugin_id, resolve_local_source, sanitize_plugin_id, unix_time_ms,
15    update_settings_json,
16};
17use crate::manifest::{load_plugin_from_directory, plugin_manifest_path};
18use crate::types::{
19    InstallOutcome, InstalledPluginRecord, InstalledPluginRegistry, Plugin, PluginDefinition,
20    PluginHooks, PluginInstallSource, PluginKind, PluginManager, PluginManagerConfig,
21    PluginManifest, PluginMetadata, PluginRegistry, PluginSummary, PluginTool, RegisteredPlugin,
22    UpdateOutcome,
23};
24
25impl PluginManager {
26    #[must_use]
27    pub fn new(config: PluginManagerConfig) -> Self {
28        Self { config }
29    }
30
31    #[must_use]
32    pub fn install_root(&self) -> PathBuf {
33        self.config
34            .install_root
35            .clone()
36            .unwrap_or_else(|| self.config.config_home.join("plugins"))
37    }
38
39    #[must_use]
40    pub fn registry_path(&self) -> PathBuf {
41        self.config.registry_path.clone().unwrap_or_else(|| {
42            self.config
43                .config_home
44                .join("plugins")
45                .join(REGISTRY_FILE_NAME)
46        })
47    }
48
49    #[must_use]
50    pub fn settings_path(&self) -> PathBuf {
51        self.config.config_home.join(SETTINGS_FILE_NAME)
52    }
53
54    pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
55        Ok(PluginRegistry::new(
56            self.discover_plugins()?
57                .into_iter()
58                .map(|plugin| {
59                    let enabled = self.is_enabled(plugin.metadata());
60                    RegisteredPlugin::new(plugin, enabled)
61                })
62                .collect(),
63        ))
64    }
65
66    pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
67        Ok(self.plugin_registry()?.summaries())
68    }
69
70    pub fn list_installed_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
71        Ok(self.installed_plugin_registry()?.summaries())
72    }
73
74    pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
75        self.sync_bundled_plugins()?;
76        let mut plugins = builtin_plugins();
77        plugins.extend(self.discover_installed_plugins()?);
78        plugins.extend(self.discover_external_directory_plugins(&plugins)?);
79        Ok(plugins)
80    }
81
82    pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
83        self.plugin_registry()?.aggregated_hooks()
84    }
85
86    pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
87        self.plugin_registry()?.aggregated_tools()
88    }
89
90    pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
91        let path = resolve_local_source(source)?;
92        load_plugin_from_directory(&path)
93    }
94
95    pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
96        let install_source = parse_install_source(source)?;
97        let temp_root = self.install_root().join(".tmp");
98        let staged_source = materialize_source(&install_source, &temp_root)?;
99        let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
100        let manifest = load_plugin_from_directory(&staged_source)?;
101
102        let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
103        let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
104        if install_path.exists() {
105            fs::remove_dir_all(&install_path)?;
106        }
107        copy_dir_all(&staged_source, &install_path)?;
108        if cleanup_source {
109            let _ = fs::remove_dir_all(&staged_source);
110        }
111
112        let now = unix_time_ms();
113        let record = InstalledPluginRecord {
114            kind: PluginKind::External,
115            id: plugin_id.clone(),
116            name: manifest.name,
117            version: manifest.version.clone(),
118            description: manifest.description,
119            install_path: install_path.clone(),
120            source: install_source,
121            installed_at_unix_ms: now,
122            updated_at_unix_ms: now,
123        };
124
125        let mut registry = self.load_registry()?;
126        registry.plugins.insert(plugin_id.clone(), record);
127        self.store_registry(&registry)?;
128        self.write_enabled_state(&plugin_id, Some(true))?;
129        self.config.enabled_plugins.insert(plugin_id.clone(), true);
130
131        Ok(InstallOutcome {
132            plugin_id,
133            version: manifest.version,
134            install_path,
135        })
136    }
137
138    pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
139        self.ensure_known_plugin(plugin_id)?;
140        self.write_enabled_state(plugin_id, Some(true))?;
141        self.config
142            .enabled_plugins
143            .insert(plugin_id.to_string(), true);
144        Ok(())
145    }
146
147    pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
148        self.ensure_known_plugin(plugin_id)?;
149        self.write_enabled_state(plugin_id, Some(false))?;
150        self.config
151            .enabled_plugins
152            .insert(plugin_id.to_string(), false);
153        Ok(())
154    }
155
156    pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
157        let mut registry = self.load_registry()?;
158        let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
159            PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
160        })?;
161        if record.kind == PluginKind::Bundled {
162            registry.plugins.insert(plugin_id.to_string(), record);
163            return Err(PluginError::CommandFailed(format!(
164                "plugin `{plugin_id}` is bundled and managed automatically; disable it instead"
165            )));
166        }
167        if record.install_path.exists() {
168            fs::remove_dir_all(&record.install_path)?;
169        }
170        self.store_registry(&registry)?;
171        self.write_enabled_state(plugin_id, None)?;
172        self.config.enabled_plugins.remove(plugin_id);
173        Ok(())
174    }
175
176    pub fn update(&mut self, plugin_id: &str) -> Result<UpdateOutcome, PluginError> {
177        let mut registry = self.load_registry()?;
178        let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| {
179            PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
180        })?;
181
182        let temp_root = self.install_root().join(".tmp");
183        let staged_source = materialize_source(&record.source, &temp_root)?;
184        let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
185        let manifest = load_plugin_from_directory(&staged_source)?;
186
187        if record.install_path.exists() {
188            fs::remove_dir_all(&record.install_path)?;
189        }
190        copy_dir_all(&staged_source, &record.install_path)?;
191        if cleanup_source {
192            let _ = fs::remove_dir_all(&staged_source);
193        }
194
195        let updated_record = InstalledPluginRecord {
196            version: manifest.version.clone(),
197            description: manifest.description,
198            updated_at_unix_ms: unix_time_ms(),
199            ..record.clone()
200        };
201        registry
202            .plugins
203            .insert(plugin_id.to_string(), updated_record);
204        self.store_registry(&registry)?;
205
206        Ok(UpdateOutcome {
207            plugin_id: plugin_id.to_string(),
208            old_version: record.version,
209            new_version: manifest.version,
210            install_path: record.install_path,
211        })
212    }
213
214    fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
215        let mut registry = self.load_registry()?;
216        let mut plugins = Vec::new();
217        let mut seen_ids = BTreeSet::<String>::new();
218        let mut seen_paths = BTreeSet::<PathBuf>::new();
219        let mut stale_registry_ids = Vec::new();
220
221        for install_path in discover_plugin_dirs(&self.install_root())? {
222            let matched_record = registry
223                .plugins
224                .values()
225                .find(|record| record.install_path == install_path);
226            let kind = matched_record.map_or(PluginKind::External, |record| record.kind);
227            let source = matched_record.map_or_else(
228                || install_path.display().to_string(),
229                |record| describe_install_source(&record.source),
230            );
231            let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
232            if seen_ids.insert(plugin.metadata().id.clone()) {
233                seen_paths.insert(install_path);
234                plugins.push(plugin);
235            }
236        }
237
238        for record in registry.plugins.values() {
239            if seen_paths.contains(&record.install_path) {
240                continue;
241            }
242            if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err()
243            {
244                stale_registry_ids.push(record.id.clone());
245                continue;
246            }
247            let plugin = load_plugin_definition(
248                &record.install_path,
249                record.kind,
250                describe_install_source(&record.source),
251                record.kind.marketplace(),
252            )?;
253            if seen_ids.insert(plugin.metadata().id.clone()) {
254                seen_paths.insert(record.install_path.clone());
255                plugins.push(plugin);
256            }
257        }
258
259        if !stale_registry_ids.is_empty() {
260            for plugin_id in stale_registry_ids {
261                registry.plugins.remove(&plugin_id);
262            }
263            self.store_registry(&registry)?;
264        }
265
266        Ok(plugins)
267    }
268
269    fn discover_external_directory_plugins(
270        &self,
271        existing_plugins: &[PluginDefinition],
272    ) -> Result<Vec<PluginDefinition>, PluginError> {
273        let mut plugins = Vec::new();
274
275        for directory in &self.config.external_dirs {
276            for root in discover_plugin_dirs(directory)? {
277                let plugin = load_plugin_definition(
278                    &root,
279                    PluginKind::External,
280                    root.display().to_string(),
281                    EXTERNAL_MARKETPLACE,
282                )?;
283                if existing_plugins
284                    .iter()
285                    .chain(plugins.iter())
286                    .all(|existing| existing.metadata().id != plugin.metadata().id)
287                {
288                    plugins.push(plugin);
289                }
290            }
291        }
292
293        Ok(plugins)
294    }
295
296    fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
297        self.sync_bundled_plugins()?;
298        Ok(PluginRegistry::new(
299            self.discover_installed_plugins()?
300                .into_iter()
301                .map(|plugin| {
302                    let enabled = self.is_enabled(plugin.metadata());
303                    RegisteredPlugin::new(plugin, enabled)
304                })
305                .collect(),
306        ))
307    }
308
309    fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
310        let install_root = self.install_root();
311        let mut registry = self.load_registry()?;
312        let mut changed = false;
313        let mut active_ids = BTreeSet::new();
314
315        if let Some(ref bundled_root) = self.config.bundled_root {
316            for source_root in discover_plugin_dirs(bundled_root)? {
317                let manifest = load_plugin_from_directory(&source_root)?;
318                let source = PluginInstallSource::LocalPath {
319                    path: source_root.clone(),
320                };
321                self.sync_one_bundled(
322                    &manifest.name,
323                    &manifest.version,
324                    &manifest.description,
325                    &install_root,
326                    &mut registry,
327                    &mut active_ids,
328                    &mut changed,
329                    |dest| copy_dir_all(&source_root, dest),
330                    source,
331                )?;
332            }
333        } else {
334            use crate::bundled::BUNDLED_PLUGINS;
335            for bp in BUNDLED_PLUGINS {
336                self.sync_one_bundled(
337                    bp.name,
338                    bp.version,
339                    bp.description,
340                    &install_root,
341                    &mut registry,
342                    &mut active_ids,
343                    &mut changed,
344                    |dest| bp.materialize(dest).map_err(PluginError::Io),
345                    PluginInstallSource::Embedded,
346                )?;
347            }
348        }
349
350        // Prune bundled entries no longer present in the source set.
351        let stale: Vec<String> = registry
352            .plugins
353            .iter()
354            .filter_map(|(id, r)| {
355                (r.kind == PluginKind::Bundled && !active_ids.contains(id)).then_some(id.clone())
356            })
357            .collect();
358        for id in stale {
359            if let Some(r) = registry.plugins.remove(&id) {
360                if r.install_path.exists() {
361                    fs::remove_dir_all(&r.install_path)?;
362                }
363                changed = true;
364            }
365        }
366
367        if changed {
368            self.store_registry(&registry)?;
369        }
370        Ok(())
371    }
372
373    #[allow(clippy::too_many_arguments)]
374    fn sync_one_bundled(
375        &self,
376        name: &str,
377        version: &str,
378        description: &str,
379        install_root: &std::path::Path,
380        registry: &mut InstalledPluginRegistry,
381        active_ids: &mut BTreeSet<String>,
382        changed: &mut bool,
383        write_files: impl FnOnce(&std::path::Path) -> Result<(), PluginError>,
384        source: PluginInstallSource,
385    ) -> Result<(), PluginError> {
386        let pid = plugin_id(name, BUNDLED_MARKETPLACE);
387        active_ids.insert(pid.clone());
388        let install_path = install_root.join(sanitize_plugin_id(&pid));
389        let now = unix_time_ms();
390        let existing = registry.plugins.get(&pid);
391        let installed_ok =
392            install_path.exists() && load_plugin_from_directory(&install_path).is_ok();
393        let needs_sync = existing.is_none_or(|r| {
394            r.kind != PluginKind::Bundled
395                || r.version != version
396                || r.name != name
397                || r.description != description
398                || r.install_path != install_path
399                || !r.install_path.exists()
400                || !installed_ok
401        });
402        if !needs_sync {
403            return Ok(());
404        }
405
406        if install_path.exists() {
407            fs::remove_dir_all(&install_path)?;
408        }
409        write_files(&install_path)?;
410        let manifest = load_plugin_from_directory(&install_path)?;
411
412        let installed_at = existing.map_or(now, |r| r.installed_at_unix_ms);
413        registry.plugins.insert(
414            pid.clone(),
415            InstalledPluginRecord {
416                kind: PluginKind::Bundled,
417                id: pid,
418                name: manifest.name,
419                version: manifest.version,
420                description: manifest.description,
421                install_path,
422                source,
423                installed_at_unix_ms: installed_at,
424                updated_at_unix_ms: now,
425            },
426        );
427        *changed = true;
428        Ok(())
429    }
430
431    fn is_enabled(&self, metadata: &PluginMetadata) -> bool {
432        self.config
433            .enabled_plugins
434            .get(&metadata.id)
435            .copied()
436            .unwrap_or(match metadata.kind {
437                PluginKind::External => false,
438                PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
439            })
440    }
441
442    fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
443        if self.plugin_registry()?.contains(plugin_id) {
444            Ok(())
445        } else {
446            Err(PluginError::NotFound(format!(
447                "plugin `{plugin_id}` is not installed or discoverable"
448            )))
449        }
450    }
451
452    pub(crate) fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> {
453        let path = self.registry_path();
454        match fs::read_to_string(&path) {
455            Ok(contents) if contents.trim().is_empty() => Ok(InstalledPluginRegistry::default()),
456            Ok(contents) => Ok(serde_json::from_str(&contents)?),
457            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
458                Ok(InstalledPluginRegistry::default())
459            }
460            Err(error) => Err(PluginError::Io(error)),
461        }
462    }
463
464    pub(crate) fn store_registry(
465        &self,
466        registry: &InstalledPluginRegistry,
467    ) -> Result<(), PluginError> {
468        let path = self.registry_path();
469        if let Some(parent) = path.parent() {
470            fs::create_dir_all(parent)?;
471        }
472        fs::write(path, serde_json::to_string_pretty(registry)?)?;
473        Ok(())
474    }
475
476    pub(crate) fn write_enabled_state(
477        &self,
478        plugin_id: &str,
479        enabled: Option<bool>,
480    ) -> Result<(), PluginError> {
481        update_settings_json(&self.settings_path(), |root| {
482            let enabled_plugins = ensure_object(root, "enabledPlugins");
483            match enabled {
484                Some(value) => {
485                    enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
486                }
487                None => {
488                    enabled_plugins.remove(plugin_id);
489                }
490            }
491        })
492    }
493}