vtcode_core/marketplace/
registry.rs1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
17pub enum MarketplaceSource {
18 GitHub {
20 id: String,
22 owner: String,
24 repo: String,
26 refspec: Option<String>,
28 },
29 Git {
31 id: String,
33 url: String,
35 refspec: Option<String>,
37 },
38 Local {
40 id: String,
42 path: String,
44 },
45 Remote {
47 id: String,
49 url: String,
51 },
52}
53
54impl MarketplaceSource {
55 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
66pub struct MarketplaceRegistry {
68 #[expect(
70 dead_code,
71 reason = "Intentional compatibility, platform, test, or API-shape suppression."
72 )]
73 base_dir: PathBuf,
74
75 marketplaces: RwLock<HashMap<MarketplaceId, MarketplaceSource>>,
77
78 manifest_cache: RwLock<HashMap<MarketplaceId, MarketplaceManifest>>,
80}
81
82impl MarketplaceRegistry {
83 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 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 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 let mut cache = self.manifest_cache.write().await;
108 cache.remove(id);
109
110 Ok(())
111 }
112
113 pub async fn list_marketplaces(&self) -> Vec<MarketplaceSource> {
115 let marketplaces = self.marketplaces.read().await;
116 marketplaces.values().cloned().collect()
117 }
118
119 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 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 async fn fetch_manifest(&self, source: &MarketplaceSource) -> Result<MarketplaceManifest> {
147 match source {
148 MarketplaceSource::GitHub { owner, repo, refspec, .. } => {
149 self.fetch_github_manifest(owner, repo, refspec.as_deref()).await
151 }
152 MarketplaceSource::Git { url, refspec, .. } => {
153 self.fetch_git_manifest(url, refspec.as_deref()).await
155 }
156 MarketplaceSource::Local { path, .. } => {
157 self.fetch_local_manifest(path).await
159 }
160 MarketplaceSource::Remote { url, .. } => {
161 self.fetch_remote_manifest(url).await
163 }
164 }
165 }
166
167 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 let refspec = refspec.unwrap_or("main");
178
179 let api_url = format!(
181 "https://api.github.com/repos/{owner}/{repo}/contents/.vtcode-plugin/marketplace.json?ref={refspec}"
182 );
183
184 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 let json_response: Value = response
208 .json()
209 .await
210 .with_context(|| format!("Failed to parse GitHub API response for {owner}/{repo}"))?;
211
212 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 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_json_with_context(&content, &format!("GitHub: {owner}/{repo}"))
227 }
228
229 async fn fetch_git_manifest(&self, url: &str, refspec: Option<&str>) -> Result<MarketplaceManifest> {
231 use tempfile::TempDir;
232 use tokio::process::Command;
233
234 let temp_dir = TempDir::new().with_context(|| "Failed to create temporary directory for git clone")?;
236 let temp_path = temp_dir.path();
237
238 let mut git_cmd = Command::new("git");
240 git_cmd.arg("clone").arg(url).arg(temp_path);
241
242 if let Some(refspec) = refspec {
244 git_cmd.arg("--branch").arg(refspec);
245 }
246
247 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 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 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 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 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 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 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}