Skip to main content

vtcode_core/marketplace/
registry.rs

1//! Marketplace registry for managing known marketplaces
2
3use hashbrown::HashMap;
4use std::path::PathBuf;
5
6use crate::utils::file_utils::{parse_json_with_context, read_json_file};
7use crate::utils::http_client;
8use anyhow::{Context, Result, bail};
9use base64;
10use serde::{Deserialize, Serialize};
11use tokio::sync::RwLock;
12
13use super::{MarketplaceId, MarketplaceManifest, PluginManifest};
14
15/// Source of a marketplace
16#[derive(Debug, Clone, Deserialize, Serialize)]
17pub enum MarketplaceSource {
18    /// GitHub repository (owner/repo format)
19    GitHub {
20        id: String,
21        owner: String,
22        repo: String,
23        refspec: Option<String>, // branch, tag, or commit
24    },
25    /// Git URL with optional refspec
26    Git {
27        id: String,
28        url: String,
29        refspec: Option<String>,
30    },
31    /// Local directory path
32    Local { id: String, path: String },
33    /// Remote URL to a marketplace manifest
34    Remote { id: String, url: String },
35}
36
37impl MarketplaceSource {
38    pub fn id(&self) -> &str {
39        match self {
40            MarketplaceSource::GitHub { id, .. } => id,
41            MarketplaceSource::Git { id, .. } => id,
42            MarketplaceSource::Local { id, .. } => id,
43            MarketplaceSource::Remote { id, .. } => id,
44        }
45    }
46}
47
48/// Registry for managing marketplaces
49pub struct MarketplaceRegistry {
50    /// Base directory for marketplace data
51    #[expect(dead_code)]
52    base_dir: PathBuf,
53
54    /// Registered marketplaces
55    marketplaces: RwLock<HashMap<MarketplaceId, MarketplaceSource>>,
56
57    /// Cache of marketplace manifests
58    manifest_cache: RwLock<HashMap<MarketplaceId, MarketplaceManifest>>,
59}
60
61impl MarketplaceRegistry {
62    pub fn new(base_dir: PathBuf) -> Self {
63        Self {
64            base_dir,
65            marketplaces: RwLock::new(HashMap::new()),
66            manifest_cache: RwLock::new(HashMap::new()),
67        }
68    }
69
70    /// Add a new marketplace source
71    pub async fn add_marketplace(&self, source: MarketplaceSource) -> Result<()> {
72        let mut marketplaces = self.marketplaces.write().await;
73        marketplaces.insert(source.id().to_string(), source);
74        Ok(())
75    }
76
77    /// Remove a marketplace by ID
78    pub async fn remove_marketplace(&self, id: &str) -> Result<()> {
79        let mut marketplaces = self.marketplaces.write().await;
80        if marketplaces.remove(id).is_none() {
81            bail!("Marketplace '{}' not found", id);
82        }
83
84        // Remove from cache as well
85        let mut cache = self.manifest_cache.write().await;
86        cache.remove(id);
87
88        Ok(())
89    }
90
91    /// List all registered marketplaces
92    pub async fn list_marketplaces(&self) -> Vec<MarketplaceSource> {
93        let marketplaces = self.marketplaces.read().await;
94        marketplaces.values().cloned().collect()
95    }
96
97    /// Get a specific marketplace source
98    pub async fn get_marketplace(&self, id: &str) -> Option<MarketplaceSource> {
99        let marketplaces = self.marketplaces.read().await;
100        marketplaces.get(id).cloned()
101    }
102
103    /// Update marketplace manifest cache
104    pub async fn update_marketplace(&self, id: &str) -> Result<()> {
105        let source = {
106            let marketplaces = self.marketplaces.read().await;
107            marketplaces.get(id).cloned()
108        };
109
110        let source = match source {
111            Some(s) => s,
112            None => bail!("Marketplace '{}' not found", id),
113        };
114
115        let manifest = self.fetch_manifest(&source).await?;
116
117        let mut cache = self.manifest_cache.write().await;
118        cache.insert(id.to_string(), manifest);
119
120        Ok(())
121    }
122
123    /// Fetch manifest from a source
124    async fn fetch_manifest(&self, source: &MarketplaceSource) -> Result<MarketplaceManifest> {
125        match source {
126            MarketplaceSource::GitHub {
127                owner,
128                repo,
129                refspec,
130                ..
131            } => {
132                // Fetch manifest from GitHub API using the authenticated client
133                self.fetch_github_manifest(owner, repo, refspec.as_deref())
134                    .await
135            }
136            MarketplaceSource::Git { url, refspec, .. } => {
137                // Fetch manifest by cloning the git repository
138                self.fetch_git_manifest(url, refspec.as_deref()).await
139            }
140            MarketplaceSource::Local { path, .. } => {
141                // Fetch manifest from local directory
142                self.fetch_local_manifest(path).await
143            }
144            MarketplaceSource::Remote { url, .. } => {
145                // Fetch manifest from remote HTTP/HTTPS URL
146                self.fetch_remote_manifest(url).await
147            }
148        }
149    }
150
151    /// Fetch manifest from GitHub repository
152    async fn fetch_github_manifest(
153        &self,
154        owner: &str,
155        repo: &str,
156        refspec: Option<&str>,
157    ) -> Result<MarketplaceManifest> {
158        use serde_json::Value;
159
160        // Determine the refspec (default to 'main' if not specified)
161        let refspec = refspec.unwrap_or("main");
162
163        // Construct the GitHub API URL to fetch the file
164        let api_url = format!(
165            "https://api.github.com/repos/{}/{}/contents/.vtcode-plugin/marketplace.json?ref={}",
166            owner, repo, refspec
167        );
168
169        // Create HTTP client with appropriate headers
170        let client = http_client::create_client_with_user_agent("vtcode");
171        let response = client
172            .get(&api_url)
173            .header("User-Agent", "vtcode")
174            .header("Accept", "application/vnd.github.v3+json")
175            .send()
176            .await
177            .with_context(|| {
178                format!(
179                    "Failed to fetch manifest from GitHub: {}/{} (ref: {})",
180                    owner, repo, refspec
181                )
182            })?;
183
184        if !response.status().is_success() {
185            if response.status() == 404 {
186                bail!(
187                    "Marketplace manifest not found in GitHub repository: {}/{} (ref: {})",
188                    owner,
189                    repo,
190                    refspec
191                );
192            } else {
193                bail!(
194                    "Failed to fetch manifest from GitHub API: HTTP {} - {}",
195                    response.status(),
196                    response.text().await.unwrap_or_default()
197                );
198            }
199        }
200
201        // Parse the GitHub API response
202        let json_response: Value = response.json().await.with_context(|| {
203            format!("Failed to parse GitHub API response for {}/{}", owner, repo)
204        })?;
205
206        // Extract the content from the response
207        let content_encoded = json_response
208            .get("content")
209            .and_then(|v| v.as_str())
210            .ok_or_else(|| anyhow::anyhow!("GitHub API response missing content field"))?;
211
212        // Decode the base64 content
213        let content_bytes =
214            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, content_encoded)
215                .with_context(|| {
216                    format!(
217                        "Failed to decode base64 content from GitHub: {}/{}",
218                        owner, repo
219                    )
220                })?;
221
222        let content = String::from_utf8(content_bytes).with_context(|| {
223            format!(
224                "Failed to decode UTF-8 content from GitHub: {}/{}",
225                owner, repo
226            )
227        })?;
228
229        // Parse the manifest from the content
230        parse_json_with_context(&content, &format!("GitHub: {}/{}", owner, repo))
231    }
232
233    /// Fetch manifest from Git repository
234    async fn fetch_git_manifest(
235        &self,
236        url: &str,
237        refspec: Option<&str>,
238    ) -> Result<MarketplaceManifest> {
239        use tempfile::TempDir;
240        use tokio::process::Command;
241
242        // Create a temporary directory for the git clone
243        let temp_dir =
244            TempDir::new().with_context(|| "Failed to create temporary directory for git clone")?;
245        let temp_path = temp_dir.path();
246
247        // Build the git clone command
248        let mut git_cmd = Command::new("git");
249        git_cmd.arg("clone").arg(url).arg(temp_path);
250
251        // Add branch/tag/commit if specified
252        if let Some(refspec) = refspec {
253            git_cmd.arg("--branch").arg(refspec);
254        }
255
256        // Execute the git clone
257        let output = git_cmd
258            .output()
259            .await
260            .with_context(|| format!("Failed to execute git clone for {}", url))?;
261
262        if !output.status.success() {
263            let stderr = String::from_utf8_lossy(&output.stderr);
264            bail!("Git clone failed for {}: {}", url, stderr);
265        }
266
267        // Look for the manifest file in the cloned repository
268        let manifest_path = temp_path.join(".vtcode-plugin/marketplace.json");
269        if !manifest_path.exists() {
270            bail!("Marketplace manifest not found in repository: {}", url);
271        }
272
273        read_json_file(&manifest_path).await
274    }
275
276    /// Fetch manifest from local path
277    async fn fetch_local_manifest(&self, path: &str) -> Result<MarketplaceManifest> {
278        use std::path::Path;
279
280        let manifest_path = Path::new(path).join(".vtcode-plugin/marketplace.json");
281        read_json_file(&manifest_path).await
282    }
283
284    /// Fetch manifest from remote URL
285    async fn fetch_remote_manifest(&self, url: &str) -> Result<MarketplaceManifest> {
286        let client = http_client::create_default_client();
287        let response = client
288            .get(url)
289            .send()
290            .await
291            .with_context(|| format!("Failed to fetch remote manifest from {}", url))?;
292
293        if !response.status().is_success() {
294            bail!(
295                "Failed to fetch remote manifest: HTTP {}",
296                response.status()
297            );
298        }
299
300        let content = response
301            .text()
302            .await
303            .with_context(|| format!("Failed to read response body from {}", url))?;
304
305        parse_json_with_context(&content, &format!("remote manifest: {}", url))
306    }
307
308    /// Get cached manifest for a marketplace
309    pub async fn get_cached_manifest(&self, id: &str) -> Option<MarketplaceManifest> {
310        let cache = self.manifest_cache.read().await;
311        cache.get(id).cloned()
312    }
313
314    /// List all plugins from all registered marketplaces
315    pub async fn list_all_plugins(&self) -> Vec<(MarketplaceId, PluginManifest)> {
316        let mut all_plugins = Vec::new();
317
318        let marketplaces = self.list_marketplaces().await;
319        for marketplace in marketplaces {
320            if let Some(manifest) = self.get_cached_manifest(marketplace.id()).await {
321                for plugin in manifest.plugins {
322                    all_plugins.push((marketplace.id().to_string(), plugin));
323                }
324            }
325        }
326
327        all_plugins
328    }
329
330    /// Find a specific plugin across all marketplaces
331    pub async fn find_plugin(&self, plugin_id: &str) -> Option<(MarketplaceId, PluginManifest)> {
332        let all_plugins = self.list_all_plugins().await;
333        all_plugins
334            .into_iter()
335            .find(|(_, plugin)| plugin.id == plugin_id)
336    }
337}