1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5#[derive(Debug)]
7enum GitHubApiError {
8 HttpStatus(u16),
10 Network(String),
12 Parse(String),
14}
15
16impl std::fmt::Display for GitHubApiError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::HttpStatus(code) => write!(f, "HTTP {}", code),
20 Self::Network(msg) => write!(f, "request failed: {}", msg),
21 Self::Parse(msg) => write!(f, "{}", msg),
22 }
23 }
24}
25
26pub struct GitHubClient {
28 base_url: String,
29 token: Option<String>,
30}
31
32impl GitHubClient {
33 pub fn new() -> Self {
35 let token = std::env::var("KISH_GITHUB_TOKEN")
36 .ok()
37 .or_else(|| std::env::var("GITHUB_TOKEN").ok());
38 Self {
39 base_url: "https://api.github.com".to_string(),
40 token,
41 }
42 }
43
44 fn get_json(&self, url: &str) -> Result<serde_json::Value, GitHubApiError> {
45 let mut req = ureq::get(url)
46 .header("User-Agent", "yosh-plugin-manager")
47 .header("Accept", "application/vnd.github.v3+json");
48 if let Some(token) = &self.token {
49 req = req.header("Authorization", format!("Bearer {}", token));
50 }
51 let body = req
52 .call()
53 .map_err(|e| match &e {
54 ureq::Error::StatusCode(code) => GitHubApiError::HttpStatus(*code),
55 _ => GitHubApiError::Network(e.to_string()),
56 })?
57 .body_mut()
58 .read_to_string()
59 .map_err(|e| GitHubApiError::Parse(format!("failed to read body: {}", e)))?;
60 serde_json::from_str(&body)
61 .map_err(|e| GitHubApiError::Parse(format!("failed to parse JSON: {}", e)))
62 }
63
64 fn release_json(
65 &self,
66 owner: &str,
67 repo: &str,
68 tag: &str,
69 ) -> Result<serde_json::Value, GitHubApiError> {
70 let url = format!(
71 "{}/repos/{}/{}/releases/tags/{}",
72 self.base_url, owner, repo, tag
73 );
74 self.get_json(&url)
75 }
76
77 pub fn find_asset_url(
80 &self,
81 owner: &str,
82 repo: &str,
83 version: &str,
84 asset_name: &str,
85 ) -> Result<String, String> {
86 let v_tag = format!("v{}", version);
87 let release = match self.release_json(owner, repo, &v_tag) {
88 Ok(r) => r,
89 Err(_) => {
90 self.release_json(owner, repo, version)
92 .map_err(|e| match e {
93 GitHubApiError::HttpStatus(404) => format!(
94 "release not found for {}/{} (tried tags '{}' and '{}')",
95 owner, repo, v_tag, version
96 ),
97 other => format!(
98 "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}",
99 owner, repo, v_tag, version, other
100 ),
101 })?
102 }
103 };
104
105 let assets = release["assets"]
106 .as_array()
107 .ok_or_else(|| "release has no assets array".to_string())?;
108
109 for asset in assets {
110 if asset["name"].as_str() == Some(asset_name) {
111 let url = asset["browser_download_url"]
112 .as_str()
113 .ok_or_else(|| "asset has no browser_download_url".to_string())?;
114 return Ok(url.to_string());
115 }
116 }
117
118 Err(format!("asset '{}' not found in release", asset_name))
119 }
120
121 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
123 if !url.starts_with("https://") {
124 return Err(format!("refusing non-HTTPS URL: {}", url));
125 }
126
127 let mut req = ureq::get(url)
128 .header("User-Agent", "yosh-plugin-manager")
129 .header("Accept", "application/vnd.github.v3+json");
130 if let Some(token) = &self.token {
131 req = req.header("Authorization", format!("Bearer {}", token));
132 }
133 let mut response = req
134 .call()
135 .map_err(|e| format!("download request failed: {}", e))?;
136
137 let mut file = fs::File::create(dest)
138 .map_err(|e| format!("failed to create {}: {}", dest.display(), e))?;
139
140 let mut reader = response.body_mut().as_reader();
141 let mut buf = [0u8; 8192];
142 loop {
143 let n = reader
144 .read(&mut buf)
145 .map_err(|e| format!("failed to read response body: {}", e))?;
146 if n == 0 {
147 break;
148 }
149 file.write_all(&buf[..n])
150 .map_err(|e| format!("failed to write to {}: {}", dest.display(), e))?;
151 }
152
153 Ok(())
154 }
155
156 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
158 let url = format!("{}/repos/{}/{}/releases/latest", self.base_url, owner, repo);
159 let json = self.get_json(&url).map_err(|e| match e {
160 GitHubApiError::HttpStatus(404) => format!(
161 "no releases found for {}/{}: publish a GitHub Release first",
162 owner, repo
163 ),
164 other => format!(
165 "failed to fetch latest release for {}/{}: {}",
166 owner, repo, other
167 ),
168 })?;
169
170 let tag = json["tag_name"]
171 .as_str()
172 .ok_or_else(|| "release has no tag_name".to_string())?;
173
174 Ok(tag.trim_start_matches('v').to_string())
175 }
176}
177
178impl Default for GitHubClient {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184#[cfg(test)]
186pub struct GitHubClientWithBase {
187 inner: GitHubClient,
188}
189
190#[cfg(test)]
191impl GitHubClientWithBase {
192 pub fn new(base_url: &str) -> Self {
193 Self {
194 inner: GitHubClient {
195 base_url: base_url.to_string(),
196 token: None,
197 },
198 }
199 }
200
201 pub fn find_asset_url(
202 &self,
203 owner: &str,
204 repo: &str,
205 version: &str,
206 asset_name: &str,
207 ) -> Result<String, String> {
208 self.inner.find_asset_url(owner, repo, version, asset_name)
209 }
210
211 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
212 self.inner.latest_version(owner, repo)
213 }
214
215 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
216 self.inner.download(url, dest)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn make_release_json(assets: &[(&str, &str)]) -> String {
225 let assets_json: Vec<String> = assets
226 .iter()
227 .map(|(name, url)| {
228 format!(
229 r#"{{"name": "{}", "browser_download_url": "{}"}}"#,
230 name, url
231 )
232 })
233 .collect();
234 format!(
235 r#"{{"tag_name": "v1.2.3", "assets": [{}]}}"#,
236 assets_json.join(", ")
237 )
238 }
239
240 #[test]
241 fn parse_release_json_finds_asset() {
242 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
243 "libfoo-linux-x86_64.so",
244 "https://example.com/libfoo-linux-x86_64.so",
245 )]))
246 .unwrap();
247 let url = json["assets"][0]["browser_download_url"].as_str().unwrap();
248 assert_eq!(url, "https://example.com/libfoo-linux-x86_64.so");
249 }
250
251 #[test]
252 fn parse_release_json_asset_not_found() {
253 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
254 "other-asset.so",
255 "https://example.com/other.so",
256 )]))
257 .unwrap();
258 let assets = json["assets"].as_array().unwrap();
259 let found = assets
260 .iter()
261 .any(|a| a["name"].as_str() == Some("libfoo-linux-x86_64.so"));
262 assert!(!found);
263 }
264
265 #[test]
266 fn download_rejects_non_https() {
267 let client = GitHubClient::new();
268 let tmp = tempfile::NamedTempFile::new().unwrap();
269 let err = client
270 .download("http://example.com/file", tmp.path())
271 .unwrap_err();
272 assert!(
273 err.contains("non-HTTPS"),
274 "expected non-HTTPS error, got: {}",
275 err
276 );
277 }
278
279 #[test]
280 fn download_rejects_ftp_url() {
281 let client = GitHubClient::new();
282 let tmp = tempfile::NamedTempFile::new().unwrap();
283 let err = client
284 .download("ftp://example.com/file", tmp.path())
285 .unwrap_err();
286 assert!(
287 err.contains("non-HTTPS"),
288 "expected non-HTTPS error, got: {}",
289 err
290 );
291 }
292
293 #[test]
294 fn find_asset_url_v_prefix_fallback() {
295 let mut server = mockito::Server::new();
296 let base = server.url();
297
298 let _m1 = server
300 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
301 .with_status(404)
302 .with_body(r#"{"message": "Not Found"}"#)
303 .create();
304
305 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
307 let _m2 = server
308 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
309 .with_status(200)
310 .with_header("content-type", "application/json")
311 .with_body(&body)
312 .create();
313
314 let client = GitHubClientWithBase::new(&base);
315 let url = client
316 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
317 .unwrap();
318 assert_eq!(url, "https://dl.example.com/myasset.so");
319 }
320
321 #[test]
322 fn find_asset_url_v_prefix_succeeds() {
323 let mut server = mockito::Server::new();
324 let base = server.url();
325
326 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
327 let _m = server
328 .mock("GET", "/repos/owner/repo/releases/tags/v2.0.0")
329 .with_status(200)
330 .with_header("content-type", "application/json")
331 .with_body(&body)
332 .create();
333
334 let client = GitHubClientWithBase::new(&base);
335 let url = client
336 .find_asset_url("owner", "repo", "2.0.0", "myasset.so")
337 .unwrap();
338 assert_eq!(url, "https://dl.example.com/myasset.so");
339 }
340
341 #[test]
342 fn find_asset_url_asset_not_found() {
343 let mut server = mockito::Server::new();
344 let base = server.url();
345
346 let body = make_release_json(&[("other.so", "https://dl.example.com/other.so")]);
347 let _m = server
348 .mock("GET", "/repos/owner/repo/releases/tags/v3.0.0")
349 .with_status(200)
350 .with_header("content-type", "application/json")
351 .with_body(&body)
352 .create();
353
354 let client = GitHubClientWithBase::new(&base);
355 let err = client
356 .find_asset_url("owner", "repo", "3.0.0", "nonexistent.so")
357 .unwrap_err();
358 assert!(
359 err.contains("not found"),
360 "expected not found error, got: {}",
361 err
362 );
363 }
364
365 #[test]
366 fn latest_version_strips_v_prefix() {
367 let mut server = mockito::Server::new();
368 let base = server.url();
369
370 let _m = server
371 .mock("GET", "/repos/owner/repo/releases/latest")
372 .with_status(200)
373 .with_header("content-type", "application/json")
374 .with_body(r#"{"tag_name": "v4.5.6"}"#)
375 .create();
376
377 let client = GitHubClientWithBase::new(&base);
378 let version = client.latest_version("owner", "repo").unwrap();
379 assert_eq!(version, "4.5.6");
380 }
381
382 #[test]
383 fn latest_version_no_v_prefix() {
384 let mut server = mockito::Server::new();
385 let base = server.url();
386
387 let _m = server
388 .mock("GET", "/repos/owner/repo/releases/latest")
389 .with_status(200)
390 .with_header("content-type", "application/json")
391 .with_body(r#"{"tag_name": "1.0.0"}"#)
392 .create();
393
394 let client = GitHubClientWithBase::new(&base);
395 let version = client.latest_version("owner", "repo").unwrap();
396 assert_eq!(version, "1.0.0");
397 }
398
399 #[test]
400 fn latest_version_no_releases_gives_helpful_error() {
401 let mut server = mockito::Server::new();
402 let base = server.url();
403
404 let _m = server
405 .mock("GET", "/repos/owner/repo/releases/latest")
406 .with_status(404)
407 .with_body(r#"{"message": "Not Found"}"#)
408 .create();
409
410 let client = GitHubClientWithBase::new(&base);
411 let err = client.latest_version("owner", "repo").unwrap_err();
412 assert!(
413 err.contains("no releases found for owner/repo"),
414 "expected helpful error, got: {}",
415 err
416 );
417 assert!(
418 err.contains("publish a GitHub Release first"),
419 "expected hint about publishing a release, got: {}",
420 err
421 );
422 }
423
424 #[test]
425 fn find_asset_url_both_tags_404_gives_helpful_error() {
426 let mut server = mockito::Server::new();
427 let base = server.url();
428
429 let _m1 = server
430 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
431 .with_status(404)
432 .with_body(r#"{"message": "Not Found"}"#)
433 .create();
434
435 let _m2 = server
436 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
437 .with_status(404)
438 .with_body(r#"{"message": "Not Found"}"#)
439 .create();
440
441 let client = GitHubClientWithBase::new(&base);
442 let err = client
443 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
444 .unwrap_err();
445 assert!(
446 err.contains("release not found for owner/repo"),
447 "expected helpful error, got: {}",
448 err
449 );
450 assert!(
451 err.contains("v1.0.0"),
452 "expected tried tags in error, got: {}",
453 err
454 );
455 }
456}