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