Skip to main content

opendev_plugins/
marketplace.rs

1//! Marketplace client: fetch plugin listings, search, download/install from marketplace.
2
3use crate::manager::{PluginError, PluginManager, Result};
4use crate::models::{MarketplaceCatalog, MarketplaceInfo, PluginMetadata};
5use chrono::Utc;
6use std::path::Path;
7use tracing::info;
8
9impl PluginManager {
10    // ── Marketplace management ─────────────────────────────────
11
12    /// Add a marketplace by recording its info and cloning its repository.
13    /// In this Rust port the actual git clone is performed synchronously
14    /// via `std::process::Command`.
15    pub fn add_marketplace(
16        &self,
17        url: &str,
18        name: Option<&str>,
19        branch: &str,
20    ) -> Result<MarketplaceInfo> {
21        let name = name
22            .map(String::from)
23            .unwrap_or_else(|| Self::extract_name_from_url(url));
24
25        // Check if marketplace already exists
26        let mut marketplaces = self.load_known_marketplaces()?;
27        if marketplaces.marketplaces.contains_key(&name) {
28            return Err(PluginError::MarketplaceAlreadyExists(name));
29        }
30
31        // Prepare target directory
32        let target_dir = self.paths.global_marketplaces_dir.join(&name);
33        if target_dir.exists() {
34            std::fs::remove_dir_all(&target_dir)?;
35        }
36
37        // Clone repository
38        self.git_clone(url, branch, &target_dir)?;
39
40        // Validate marketplace structure
41        if !self.validate_marketplace(&target_dir) {
42            let _ = std::fs::remove_dir_all(&target_dir);
43            return Err(PluginError::InvalidPlugin(
44                "Invalid marketplace: no marketplace.json, plugins/, or skills/ directory found"
45                    .to_string(),
46            ));
47        }
48
49        // Register marketplace
50        let info = MarketplaceInfo {
51            name: name.clone(),
52            url: url.to_string(),
53            branch: branch.to_string(),
54            added_at: Utc::now(),
55            last_updated: Some(Utc::now()),
56        };
57        marketplaces.marketplaces.insert(name.clone(), info.clone());
58        self.save_known_marketplaces(&marketplaces)?;
59
60        info!(marketplace = %name, "Marketplace added");
61        Ok(info)
62    }
63
64    /// Remove a marketplace.
65    pub fn remove_marketplace(&self, name: &str) -> Result<()> {
66        let mut marketplaces = self.load_known_marketplaces()?;
67        if !marketplaces.marketplaces.contains_key(name) {
68            return Err(PluginError::MarketplaceNotFound(name.to_string()));
69        }
70
71        // Remove directory
72        let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
73        if marketplace_dir.exists() {
74            std::fs::remove_dir_all(&marketplace_dir)?;
75        }
76
77        marketplaces.marketplaces.remove(name);
78        self.save_known_marketplaces(&marketplaces)?;
79        info!(marketplace = name, "Marketplace removed");
80        Ok(())
81    }
82
83    /// List all registered marketplaces.
84    pub fn list_marketplaces(&self) -> Result<Vec<MarketplaceInfo>> {
85        let marketplaces = self.load_known_marketplaces()?;
86        Ok(marketplaces.marketplaces.into_values().collect())
87    }
88
89    /// Sync (git pull) a marketplace.
90    pub fn sync_marketplace(&self, name: &str) -> Result<()> {
91        let mut marketplaces = self.load_known_marketplaces()?;
92        if !marketplaces.marketplaces.contains_key(name) {
93            return Err(PluginError::MarketplaceNotFound(name.to_string()));
94        }
95
96        let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
97        if !marketplace_dir.exists() {
98            return Err(PluginError::Other(format!(
99                "Marketplace directory missing: {}",
100                marketplace_dir.display()
101            )));
102        }
103
104        self.git_pull(&marketplace_dir)?;
105
106        // Update timestamp
107        if let Some(info) = marketplaces.marketplaces.get_mut(name) {
108            info.last_updated = Some(Utc::now());
109        }
110        self.save_known_marketplaces(&marketplaces)?;
111        info!(marketplace = name, "Marketplace synced");
112        Ok(())
113    }
114
115    /// Sync all registered marketplaces. Returns a map of name to optional error message.
116    pub fn sync_all_marketplaces(
117        &self,
118    ) -> Result<std::collections::HashMap<String, Option<String>>> {
119        let mut results = std::collections::HashMap::new();
120        let marketplaces = self.list_marketplaces()?;
121        for m in marketplaces {
122            match self.sync_marketplace(&m.name) {
123                Ok(()) => {
124                    results.insert(m.name, None);
125                }
126                Err(e) => {
127                    results.insert(m.name, Some(e.to_string()));
128                }
129            }
130        }
131        Ok(results)
132    }
133
134    // ── Catalog ────────────────────────────────────────────────
135
136    /// Get the plugin catalog from a marketplace.
137    /// If no marketplace.json exists, auto-discovers plugins from plugins/ and skills/ dirs.
138    pub fn get_marketplace_catalog(&self, name: &str) -> Result<MarketplaceCatalog> {
139        let marketplaces = self.load_known_marketplaces()?;
140        if !marketplaces.marketplaces.contains_key(name) {
141            return Err(PluginError::MarketplaceNotFound(name.to_string()));
142        }
143
144        let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
145        if let Some(catalog_path) = self.get_marketplace_json_path(&marketplace_dir) {
146            let content = std::fs::read_to_string(catalog_path)?;
147            let catalog: MarketplaceCatalog = serde_json::from_str(&content)?;
148            return Ok(catalog);
149        }
150
151        // Auto-discover
152        Ok(self.auto_discover_catalog(&marketplace_dir))
153    }
154
155    /// List all plugins available in a marketplace.
156    pub fn list_marketplace_plugins(&self, name: &str) -> Result<Vec<PluginMetadata>> {
157        let catalog = self.get_marketplace_catalog(name)?;
158        let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
159        let mut plugins = Vec::new();
160
161        // Check plugins/ directory
162        let plugins_dir = marketplace_dir.join("plugins");
163        if plugins_dir.exists() {
164            for plugin_name in &catalog.plugins {
165                let plugin_dir = plugins_dir.join(plugin_name);
166                if plugin_dir.exists() {
167                    match self.load_plugin_metadata(&plugin_dir) {
168                        Ok(metadata) => plugins.push(metadata),
169                        Err(_) => {
170                            plugins.push(PluginMetadata {
171                                name: plugin_name.clone(),
172                                version: "0.0.0".to_string(),
173                                description: format!("Plugin: {}", plugin_name),
174                                author: None,
175                                skills: Self::discover_skills_in_dir(&plugin_dir),
176                                repository: None,
177                                license: None,
178                            });
179                        }
180                    }
181                }
182            }
183        }
184
185        // Check skills/ directory for auto-discovered catalogs
186        let skills_dir = marketplace_dir.join("skills");
187        if skills_dir.exists() && catalog.auto_discovered {
188            for skill_name in &catalog.plugins {
189                let skill_dir = skills_dir.join(skill_name);
190                if skill_dir.exists() && skill_dir.join("SKILL.md").exists() {
191                    // Skip duplicates
192                    if plugins.iter().any(|p| p.name == *skill_name) {
193                        continue;
194                    }
195                    let (_name, desc) = Self::parse_skill_metadata(&skill_dir.join("SKILL.md"));
196                    plugins.push(PluginMetadata {
197                        name: skill_name.clone(),
198                        version: "0.0.0".to_string(),
199                        description: if desc.is_empty() {
200                            format!("Skill: {}", skill_name)
201                        } else {
202                            desc
203                        },
204                        author: None,
205                        skills: vec![skill_name.clone()],
206                        repository: None,
207                        license: None,
208                    });
209                }
210            }
211        }
212
213        Ok(plugins)
214    }
215
216    /// Search marketplace plugins by query string. Simple substring match on name/description.
217    pub fn search_marketplace(
218        &self,
219        marketplace_name: &str,
220        query: &str,
221    ) -> Result<Vec<PluginMetadata>> {
222        let plugins = self.list_marketplace_plugins(marketplace_name)?;
223        let query_lower = query.to_lowercase();
224        Ok(plugins
225            .into_iter()
226            .filter(|p| {
227                p.name.to_lowercase().contains(&query_lower)
228                    || p.description.to_lowercase().contains(&query_lower)
229            })
230            .collect())
231    }
232
233    // ── Marketplace HTTP fetch (async) ─────────────────────────
234
235    /// Fetch a marketplace catalog from a remote HTTP registry URL.
236    pub async fn fetch_remote_catalog(registry_url: &str) -> Result<MarketplaceCatalog> {
237        let client = reqwest::Client::new();
238        let response = client
239            .get(registry_url)
240            .header("Accept", "application/json")
241            .send()
242            .await
243            .map_err(|e| PluginError::Other(format!("HTTP request failed: {}", e)))?;
244
245        if !response.status().is_success() {
246            return Err(PluginError::Other(format!(
247                "Registry returned status {}",
248                response.status()
249            )));
250        }
251
252        let text = response
253            .text()
254            .await
255            .map_err(|e| PluginError::Other(format!("Failed to read response body: {}", e)))?;
256
257        let catalog: MarketplaceCatalog = serde_json::from_str(&text)?;
258
259        Ok(catalog)
260    }
261
262    // ── Internal helpers ───────────────────────────────────────
263
264    /// Validate marketplace directory structure.
265    fn validate_marketplace(&self, directory: &Path) -> bool {
266        // Check for marketplace.json in various locations
267        if self.get_marketplace_json_path(directory).is_some() {
268            return true;
269        }
270        // Auto-discovery: accept if plugins/ or skills/ directory exists
271        let plugins_dir = directory.join("plugins");
272        if plugins_dir.exists() && plugins_dir.is_dir() {
273            return true;
274        }
275        let skills_dir = directory.join("skills");
276        if skills_dir.exists() && skills_dir.is_dir() {
277            return true;
278        }
279        false
280    }
281
282    /// Find the marketplace.json file in a marketplace directory.
283    fn get_marketplace_json_path(&self, directory: &Path) -> Option<std::path::PathBuf> {
284        let possible_paths = [
285            directory.join(".opendev").join("marketplace.json"),
286            directory.join("marketplace.json"),
287        ];
288        possible_paths.into_iter().find(|p| p.exists())
289    }
290
291    /// Auto-discover plugins when no marketplace.json exists.
292    fn auto_discover_catalog(&self, marketplace_dir: &Path) -> MarketplaceCatalog {
293        let mut plugin_names = Vec::new();
294
295        // Check plugins/ directory
296        let plugins_dir = marketplace_dir.join("plugins");
297        if plugins_dir.exists()
298            && plugins_dir.is_dir()
299            && let Ok(entries) = std::fs::read_dir(&plugins_dir)
300        {
301            for entry in entries.flatten() {
302                if entry.path().is_dir()
303                    && let Some(name) = entry.file_name().to_str()
304                {
305                    plugin_names.push(name.to_string());
306                }
307            }
308        }
309
310        // Check skills/ directory
311        let skills_dir = marketplace_dir.join("skills");
312        if skills_dir.exists()
313            && skills_dir.is_dir()
314            && let Ok(entries) = std::fs::read_dir(&skills_dir)
315        {
316            for entry in entries.flatten() {
317                let path = entry.path();
318                if path.is_dir()
319                    && path.join("SKILL.md").exists()
320                    && let Some(name) = entry.file_name().to_str()
321                {
322                    plugin_names.push(name.to_string());
323                }
324            }
325        }
326
327        MarketplaceCatalog {
328            plugins: plugin_names,
329            auto_discovered: true,
330        }
331    }
332
333    /// Run `git clone --depth 1` into a target directory.
334    fn git_clone(&self, url: &str, branch: &str, target_dir: &Path) -> Result<()> {
335        let output = std::process::Command::new("git")
336            .args([
337                "clone",
338                "--depth",
339                "1",
340                "--branch",
341                branch,
342                url,
343                &target_dir.to_string_lossy(),
344            ])
345            .output()
346            .map_err(|e| PluginError::Git(format!("Failed to run git: {}", e)))?;
347
348        if !output.status.success() {
349            let stderr = String::from_utf8_lossy(&output.stderr);
350            return Err(PluginError::Git(format!("Git clone failed: {}", stderr)));
351        }
352        Ok(())
353    }
354
355    /// Run `git pull` in a directory.
356    fn git_pull(&self, dir: &Path) -> Result<()> {
357        let output = std::process::Command::new("git")
358            .args(["pull"])
359            .current_dir(dir)
360            .output()
361            .map_err(|e| PluginError::Git(format!("Failed to run git: {}", e)))?;
362
363        if !output.status.success() {
364            let stderr = String::from_utf8_lossy(&output.stderr);
365            return Err(PluginError::Git(format!("Git pull failed: {}", stderr)));
366        }
367        Ok(())
368    }
369}