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.join(".oxi").join("extensions").join("registry.json"))
64 }
65
66 pub fn extensions_dir() -> Result<PathBuf> {
68 let home = dirs::home_dir().context("Cannot determine home directory")?;
69 Ok(home.join(".oxi").join("extensions"))
70 }
71}
72
73#[derive(Debug, Deserialize)]
76struct GitHubRelease {
77 tag_name: String,
78 assets: Vec<GitHubAsset>,
79 prerelease: bool,
80 draft: bool,
81}
82
83#[derive(Debug, Deserialize)]
84struct GitHubAsset {
85 name: String,
86 browser_download_url: String,
87 size: u64,
88}
89
90async fn fetch_latest_release(source: &str, include_prerelease: bool) -> Result<GitHubRelease> {
92 let url = format!("https://api.github.com/repos/{}/releases", source);
93 let client = shared_http_client();
94 let mut request = client.get(&url).header("User-Agent", "oxi-ext");
95
96 if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
98 request = request.header("Authorization", format!("Bearer {}", token));
99 }
100
101 let releases: Vec<GitHubRelease> = request
102 .send()
103 .await
104 .with_context(|| format!("Failed to fetch releases for {}", source))?
105 .json()
106 .await
107 .with_context(|| format!("Failed to parse releases for {}", source))?;
108
109 releases
110 .into_iter()
111 .filter(|r| !r.draft)
112 .filter(|r| include_prerelease || !r.prerelease)
113 .find(|r| r.assets.iter().any(|a| a.name.ends_with(".wasm")))
114 .context(format!("No release with .wasm asset found for {}", source))
115}
116
117async fn download_file(url: &str, dest: &Path) -> Result<()> {
119 let response = reqwest::get(url)
120 .await
121 .with_context(|| format!("Failed to download {}", url))?;
122
123 if !response.status().is_success() {
124 anyhow::bail!("Download failed with status: {}", response.status());
125 }
126
127 let bytes = response
128 .bytes()
129 .await
130 .context("Failed to read download response")?;
131
132 std::fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?;
133
134 Ok(())
135}
136
137#[derive(Debug)]
141pub struct InstallResult {
142 pub name: String,
143 pub version: String,
144 pub source: String,
145 pub wasm_file: String,
146}
147
148pub async fn install_extension(source: &str, include_prerelease: bool) -> Result<InstallResult> {
153 let (repo, wanted_version) = if let Some((r, v)) = source.split_once('@') {
154 (r, Some(v.to_string()))
155 } else {
156 (source, None)
157 };
158
159 if !repo.contains('/') || repo.split('/').count() != 2 {
161 anyhow::bail!(
162 "Invalid source format: '{}'. Use 'owner/repo' (e.g. 'a7garden/oxi-web-search')",
163 repo
164 );
165 }
166
167 let release = if let Some(tag) = &wanted_version {
168 let url = format!(
170 "https://api.github.com/repos/{}/releases/tags/{}",
171 repo, tag
172 );
173 let client = shared_http_client();
174 let mut request = client.get(&url).header("User-Agent", "oxi-ext");
175 if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
176 request = request.header("Authorization", format!("Bearer {}", token));
177 }
178 request
179 .send()
180 .await
181 .with_context(|| format!("Failed to fetch release {} for {}", tag, repo))?
182 .json()
183 .await
184 .with_context(|| format!("Release {} not found for {}", tag, repo))?
185 } else {
186 fetch_latest_release(repo, include_prerelease).await?
187 };
188
189 let wasm_asset = release
191 .assets
192 .iter()
193 .find(|a| a.name.ends_with(".wasm"))
194 .context(format!(
195 "No .wasm file found in release {} of {}",
196 release.tag_name, repo
197 ))?;
198
199 let ext_name = wasm_asset
201 .name
202 .strip_suffix(".wasm")
203 .unwrap_or(&wasm_asset.name)
204 .to_string();
205
206 let extensions_dir = ExtensionRegistry::extensions_dir()?;
208 std::fs::create_dir_all(&extensions_dir)?;
209 let dest = extensions_dir.join(&wasm_asset.name);
210
211 println!(
212 "Downloading {} v{} ({:.1} KB)...",
213 ext_name,
214 release.tag_name,
215 wasm_asset.size as f64 / 1024.0
216 );
217
218 download_file(&wasm_asset.browser_download_url, &dest).await?;
219
220 let mut registry = ExtensionRegistry::load()?;
222 let entry = ExtensionEntry {
223 source: repo.to_string(),
224 version: release.tag_name.clone(),
225 installed_at: chrono::Utc::now().to_rfc3339(),
226 wasm_file: wasm_asset.name.clone(),
227 };
228 registry.extensions.insert(ext_name.clone(), entry);
229 registry.save()?;
230
231 Ok(InstallResult {
232 name: ext_name,
233 version: release.tag_name,
234 source: repo.to_string(),
235 wasm_file: wasm_asset.name.clone(),
236 })
237}
238
239pub fn remove_extension(name: &str) -> Result<()> {
241 let mut registry = ExtensionRegistry::load()?;
242
243 let entry = registry
244 .extensions
245 .remove(name)
246 .context(format!("Extension '{}' not found in registry", name))?;
247
248 let extensions_dir = ExtensionRegistry::extensions_dir()?;
250 let wasm_path = extensions_dir.join(&entry.wasm_file);
251 if wasm_path.exists() {
252 std::fs::remove_file(&wasm_path)
253 .with_context(|| format!("Failed to delete {}", wasm_path.display()))?;
254 }
255
256 registry.save()?;
257 Ok(())
258}
259
260pub fn list_extensions() -> Result<Vec<(String, ExtensionEntry)>> {
262 let registry = ExtensionRegistry::load()?;
263 let mut entries: Vec<_> = registry.extensions.into_iter().collect();
264 entries.sort_by(|a, b| a.0.cmp(&b.0));
265 Ok(entries)
266}
267
268pub async fn update_extension(name: Option<&str>) -> Result<Vec<InstallResult>> {
270 let registry = ExtensionRegistry::load()?;
271 let mut results = Vec::new();
272
273 let targets: Vec<(String, ExtensionEntry)> = if let Some(name) = name {
274 let entry = registry
275 .extensions
276 .get(name)
277 .cloned()
278 .context(format!("Extension '{}' not found", name))?;
279 vec![(name.to_string(), entry)]
280 } else {
281 registry.extensions.into_iter().collect()
282 };
283
284 for (ext_name, entry) in targets {
285 match install_extension(&entry.source, false).await {
286 Ok(result) => {
287 println!("Updated {} to {}", ext_name, result.version);
288 results.push(result);
289 }
290 Err(e) => {
291 eprintln!("Failed to update {}: {}", ext_name, e);
292 }
293 }
294 }
295
296 Ok(results)
297}
298
299pub async fn info_extension(source: &str) -> Result<()> {
301 let release = fetch_latest_release(source, true).await?;
302 println!("Extension: {}", source);
303 println!("Latest version: {}", release.tag_name);
304 println!("Pre-release: {}", release.prerelease);
305 println!("Assets:");
306 for asset in &release.assets {
307 println!(" {} ({:.1} KB)", asset.name, asset.size as f64 / 1024.0);
308 }
309 Ok(())
310}