1use crate::util::http_client::shared_http_client;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ExtensionEntry {
20 pub source: String,
22 pub version: String,
24 pub installed_at: String,
26 pub wasm_file: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct ExtensionRegistry {
33 pub extensions: std::collections::HashMap<String, ExtensionEntry>,
34}
35
36impl ExtensionRegistry {
37 pub fn load() -> Result<Self> {
39 let path = Self::registry_path()?;
40 if !path.exists() {
41 return Ok(Self::default());
42 }
43 let data = std::fs::read_to_string(&path)
44 .with_context(|| format!("Failed to read {}", path.display()))?;
45 serde_json::from_str(&data).with_context(|| format!("Failed to parse {}", path.display()))
46 }
47
48 pub fn save(&self) -> Result<()> {
50 let path = Self::registry_path()?;
51 if let Some(parent) = path.parent() {
52 std::fs::create_dir_all(parent)?;
53 }
54 let data = serde_json::to_string_pretty(self)?;
55 std::fs::write(&path, data)
56 .with_context(|| format!("Failed to write {}", path.display()))?;
57 Ok(())
58 }
59
60 pub fn registry_path() -> Result<PathBuf> {
62 let home = dirs::home_dir().context("Cannot determine home directory")?;
63 Ok(home
64 .join(".oxicode")
65 .join("extensions")
66 .join("registry.json"))
67 }
68
69 pub fn extensions_dir() -> Result<PathBuf> {
71 let home = dirs::home_dir().context("Cannot determine home directory")?;
72 Ok(home.join(".oxicode").join("extensions"))
73 }
74}
75
76#[derive(Debug, Deserialize)]
79struct GitHubRelease {
80 tag_name: String,
81 assets: Vec<GitHubAsset>,
82 prerelease: bool,
83 draft: bool,
84}
85
86#[derive(Debug, Deserialize)]
87struct GitHubAsset {
88 name: String,
89 browser_download_url: String,
90 size: u64,
91}
92
93async fn fetch_latest_release(source: &str, include_prerelease: bool) -> Result<GitHubRelease> {
95 let url = format!("https://api.github.com/repos/{}/releases", source);
96 let client = shared_http_client();
97 let mut request = client.get(&url).header("User-Agent", "oxicode-ext");
98
99 if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
101 request = request.header("Authorization", format!("Bearer {}", token));
102 }
103
104 let releases: Vec<GitHubRelease> = request
105 .send()
106 .await
107 .with_context(|| format!("Failed to fetch releases for {}", source))?
108 .json()
109 .await
110 .with_context(|| format!("Failed to parse releases for {}", source))?;
111
112 releases
113 .into_iter()
114 .filter(|r| !r.draft)
115 .filter(|r| include_prerelease || !r.prerelease)
116 .find(|r| r.assets.iter().any(|a| a.name.ends_with(".wasm")))
117 .context(format!("No release with .wasm asset found for {}", source))
118}
119
120async fn download_file(url: &str, dest: &Path) -> Result<()> {
122 let response = reqwest::get(url)
123 .await
124 .with_context(|| format!("Failed to download {}", url))?;
125
126 if !response.status().is_success() {
127 anyhow::bail!("Download failed with status: {}", response.status());
128 }
129
130 let bytes = response
131 .bytes()
132 .await
133 .context("Failed to read download response")?;
134
135 std::fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?;
136
137 Ok(())
138}
139
140#[derive(Debug)]
144pub struct InstallResult {
145 pub name: String,
146 pub version: String,
147 pub source: String,
148 pub wasm_file: String,
149}
150
151pub async fn install_extension(source: &str, include_prerelease: bool) -> Result<InstallResult> {
156 let (repo, wanted_version) = if let Some((r, v)) = source.split_once('@') {
157 (r, Some(v.to_string()))
158 } else {
159 (source, None)
160 };
161
162 if !repo.contains('/') || repo.split('/').count() != 2 {
164 anyhow::bail!(
165 "Invalid source format: '{}'. Use 'owner/repo' (e.g. 'a7garden/oxicode-web-search')",
166 repo
167 );
168 }
169
170 let release = if let Some(tag) = &wanted_version {
171 let url = format!(
173 "https://api.github.com/repos/{}/releases/tags/{}",
174 repo, tag
175 );
176 let client = shared_http_client();
177 let mut request = client.get(&url).header("User-Agent", "oxicode-ext");
178 if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
179 request = request.header("Authorization", format!("Bearer {}", token));
180 }
181 request
182 .send()
183 .await
184 .with_context(|| format!("Failed to fetch release {} for {}", tag, repo))?
185 .json()
186 .await
187 .with_context(|| format!("Release {} not found for {}", tag, repo))?
188 } else {
189 fetch_latest_release(repo, include_prerelease).await?
190 };
191
192 let wasm_asset = release
194 .assets
195 .iter()
196 .find(|a| a.name.ends_with(".wasm"))
197 .context(format!(
198 "No .wasm file found in release {} of {}",
199 release.tag_name, repo
200 ))?;
201
202 let ext_name = wasm_asset
204 .name
205 .strip_suffix(".wasm")
206 .unwrap_or(&wasm_asset.name)
207 .to_string();
208
209 let extensions_dir = ExtensionRegistry::extensions_dir()?;
211 std::fs::create_dir_all(&extensions_dir)?;
212 let dest = extensions_dir.join(&wasm_asset.name);
213
214 println!(
215 "Downloading {} v{} ({:.1} KB)...",
216 ext_name,
217 release.tag_name,
218 wasm_asset.size as f64 / 1024.0
219 );
220
221 download_file(&wasm_asset.browser_download_url, &dest).await?;
222
223 let mut registry = ExtensionRegistry::load()?;
225 let entry = ExtensionEntry {
226 source: repo.to_string(),
227 version: release.tag_name.clone(),
228 installed_at: chrono::Utc::now().to_rfc3339(),
229 wasm_file: wasm_asset.name.clone(),
230 };
231 registry.extensions.insert(repo.to_string(), entry);
232 registry.save()?;
233
234 Ok(InstallResult {
235 name: repo.to_string(),
236 version: release.tag_name,
237 source: repo.to_string(),
238 wasm_file: wasm_asset.name.clone(),
239 })
240}
241
242pub fn remove_extension(source: &str) -> Result<()> {
244 let mut registry = ExtensionRegistry::load()?;
245
246 let entry = registry
247 .extensions
248 .remove(source)
249 .context(format!("Extension '{}' not found in registry", source))?;
250
251 let extensions_dir = ExtensionRegistry::extensions_dir()?;
253 let wasm_path = extensions_dir.join(&entry.wasm_file);
254 if wasm_path.exists() {
255 std::fs::remove_file(&wasm_path)
256 .with_context(|| format!("Failed to delete {}", wasm_path.display()))?;
257 }
258
259 registry.save()?;
260 Ok(())
261}
262
263pub fn list_extensions() -> Result<Vec<(String, ExtensionEntry)>> {
265 let registry = ExtensionRegistry::load()?;
266 let mut entries: Vec<_> = registry.extensions.into_iter().collect();
267 entries.sort_by(|a, b| a.0.cmp(&b.0));
268 Ok(entries)
269}
270
271pub async fn update_extension(source: Option<&str>) -> Result<Vec<InstallResult>> {
273 let registry = ExtensionRegistry::load()?;
274 let mut results = Vec::new();
275
276 let targets: Vec<(String, ExtensionEntry)> = if let Some(src) = source {
277 let entry = registry
278 .extensions
279 .get(src)
280 .cloned()
281 .context(format!("Extension '{}' not found", src))?;
282 vec![(src.to_string(), entry)]
283 } else {
284 registry.extensions.into_iter().collect()
285 };
286
287 for (_ext_source, entry) in targets {
288 let label = entry
289 .wasm_file
290 .strip_suffix(".wasm")
291 .unwrap_or(&entry.wasm_file);
292 match install_extension(&entry.source, false).await {
293 Ok(result) => {
294 println!("Updated {} to {}", label, result.version);
295 results.push(result);
296 }
297 Err(e) => {
298 eprintln!(
299 "{}",
300 crate::print_mode::format_error(&format!("Failed to update {}: {}", label, e))
301 );
302 }
303 }
304 }
305
306 Ok(results)
307}
308
309pub async fn info_extension(source: &str) -> Result<()> {
311 let release = fetch_latest_release(source, true).await?;
312 println!("Extension: {}", source);
313 println!("Latest version: {}", release.tag_name);
314 println!("Pre-release: {}", release.prerelease);
315 println!("Assets:");
316 for asset in &release.assets {
317 println!(" {} ({:.1} KB)", asset.name, asset.size as f64 / 1024.0);
318 }
319 Ok(())
320}