1use 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,
21 owner: String,
22 repo: String,
23 refspec: Option<String>, },
25 Git {
27 id: String,
28 url: String,
29 refspec: Option<String>,
30 },
31 Local { id: String, path: String },
33 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
48pub struct MarketplaceRegistry {
50 #[expect(dead_code)]
52 base_dir: PathBuf,
53
54 marketplaces: RwLock<HashMap<MarketplaceId, MarketplaceSource>>,
56
57 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 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 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 let mut cache = self.manifest_cache.write().await;
86 cache.remove(id);
87
88 Ok(())
89 }
90
91 pub async fn list_marketplaces(&self) -> Vec<MarketplaceSource> {
93 let marketplaces = self.marketplaces.read().await;
94 marketplaces.values().cloned().collect()
95 }
96
97 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 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 async fn fetch_manifest(&self, source: &MarketplaceSource) -> Result<MarketplaceManifest> {
125 match source {
126 MarketplaceSource::GitHub {
127 owner,
128 repo,
129 refspec,
130 ..
131 } => {
132 self.fetch_github_manifest(owner, repo, refspec.as_deref())
134 .await
135 }
136 MarketplaceSource::Git { url, refspec, .. } => {
137 self.fetch_git_manifest(url, refspec.as_deref()).await
139 }
140 MarketplaceSource::Local { path, .. } => {
141 self.fetch_local_manifest(path).await
143 }
144 MarketplaceSource::Remote { url, .. } => {
145 self.fetch_remote_manifest(url).await
147 }
148 }
149 }
150
151 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 let refspec = refspec.unwrap_or("main");
162
163 let api_url = format!(
165 "https://api.github.com/repos/{}/{}/contents/.vtcode-plugin/marketplace.json?ref={}",
166 owner, repo, refspec
167 );
168
169 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 let json_response: Value = response.json().await.with_context(|| {
203 format!("Failed to parse GitHub API response for {}/{}", owner, repo)
204 })?;
205
206 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 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_json_with_context(&content, &format!("GitHub: {}/{}", owner, repo))
231 }
232
233 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 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 let mut git_cmd = Command::new("git");
249 git_cmd.arg("clone").arg(url).arg(temp_path);
250
251 if let Some(refspec) = refspec {
253 git_cmd.arg("--branch").arg(refspec);
254 }
255
256 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 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 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 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 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 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 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}