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.
9    HttpStatus(u16),
10    /// Network/transport error (DNS, connection, timeout).
11    Network(String),
12    /// Response body could not be read or parsed as JSON.
13    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
26/// GitHub API client for fetching release information and downloading assets.
27pub struct GitHubClient {
28    base_url: String,
29    token: Option<String>,
30}
31
32impl GitHubClient {
33    /// Create a new client, reading auth token from `KISH_GITHUB_TOKEN` or `GITHUB_TOKEN`.
34    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    /// Look up a GitHub release by tag, trying `v{version}` first then `{version}`.
78    /// Returns the download URL of the named asset.
79    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                // Fallback to bare version tag
91                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    /// Download a file from an HTTPS URL to a local path. Rejects non-HTTPS URLs.
122    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    /// Get the latest release tag for a repo, stripping a leading `v` prefix.
157    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/// Test-only client that uses a custom base URL (for mockito tests).
185#[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        // v-prefixed tag returns 404
299        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        // bare version tag succeeds
306        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}