Skip to main content

opendev_plugins/
manager.rs

1//! Plugin manager: discovery, install/uninstall, enable/disable.
2
3use crate::models::{
4    InstalledPlugins, KnownMarketplaces, PluginConfig, PluginManifest, PluginMetadata, PluginScope,
5    PluginSource, PluginStatus,
6};
7use chrono::Utc;
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, info, warn};
11
12/// Errors produced by the plugin manager.
13#[derive(Debug, Error)]
14pub enum PluginError {
15    #[error("Plugin not found: {0}")]
16    NotFound(String),
17    #[error("Plugin already installed: {0}")]
18    AlreadyInstalled(String),
19    #[error("Marketplace not found: {0}")]
20    MarketplaceNotFound(String),
21    #[error("Marketplace already exists: {0}")]
22    MarketplaceAlreadyExists(String),
23    #[error("Invalid plugin: {0}")]
24    InvalidPlugin(String),
25    #[error("IO error: {0}")]
26    Io(#[from] std::io::Error),
27    #[error("JSON error: {0}")]
28    Json(#[from] serde_json::Error),
29    #[error("Git operation failed: {0}")]
30    Git(String),
31    #[error("Plugin manager error: {0}")]
32    Other(String),
33}
34
35pub type Result<T> = std::result::Result<T, PluginError>;
36
37/// Paths used by the plugin manager.
38#[derive(Debug, Clone)]
39pub struct PluginPaths {
40    /// Global plugins directory: ~/.opendev/plugins/
41    pub global_plugins_dir: PathBuf,
42    /// Project plugins directory: .opendev/plugins/
43    pub project_plugins_dir: PathBuf,
44    /// Global marketplaces directory: ~/.opendev/marketplaces/
45    pub global_marketplaces_dir: PathBuf,
46    /// Global plugin cache: ~/.opendev/plugins/cache/
47    pub global_plugin_cache_dir: PathBuf,
48    /// Known marketplaces registry file.
49    pub known_marketplaces_file: PathBuf,
50    /// Global installed plugins registry file.
51    pub global_installed_plugins_file: PathBuf,
52    /// Project installed plugins registry file.
53    pub project_installed_plugins_file: PathBuf,
54}
55
56impl PluginPaths {
57    /// Build plugin paths from an optional working directory.
58    pub fn new(working_dir: Option<&Path>) -> Self {
59        let home = dirs_next::home_dir().unwrap_or_else(|| PathBuf::from("."));
60        let global_base = home.join(".opendev");
61        let project_base = working_dir
62            .map(|d| d.join(".opendev"))
63            .unwrap_or_else(|| PathBuf::from(".opendev"));
64
65        Self {
66            global_plugins_dir: global_base.join("plugins"),
67            project_plugins_dir: project_base.join("plugins"),
68            global_marketplaces_dir: global_base.join("marketplaces"),
69            global_plugin_cache_dir: global_base.join("plugins").join("cache"),
70            known_marketplaces_file: global_base.join("marketplaces.json"),
71            global_installed_plugins_file: global_base.join("installed_plugins.json"),
72            project_installed_plugins_file: project_base.join("installed_plugins.json"),
73        }
74    }
75}
76
77/// Plugin manager: discovers, installs, and manages plugins.
78pub struct PluginManager {
79    /// Working directory for path resolution.
80    pub working_dir: Option<PathBuf>,
81    /// Resolved paths.
82    pub paths: PluginPaths,
83}
84
85impl PluginManager {
86    /// Create a new PluginManager.
87    pub fn new(working_dir: Option<PathBuf>) -> Self {
88        let paths = PluginPaths::new(working_dir.as_deref());
89        Self { working_dir, paths }
90    }
91
92    // ── Discovery ──────────────────────────────────────────────
93
94    /// Discover plugins from both project and global plugin directories.
95    /// Scans each directory for subdirectories containing a `manifest.json`.
96    pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
97        let mut manifests = Vec::new();
98
99        // Project plugins (higher priority)
100        if self.paths.project_plugins_dir.exists() {
101            debug!(
102                path = %self.paths.project_plugins_dir.display(),
103                "Scanning project plugins directory"
104            );
105            self.scan_directory(&self.paths.project_plugins_dir, &mut manifests)?;
106        }
107
108        // Global plugins
109        if self.paths.global_plugins_dir.exists() {
110            debug!(
111                path = %self.paths.global_plugins_dir.display(),
112                "Scanning global plugins directory"
113            );
114            self.scan_directory(&self.paths.global_plugins_dir, &mut manifests)?;
115        }
116
117        info!(count = manifests.len(), "Discovered plugins");
118        Ok(manifests)
119    }
120
121    /// Scan a directory for plugin subdirectories containing `manifest.json`.
122    fn scan_directory(&self, dir: &Path, manifests: &mut Vec<PluginManifest>) -> Result<()> {
123        let entries = std::fs::read_dir(dir)?;
124        for entry in entries {
125            let entry = entry?;
126            let path = entry.path();
127            if path.is_dir() {
128                match self.load_manifest(&path) {
129                    Ok(manifest) => {
130                        debug!(name = %manifest.name, "Found plugin");
131                        manifests.push(manifest);
132                    }
133                    Err(e) => {
134                        warn!(
135                            path = %path.display(),
136                            error = %e,
137                            "Skipping directory: failed to load manifest"
138                        );
139                    }
140                }
141            }
142        }
143        Ok(())
144    }
145
146    /// Load a plugin manifest from a directory.
147    /// Checks multiple possible locations for `manifest.json`.
148    pub fn load_manifest(&self, plugin_dir: &Path) -> Result<PluginManifest> {
149        let possible_paths = [
150            plugin_dir.join(".opendev").join("manifest.json"),
151            plugin_dir.join("manifest.json"),
152            plugin_dir.join("plugin.json"),
153        ];
154
155        for path in &possible_paths {
156            if path.exists() {
157                let content = std::fs::read_to_string(path)?;
158                let manifest: PluginManifest = serde_json::from_str(&content)?;
159                return Ok(manifest);
160            }
161        }
162
163        Err(PluginError::InvalidPlugin(format!(
164            "No manifest.json found in {}",
165            plugin_dir.display()
166        )))
167    }
168
169    // ── Install / Uninstall ────────────────────────────────────
170
171    /// Install a plugin from a marketplace into the plugins directory.
172    pub fn install_plugin(
173        &self,
174        plugin_name: &str,
175        marketplace_name: &str,
176        scope: PluginScope,
177    ) -> Result<PluginConfig> {
178        // Check marketplace exists
179        let marketplaces = self.load_known_marketplaces()?;
180        if !marketplaces.marketplaces.contains_key(marketplace_name) {
181            return Err(PluginError::MarketplaceNotFound(
182                marketplace_name.to_string(),
183            ));
184        }
185
186        // Check not already installed
187        let installed = self.load_installed_plugins(scope)?;
188        if installed.get(marketplace_name, plugin_name).is_some() {
189            return Err(PluginError::AlreadyInstalled(format!(
190                "{}:{}",
191                marketplace_name, plugin_name
192            )));
193        }
194
195        // Locate plugin in marketplace directory
196        let marketplace_dir = self.paths.global_marketplaces_dir.join(marketplace_name);
197        let source_dir = marketplace_dir.join("plugins").join(plugin_name);
198        if !source_dir.exists() {
199            return Err(PluginError::NotFound(format!(
200                "Plugin '{}' not found in marketplace '{}'",
201                plugin_name, marketplace_name
202            )));
203        }
204
205        // Load manifest to get version
206        let manifest = self.load_manifest(&source_dir)?;
207
208        // Determine target directory
209        let cache_dir = match scope {
210            PluginScope::Project => self.paths.project_plugins_dir.join("cache"),
211            PluginScope::User => self.paths.global_plugin_cache_dir.clone(),
212        };
213        let target_dir = cache_dir
214            .join(marketplace_name)
215            .join(plugin_name)
216            .join(&manifest.version);
217
218        // Copy plugin to cache
219        if target_dir.exists() {
220            std::fs::remove_dir_all(&target_dir)?;
221        }
222        copy_dir_recursive(&source_dir, &target_dir)?;
223
224        // Register installation
225        let config = PluginConfig {
226            name: plugin_name.to_string(),
227            version: manifest.version.clone(),
228            source: PluginSource::Marketplace {
229                marketplace: marketplace_name.to_string(),
230            },
231            status: PluginStatus::Installed,
232            scope,
233            enabled: true,
234            path: target_dir,
235            installed_at: Utc::now(),
236            marketplace: Some(marketplace_name.to_string()),
237        };
238
239        let mut installed = self.load_installed_plugins(scope)?;
240        installed.add(config.clone());
241        self.save_installed_plugins(&installed, scope)?;
242
243        info!(
244            plugin = plugin_name,
245            marketplace = marketplace_name,
246            "Plugin installed"
247        );
248        Ok(config)
249    }
250
251    /// Uninstall a plugin.
252    pub fn uninstall_plugin(
253        &self,
254        plugin_name: &str,
255        marketplace_name: &str,
256        scope: PluginScope,
257    ) -> Result<()> {
258        let mut installed = self.load_installed_plugins(scope)?;
259        let plugin = installed
260            .remove(marketplace_name, plugin_name)
261            .ok_or_else(|| {
262                PluginError::NotFound(format!(
263                    "Plugin '{}:{}' not installed in {:?} scope",
264                    marketplace_name, plugin_name, scope
265                ))
266            })?;
267
268        // Remove from filesystem
269        if plugin.path.exists() {
270            std::fs::remove_dir_all(&plugin.path)?;
271        }
272
273        self.save_installed_plugins(&installed, scope)?;
274        info!(plugin = plugin_name, "Plugin uninstalled");
275        Ok(())
276    }
277
278    // ── Enable / Disable ───────────────────────────────────────
279
280    /// Enable a disabled plugin.
281    pub fn enable_plugin(
282        &self,
283        plugin_name: &str,
284        marketplace_name: &str,
285        scope: PluginScope,
286    ) -> Result<()> {
287        let mut installed = self.load_installed_plugins(scope)?;
288        let plugin = installed
289            .get_mut(marketplace_name, plugin_name)
290            .ok_or_else(|| {
291                PluginError::NotFound(format!(
292                    "Plugin '{}:{}' not installed in {:?} scope",
293                    marketplace_name, plugin_name, scope
294                ))
295            })?;
296
297        plugin.enabled = true;
298        plugin.status = PluginStatus::Installed;
299        self.save_installed_plugins(&installed, scope)?;
300        info!(plugin = plugin_name, "Plugin enabled");
301        Ok(())
302    }
303
304    /// Disable a plugin.
305    pub fn disable_plugin(
306        &self,
307        plugin_name: &str,
308        marketplace_name: &str,
309        scope: PluginScope,
310    ) -> Result<()> {
311        let mut installed = self.load_installed_plugins(scope)?;
312        let plugin = installed
313            .get_mut(marketplace_name, plugin_name)
314            .ok_or_else(|| {
315                PluginError::NotFound(format!(
316                    "Plugin '{}:{}' not installed in {:?} scope",
317                    marketplace_name, plugin_name, scope
318                ))
319            })?;
320
321        plugin.enabled = false;
322        plugin.status = PluginStatus::Disabled;
323        self.save_installed_plugins(&installed, scope)?;
324        info!(plugin = plugin_name, "Plugin disabled");
325        Ok(())
326    }
327
328    // ── List ───────────────────────────────────────────────────
329
330    /// List all installed plugins, optionally filtering by scope.
331    pub fn list_installed(&self, scope: Option<PluginScope>) -> Result<Vec<PluginConfig>> {
332        match scope {
333            Some(s) => {
334                let installed = self.load_installed_plugins(s)?;
335                Ok(installed.plugins.into_values().collect())
336            }
337            None => {
338                // Merge project + user, project takes priority
339                let project = self.load_installed_plugins(PluginScope::Project)?;
340                let user = self.load_installed_plugins(PluginScope::User)?;
341
342                let mut all: Vec<PluginConfig> = project.plugins.values().cloned().collect();
343
344                let project_keys: std::collections::HashSet<_> =
345                    project.plugins.keys().cloned().collect();
346                for (key, plugin) in &user.plugins {
347                    if !project_keys.contains(key) {
348                        all.push(plugin.clone());
349                    }
350                }
351                Ok(all)
352            }
353        }
354    }
355
356    // ── Registry persistence ───────────────────────────────────
357
358    /// Load the known marketplaces registry from disk.
359    pub fn load_known_marketplaces(&self) -> Result<KnownMarketplaces> {
360        let path = &self.paths.known_marketplaces_file;
361        if !path.exists() {
362            return Ok(KnownMarketplaces::default());
363        }
364        let content = std::fs::read_to_string(path)?;
365        let marketplaces: KnownMarketplaces = serde_json::from_str(&content)?;
366        Ok(marketplaces)
367    }
368
369    /// Save the known marketplaces registry to disk.
370    pub fn save_known_marketplaces(&self, marketplaces: &KnownMarketplaces) -> Result<()> {
371        let path = &self.paths.known_marketplaces_file;
372        if let Some(parent) = path.parent() {
373            std::fs::create_dir_all(parent)?;
374        }
375        let content = serde_json::to_string_pretty(marketplaces)?;
376        std::fs::write(path, content)?;
377        Ok(())
378    }
379
380    /// Load the installed plugins registry from disk.
381    pub fn load_installed_plugins(&self, scope: PluginScope) -> Result<InstalledPlugins> {
382        let path = match scope {
383            PluginScope::User => &self.paths.global_installed_plugins_file,
384            PluginScope::Project => &self.paths.project_installed_plugins_file,
385        };
386        if !path.exists() {
387            return Ok(InstalledPlugins::default());
388        }
389        let content = std::fs::read_to_string(path)?;
390        let plugins: InstalledPlugins = serde_json::from_str(&content)?;
391        Ok(plugins)
392    }
393
394    /// Save the installed plugins registry to disk.
395    pub fn save_installed_plugins(
396        &self,
397        plugins: &InstalledPlugins,
398        scope: PluginScope,
399    ) -> Result<()> {
400        let path = match scope {
401            PluginScope::User => &self.paths.global_installed_plugins_file,
402            PluginScope::Project => &self.paths.project_installed_plugins_file,
403        };
404        if let Some(parent) = path.parent() {
405            std::fs::create_dir_all(parent)?;
406        }
407        let content = serde_json::to_string_pretty(plugins)?;
408        std::fs::write(path, content)?;
409        Ok(())
410    }
411
412    // ── Plugin metadata loading ────────────────────────────────
413
414    /// Load plugin metadata from a plugin.json file in the given directory.
415    pub fn load_plugin_metadata(&self, plugin_dir: &Path) -> Result<PluginMetadata> {
416        let possible_paths = [
417            plugin_dir.join(".opendev").join("plugin.json"),
418            plugin_dir.join("plugin.json"),
419        ];
420
421        for path in &possible_paths {
422            if path.exists() {
423                let content = std::fs::read_to_string(path)?;
424                let metadata: PluginMetadata = serde_json::from_str(&content)?;
425                return Ok(metadata);
426            }
427        }
428
429        Err(PluginError::InvalidPlugin(format!(
430            "No plugin.json found in {}",
431            plugin_dir.display()
432        )))
433    }
434
435    /// Parse SKILL.md frontmatter for name and description.
436    pub fn parse_skill_metadata(skill_file: &Path) -> (String, String) {
437        let content = match std::fs::read_to_string(skill_file) {
438            Ok(c) => c,
439            Err(_) => return (String::new(), String::new()),
440        };
441
442        let mut name = String::new();
443        let mut description = String::new();
444
445        if content.starts_with("---") {
446            let parts: Vec<&str> = content.splitn(3, "---").collect();
447            if parts.len() >= 3 {
448                for line in parts[1].trim().lines() {
449                    let line = line.trim();
450                    if let Some(val) = line.strip_prefix("name:") {
451                        name = val
452                            .trim()
453                            .trim_matches(|c| c == '"' || c == '\'')
454                            .to_string();
455                    } else if let Some(val) = line.strip_prefix("description:") {
456                        description = val
457                            .trim()
458                            .trim_matches(|c| c == '"' || c == '\'')
459                            .to_string();
460                    }
461                }
462            }
463        }
464
465        (name, description)
466    }
467
468    /// Discover skill names in a plugin directory.
469    pub fn discover_skills_in_dir(plugin_dir: &Path) -> Vec<String> {
470        let skills_dir = plugin_dir.join("skills");
471        let mut skills = Vec::new();
472        if skills_dir.exists()
473            && skills_dir.is_dir()
474            && let Ok(entries) = std::fs::read_dir(&skills_dir)
475        {
476            for entry in entries.flatten() {
477                let path = entry.path();
478                if path.is_dir()
479                    && path.join("SKILL.md").exists()
480                    && let Some(name) = path.file_name().and_then(|n| n.to_str())
481                {
482                    skills.push(name.to_string());
483                }
484            }
485        }
486        skills
487    }
488
489    /// Extract a name from a git URL.
490    pub fn extract_name_from_url(url: &str) -> String {
491        // Remove .git suffix
492        let cleaned = regex::Regex::new(r"\.git$")
493            .unwrap()
494            .replace(url, "")
495            .to_string();
496
497        // Try to parse as URL
498        if let Ok(parsed) = url::Url::parse(&cleaned) {
499            let path = parsed.path().trim_matches('/');
500            if let Some(last) = path.split('/').next_back() {
501                let name = last.to_string();
502                let name = regex::Regex::new(r"^swecli-")
503                    .unwrap()
504                    .replace(&name, "")
505                    .to_string();
506                let name = regex::Regex::new(r"-marketplace$")
507                    .unwrap()
508                    .replace(&name, "")
509                    .to_string();
510                if !name.is_empty() {
511                    return name;
512                }
513            }
514        }
515
516        // Handle SSH-style URLs: git@github.com:user/repo
517        if cleaned.contains('@')
518            && cleaned.contains(':')
519            && let Some(path_part) = cleaned.split(':').next_back()
520            && let Some(last) = path_part.trim_matches('/').split('/').next_back()
521        {
522            let name = last.to_string();
523            let name = regex::Regex::new(r"^swecli-")
524                .unwrap()
525                .replace(&name, "")
526                .to_string();
527            let name = regex::Regex::new(r"-marketplace$")
528                .unwrap()
529                .replace(&name, "")
530                .to_string();
531            if !name.is_empty() {
532                return name;
533            }
534        }
535
536        "default".to_string()
537    }
538}
539
540/// Recursively copy a directory.
541pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
542    std::fs::create_dir_all(dst)?;
543    for entry in std::fs::read_dir(src)? {
544        let entry = entry?;
545        let src_path = entry.path();
546        let dst_path = dst.join(entry.file_name());
547        if src_path.is_dir() {
548            copy_dir_recursive(&src_path, &dst_path)?;
549        } else {
550            std::fs::copy(&src_path, &dst_path)?;
551        }
552    }
553    Ok(())
554}