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