1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5#[derive(Debug)]
7enum GitHubApiError {
8 HttpStatus(u16),
10 RateLimited(u16),
14 Network(String),
16 Parse(String),
18}
19
20impl std::fmt::Display for GitHubApiError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::HttpStatus(code) => write!(f, "HTTP {}", code),
24 Self::RateLimited(code) => write!(f, "HTTP {} (rate-limited or unauthorized)", code),
25 Self::Network(msg) => write!(f, "request failed: {}", msg),
26 Self::Parse(msg) => write!(f, "{}", msg),
27 }
28 }
29}
30
31const RATE_LIMIT_HINT: &str =
32 "hint: set YOSH_GITHUB_TOKEN or GITHUB_TOKEN to raise the rate limit (60 -> 5000 req/hour)";
33
34pub struct GitHubClient {
36 base_url: String,
37 token: Option<String>,
38}
39
40impl GitHubClient {
41 pub fn new() -> Self {
46 let token = std::env::var("YOSH_GITHUB_TOKEN")
47 .ok()
48 .or_else(|| std::env::var("GITHUB_TOKEN").ok());
49 Self {
50 base_url: "https://api.github.com".to_string(),
51 token,
52 }
53 }
54
55 fn get_json(&self, url: &str) -> Result<serde_json::Value, GitHubApiError> {
56 let mut req = ureq::get(url)
57 .header("User-Agent", "yosh-plugin-manager")
58 .header("Accept", "application/vnd.github.v3+json");
59 if let Some(token) = &self.token {
60 req = req.header("Authorization", format!("Bearer {}", token));
61 }
62 let body = req
63 .call()
64 .map_err(|e| match &e {
65 ureq::Error::StatusCode(code) if *code == 403 || *code == 429 => {
66 GitHubApiError::RateLimited(*code)
67 }
68 ureq::Error::StatusCode(code) => GitHubApiError::HttpStatus(*code),
69 _ => GitHubApiError::Network(e.to_string()),
70 })?
71 .body_mut()
72 .read_to_string()
73 .map_err(|e| GitHubApiError::Parse(format!("failed to read body: {}", e)))?;
74 serde_json::from_str(&body)
75 .map_err(|e| GitHubApiError::Parse(format!("failed to parse JSON: {}", e)))
76 }
77
78 fn release_json(
79 &self,
80 owner: &str,
81 repo: &str,
82 tag: &str,
83 ) -> Result<serde_json::Value, GitHubApiError> {
84 let url = format!(
85 "{}/repos/{}/{}/releases/tags/{}",
86 self.base_url, owner, repo, tag
87 );
88 self.get_json(&url)
89 }
90
91 pub fn find_asset_url(
94 &self,
95 owner: &str,
96 repo: &str,
97 version: &str,
98 asset_name: &str,
99 ) -> Result<String, String> {
100 let v_tag = format!("v{}", version);
101 let release = match self.release_json(owner, repo, &v_tag) {
102 Ok(r) => r,
103 Err(_) => {
104 self.release_json(owner, repo, version)
106 .map_err(|e| match e {
107 GitHubApiError::HttpStatus(404) => format!(
108 "release not found for {}/{} (tried tags '{}' and '{}')",
109 owner, repo, v_tag, version
110 ),
111 other @ GitHubApiError::RateLimited(_) if self.token.is_none() => format!(
112 "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}\n {}",
113 owner, repo, v_tag, version, other, RATE_LIMIT_HINT
114 ),
115 other => format!(
116 "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}",
117 owner, repo, v_tag, version, other
118 ),
119 })?
120 }
121 };
122
123 let assets = release["assets"]
124 .as_array()
125 .ok_or_else(|| "release has no assets array".to_string())?;
126
127 for asset in assets {
128 if asset["name"].as_str() == Some(asset_name) {
129 let url = asset["browser_download_url"]
130 .as_str()
131 .ok_or_else(|| "asset has no browser_download_url".to_string())?;
132 return Ok(url.to_string());
133 }
134 }
135
136 Err(format!("asset '{}' not found in release", asset_name))
137 }
138
139 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
141 if !url.starts_with("https://") {
142 return Err(format!("refusing non-HTTPS URL: {}", url));
143 }
144
145 let mut req = ureq::get(url)
146 .header("User-Agent", "yosh-plugin-manager")
147 .header("Accept", "application/vnd.github.v3+json");
148 if let Some(token) = &self.token {
149 req = req.header("Authorization", format!("Bearer {}", token));
150 }
151 let mut response = req
152 .call()
153 .map_err(|e| format!("download request failed: {}", e))?;
154
155 let mut file = fs::File::create(dest)
156 .map_err(|e| format!("failed to create {}: {}", dest.display(), e))?;
157
158 let mut reader = response.body_mut().as_reader();
159 let mut buf = [0u8; 8192];
160 loop {
161 let n = reader
162 .read(&mut buf)
163 .map_err(|e| format!("failed to read response body: {}", e))?;
164 if n == 0 {
165 break;
166 }
167 file.write_all(&buf[..n])
168 .map_err(|e| format!("failed to write to {}: {}", dest.display(), e))?;
169 }
170
171 Ok(())
172 }
173
174 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
176 let url = format!("{}/repos/{}/{}/releases/latest", self.base_url, owner, repo);
177 let json = self.get_json(&url).map_err(|e| match e {
178 GitHubApiError::HttpStatus(404) => format!(
179 "no releases found for {}/{}: publish a GitHub Release first",
180 owner, repo
181 ),
182 other @ GitHubApiError::RateLimited(_) if self.token.is_none() => format!(
183 "failed to fetch latest release for {}/{}: {}\n {}",
184 owner, repo, other, RATE_LIMIT_HINT
185 ),
186 other => format!(
187 "failed to fetch latest release for {}/{}: {}",
188 owner, repo, other
189 ),
190 })?;
191
192 let tag = json["tag_name"]
193 .as_str()
194 .ok_or_else(|| "release has no tag_name".to_string())?;
195
196 Ok(tag.trim_start_matches('v').to_string())
197 }
198}
199
200impl Default for GitHubClient {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206#[cfg(test)]
208pub struct GitHubClientWithBase {
209 inner: GitHubClient,
210}
211
212#[cfg(test)]
213impl GitHubClientWithBase {
214 pub fn new(base_url: &str) -> Self {
215 Self {
216 inner: GitHubClient {
217 base_url: base_url.to_string(),
218 token: None,
219 },
220 }
221 }
222
223 pub fn with_token(base_url: &str, token: &str) -> Self {
224 Self {
225 inner: GitHubClient {
226 base_url: base_url.to_string(),
227 token: Some(token.to_string()),
228 },
229 }
230 }
231
232 pub fn find_asset_url(
233 &self,
234 owner: &str,
235 repo: &str,
236 version: &str,
237 asset_name: &str,
238 ) -> Result<String, String> {
239 self.inner.find_asset_url(owner, repo, version, asset_name)
240 }
241
242 pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
243 self.inner.latest_version(owner, repo)
244 }
245
246 pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
247 self.inner.download(url, dest)
248 }
249
250 pub fn into_client(self) -> GitHubClient {
254 self.inner
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn make_release_json(assets: &[(&str, &str)]) -> String {
263 let assets_json: Vec<String> = assets
264 .iter()
265 .map(|(name, url)| {
266 format!(
267 r#"{{"name": "{}", "browser_download_url": "{}"}}"#,
268 name, url
269 )
270 })
271 .collect();
272 format!(
273 r#"{{"tag_name": "v1.2.3", "assets": [{}]}}"#,
274 assets_json.join(", ")
275 )
276 }
277
278 #[test]
279 fn parse_release_json_finds_asset() {
280 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
281 "libfoo-linux-x86_64.so",
282 "https://example.com/libfoo-linux-x86_64.so",
283 )]))
284 .unwrap();
285 let url = json["assets"][0]["browser_download_url"].as_str().unwrap();
286 assert_eq!(url, "https://example.com/libfoo-linux-x86_64.so");
287 }
288
289 #[test]
290 fn parse_release_json_asset_not_found() {
291 let json: serde_json::Value = serde_json::from_str(&make_release_json(&[(
292 "other-asset.so",
293 "https://example.com/other.so",
294 )]))
295 .unwrap();
296 let assets = json["assets"].as_array().unwrap();
297 let found = assets
298 .iter()
299 .any(|a| a["name"].as_str() == Some("libfoo-linux-x86_64.so"));
300 assert!(!found);
301 }
302
303 #[test]
304 fn download_rejects_non_https() {
305 let client = GitHubClient::new();
306 let tmp = tempfile::NamedTempFile::new().unwrap();
307 let err = client
308 .download("http://example.com/file", tmp.path())
309 .unwrap_err();
310 assert!(
311 err.contains("non-HTTPS"),
312 "expected non-HTTPS error, got: {}",
313 err
314 );
315 }
316
317 #[test]
318 fn download_rejects_ftp_url() {
319 let client = GitHubClient::new();
320 let tmp = tempfile::NamedTempFile::new().unwrap();
321 let err = client
322 .download("ftp://example.com/file", tmp.path())
323 .unwrap_err();
324 assert!(
325 err.contains("non-HTTPS"),
326 "expected non-HTTPS error, got: {}",
327 err
328 );
329 }
330
331 #[test]
332 fn find_asset_url_v_prefix_fallback() {
333 let mut server = mockito::Server::new();
334 let base = server.url();
335
336 let _m1 = server
338 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
339 .with_status(404)
340 .with_body(r#"{"message": "Not Found"}"#)
341 .create();
342
343 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
345 let _m2 = server
346 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
347 .with_status(200)
348 .with_header("content-type", "application/json")
349 .with_body(&body)
350 .create();
351
352 let client = GitHubClientWithBase::new(&base);
353 let url = client
354 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
355 .unwrap();
356 assert_eq!(url, "https://dl.example.com/myasset.so");
357 }
358
359 #[test]
360 fn find_asset_url_v_prefix_succeeds() {
361 let mut server = mockito::Server::new();
362 let base = server.url();
363
364 let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
365 let _m = server
366 .mock("GET", "/repos/owner/repo/releases/tags/v2.0.0")
367 .with_status(200)
368 .with_header("content-type", "application/json")
369 .with_body(&body)
370 .create();
371
372 let client = GitHubClientWithBase::new(&base);
373 let url = client
374 .find_asset_url("owner", "repo", "2.0.0", "myasset.so")
375 .unwrap();
376 assert_eq!(url, "https://dl.example.com/myasset.so");
377 }
378
379 #[test]
380 fn find_asset_url_asset_not_found() {
381 let mut server = mockito::Server::new();
382 let base = server.url();
383
384 let body = make_release_json(&[("other.so", "https://dl.example.com/other.so")]);
385 let _m = server
386 .mock("GET", "/repos/owner/repo/releases/tags/v3.0.0")
387 .with_status(200)
388 .with_header("content-type", "application/json")
389 .with_body(&body)
390 .create();
391
392 let client = GitHubClientWithBase::new(&base);
393 let err = client
394 .find_asset_url("owner", "repo", "3.0.0", "nonexistent.so")
395 .unwrap_err();
396 assert!(
397 err.contains("not found"),
398 "expected not found error, got: {}",
399 err
400 );
401 }
402
403 #[test]
404 fn latest_version_strips_v_prefix() {
405 let mut server = mockito::Server::new();
406 let base = server.url();
407
408 let _m = server
409 .mock("GET", "/repos/owner/repo/releases/latest")
410 .with_status(200)
411 .with_header("content-type", "application/json")
412 .with_body(r#"{"tag_name": "v4.5.6"}"#)
413 .create();
414
415 let client = GitHubClientWithBase::new(&base);
416 let version = client.latest_version("owner", "repo").unwrap();
417 assert_eq!(version, "4.5.6");
418 }
419
420 #[test]
421 fn latest_version_no_v_prefix() {
422 let mut server = mockito::Server::new();
423 let base = server.url();
424
425 let _m = server
426 .mock("GET", "/repos/owner/repo/releases/latest")
427 .with_status(200)
428 .with_header("content-type", "application/json")
429 .with_body(r#"{"tag_name": "1.0.0"}"#)
430 .create();
431
432 let client = GitHubClientWithBase::new(&base);
433 let version = client.latest_version("owner", "repo").unwrap();
434 assert_eq!(version, "1.0.0");
435 }
436
437 #[test]
438 fn latest_version_no_releases_gives_helpful_error() {
439 let mut server = mockito::Server::new();
440 let base = server.url();
441
442 let _m = server
443 .mock("GET", "/repos/owner/repo/releases/latest")
444 .with_status(404)
445 .with_body(r#"{"message": "Not Found"}"#)
446 .create();
447
448 let client = GitHubClientWithBase::new(&base);
449 let err = client.latest_version("owner", "repo").unwrap_err();
450 assert!(
451 err.contains("no releases found for owner/repo"),
452 "expected helpful error, got: {}",
453 err
454 );
455 assert!(
456 err.contains("publish a GitHub Release first"),
457 "expected hint about publishing a release, got: {}",
458 err
459 );
460 }
461
462 #[test]
463 fn find_asset_url_both_tags_404_gives_helpful_error() {
464 let mut server = mockito::Server::new();
465 let base = server.url();
466
467 let _m1 = server
468 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
469 .with_status(404)
470 .with_body(r#"{"message": "Not Found"}"#)
471 .create();
472
473 let _m2 = server
474 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
475 .with_status(404)
476 .with_body(r#"{"message": "Not Found"}"#)
477 .create();
478
479 let client = GitHubClientWithBase::new(&base);
480 let err = client
481 .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
482 .unwrap_err();
483 assert!(
484 err.contains("release not found for owner/repo"),
485 "expected helpful error, got: {}",
486 err
487 );
488 assert!(
489 err.contains("v1.0.0"),
490 "expected tried tags in error, got: {}",
491 err
492 );
493 }
494
495 #[test]
496 fn latest_version_403_without_token_includes_hint() {
497 let mut server = mockito::Server::new();
498 let base = server.url();
499
500 let _m = server
501 .mock("GET", "/repos/owner/repo/releases/latest")
502 .with_status(403)
503 .with_body(r#"{"message": "API rate limit exceeded"}"#)
504 .create();
505
506 let client = GitHubClientWithBase::new(&base);
507 let err = client.latest_version("owner", "repo").unwrap_err();
508 assert!(
509 err.contains("YOSH_GITHUB_TOKEN"),
510 "expected hint mentioning YOSH_GITHUB_TOKEN, got: {}",
511 err
512 );
513 }
514
515 #[test]
516 fn latest_version_403_with_token_no_hint() {
517 let mut server = mockito::Server::new();
518 let base = server.url();
519
520 let _m = server
521 .mock("GET", "/repos/owner/repo/releases/latest")
522 .with_status(403)
523 .with_body(r#"{"message": "Bad credentials"}"#)
524 .create();
525
526 let client = GitHubClientWithBase::with_token(&base, "fake-token");
527 let err = client.latest_version("owner", "repo").unwrap_err();
528 assert!(
529 !err.contains("YOSH_GITHUB_TOKEN"),
530 "should not suggest setting a token when one is already set, got: {}",
531 err
532 );
533 assert!(
534 err.contains("HTTP 403"),
535 "should still surface the HTTP status, got: {}",
536 err
537 );
538 }
539
540 #[test]
541 fn find_asset_url_429_with_token_no_hint() {
542 let mut server = mockito::Server::new();
543 let base = server.url();
544
545 let _m1 = server
547 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
548 .with_status(429)
549 .with_body(r#"{"message": "Too many requests"}"#)
550 .create();
551 let _m2 = server
552 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
553 .with_status(429)
554 .with_body(r#"{"message": "Too many requests"}"#)
555 .create();
556
557 let client = GitHubClientWithBase::with_token(&base, "fake-token");
558 let err = client
559 .find_asset_url("owner", "repo", "1.0.0", "asset.wasm")
560 .unwrap_err();
561 assert!(
562 !err.contains("YOSH_GITHUB_TOKEN"),
563 "should not suggest setting a token when one is already set, got: {}",
564 err
565 );
566 assert!(
567 err.contains("HTTP 429"),
568 "should still surface the HTTP status, got: {}",
569 err
570 );
571 }
572
573 #[test]
574 fn find_asset_url_429_without_token_includes_hint() {
575 let mut server = mockito::Server::new();
576 let base = server.url();
577
578 let _m1 = server
580 .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
581 .with_status(429)
582 .with_body(r#"{"message": "Too many requests"}"#)
583 .create();
584 let _m2 = server
585 .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
586 .with_status(429)
587 .with_body(r#"{"message": "Too many requests"}"#)
588 .create();
589
590 let client = GitHubClientWithBase::new(&base);
591 let err = client
592 .find_asset_url("owner", "repo", "1.0.0", "asset.wasm")
593 .unwrap_err();
594 assert!(
595 err.contains("YOSH_GITHUB_TOKEN"),
596 "expected rate-limit hint, got: {}",
597 err
598 );
599 }
600}