Skip to main content

yosh_plugin_manager/
github.rs

1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5/// Error type for GitHub API requests made through `get_json`.
6#[derive(Debug)]
7enum GitHubApiError {
8    /// HTTP response with non-2xx status code (excluding 403/429).
9    HttpStatus(u16),
10    /// HTTP 403 or 429 — likely rate-limited, or auth-rejected when a
11    /// token is set. Surfaced separately so callers can attach a
12    /// "set YOSH_GITHUB_TOKEN" hint when no token is configured.
13    RateLimited(u16),
14    /// Network/transport error (DNS, connection, timeout).
15    Network(String),
16    /// Response body could not be read or parsed as JSON.
17    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
34/// GitHub API client for fetching release information and downloading assets.
35pub struct GitHubClient {
36    base_url: String,
37    token: Option<String>,
38}
39
40impl GitHubClient {
41    /// Create a new client, reading auth token from `YOSH_GITHUB_TOKEN`
42    /// (preferred) or `GITHUB_TOKEN`. The legacy `KISH_GITHUB_TOKEN` env
43    /// var was removed as part of the kish→yosh rename cleanup in
44    /// v0.2.0.
45    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    /// Look up a GitHub release by tag, trying `v{version}` first then `{version}`.
92    /// Returns the download URL of the named asset.
93    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                // Fallback to bare version tag
105                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    /// Download a file from an HTTPS URL to a local path. Rejects non-HTTPS URLs.
140    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    /// Get the latest release tag for a repo, stripping a leading `v` prefix.
175    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/// Test-only client that uses a custom base URL (for mockito tests).
207#[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    /// Unwrap to a plain `GitHubClient` so callers that need the
251    /// concrete type (e.g. `update::update`'s signature) can be driven
252    /// against a mockito server in tests.
253    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        // v-prefixed tag returns 404
337        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        // bare version tag succeeds
344        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        // Both v-prefix and bare-version tag attempts return 429.
546        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        // Both v-prefix and bare-version tag attempts return 429 (rate-limited).
579        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}