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(&self, owner: &str, repo: &str, tag: &str) -> Result<serde_json::Value, GitHubApiError> {
65        let url = format!("{}/repos/{}/{}/releases/tags/{}", self.base_url, owner, repo, tag);
66        self.get_json(&url)
67    }
68
69    /// Look up a GitHub release by tag, trying `v{version}` first then `{version}`.
70    /// Returns the download URL of the named asset.
71    pub fn find_asset_url(
72        &self,
73        owner: &str,
74        repo: &str,
75        version: &str,
76        asset_name: &str,
77    ) -> Result<String, String> {
78        let v_tag = format!("v{}", version);
79        let release = match self.release_json(owner, repo, &v_tag) {
80            Ok(r) => r,
81            Err(_) => {
82                // Fallback to bare version tag
83                self.release_json(owner, repo, version).map_err(|e| match e {
84                    GitHubApiError::HttpStatus(404) => format!(
85                        "release not found for {}/{} (tried tags '{}' and '{}')",
86                        owner, repo, v_tag, version
87                    ),
88                    other => format!(
89                        "failed to fetch release for {}/{} (tried tags '{}' and '{}'): {}",
90                        owner, repo, v_tag, version, other
91                    ),
92                })?
93            }
94        };
95
96        let assets = release["assets"]
97            .as_array()
98            .ok_or_else(|| "release has no assets array".to_string())?;
99
100        for asset in assets {
101            if asset["name"].as_str() == Some(asset_name) {
102                let url = asset["browser_download_url"]
103                    .as_str()
104                    .ok_or_else(|| "asset has no browser_download_url".to_string())?;
105                return Ok(url.to_string());
106            }
107        }
108
109        Err(format!("asset '{}' not found in release", asset_name))
110    }
111
112    /// Download a file from an HTTPS URL to a local path. Rejects non-HTTPS URLs.
113    pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
114        if !url.starts_with("https://") {
115            return Err(format!("refusing non-HTTPS URL: {}", url));
116        }
117
118        let mut req = ureq::get(url)
119            .header("User-Agent", "yosh-plugin-manager")
120            .header("Accept", "application/vnd.github.v3+json");
121        if let Some(token) = &self.token {
122            req = req.header("Authorization", format!("Bearer {}", token));
123        }
124        let mut response = req
125            .call()
126            .map_err(|e| format!("download request failed: {}", e))?;
127
128        let mut file = fs::File::create(dest)
129            .map_err(|e| format!("failed to create {}: {}", dest.display(), e))?;
130
131        let mut reader = response.body_mut().as_reader();
132        let mut buf = [0u8; 8192];
133        loop {
134            let n = reader
135                .read(&mut buf)
136                .map_err(|e| format!("failed to read response body: {}", e))?;
137            if n == 0 {
138                break;
139            }
140            file.write_all(&buf[..n])
141                .map_err(|e| format!("failed to write to {}: {}", dest.display(), e))?;
142        }
143
144        Ok(())
145    }
146
147    /// Get the latest release tag for a repo, stripping a leading `v` prefix.
148    pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
149        let url = format!("{}/repos/{}/{}/releases/latest", self.base_url, owner, repo);
150        let json = self.get_json(&url).map_err(|e| match e {
151            GitHubApiError::HttpStatus(404) => format!(
152                "no releases found for {}/{}: publish a GitHub Release first",
153                owner, repo
154            ),
155            other => format!(
156                "failed to fetch latest release for {}/{}: {}",
157                owner, repo, other
158            ),
159        })?;
160
161        let tag = json["tag_name"]
162            .as_str()
163            .ok_or_else(|| "release has no tag_name".to_string())?;
164
165        Ok(tag.trim_start_matches('v').to_string())
166    }
167}
168
169impl Default for GitHubClient {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175/// Test-only client that uses a custom base URL (for mockito tests).
176#[cfg(test)]
177pub struct GitHubClientWithBase {
178    inner: GitHubClient,
179}
180
181#[cfg(test)]
182impl GitHubClientWithBase {
183    pub fn new(base_url: &str) -> Self {
184        Self {
185            inner: GitHubClient {
186                base_url: base_url.to_string(),
187                token: None,
188            },
189        }
190    }
191
192    pub fn find_asset_url(
193        &self,
194        owner: &str,
195        repo: &str,
196        version: &str,
197        asset_name: &str,
198    ) -> Result<String, String> {
199        self.inner.find_asset_url(owner, repo, version, asset_name)
200    }
201
202    pub fn latest_version(&self, owner: &str, repo: &str) -> Result<String, String> {
203        self.inner.latest_version(owner, repo)
204    }
205
206    pub fn download(&self, url: &str, dest: &Path) -> Result<(), String> {
207        self.inner.download(url, dest)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn make_release_json(assets: &[(&str, &str)]) -> String {
216        let assets_json: Vec<String> = assets
217            .iter()
218            .map(|(name, url)| {
219                format!(
220                    r#"{{"name": "{}", "browser_download_url": "{}"}}"#,
221                    name, url
222                )
223            })
224            .collect();
225        format!(r#"{{"tag_name": "v1.2.3", "assets": [{}]}}"#, assets_json.join(", "))
226    }
227
228    #[test]
229    fn parse_release_json_finds_asset() {
230        let json: serde_json::Value =
231            serde_json::from_str(&make_release_json(&[("libfoo-linux-x86_64.so", "https://example.com/libfoo-linux-x86_64.so")])).unwrap();
232        let url = json["assets"][0]["browser_download_url"].as_str().unwrap();
233        assert_eq!(url, "https://example.com/libfoo-linux-x86_64.so");
234    }
235
236    #[test]
237    fn parse_release_json_asset_not_found() {
238        let json: serde_json::Value =
239            serde_json::from_str(&make_release_json(&[("other-asset.so", "https://example.com/other.so")])).unwrap();
240        let assets = json["assets"].as_array().unwrap();
241        let found = assets.iter().any(|a| a["name"].as_str() == Some("libfoo-linux-x86_64.so"));
242        assert!(!found);
243    }
244
245    #[test]
246    fn download_rejects_non_https() {
247        let client = GitHubClient::new();
248        let tmp = tempfile::NamedTempFile::new().unwrap();
249        let err = client.download("http://example.com/file", tmp.path()).unwrap_err();
250        assert!(err.contains("non-HTTPS"), "expected non-HTTPS error, got: {}", err);
251    }
252
253    #[test]
254    fn download_rejects_ftp_url() {
255        let client = GitHubClient::new();
256        let tmp = tempfile::NamedTempFile::new().unwrap();
257        let err = client.download("ftp://example.com/file", tmp.path()).unwrap_err();
258        assert!(err.contains("non-HTTPS"), "expected non-HTTPS error, got: {}", err);
259    }
260
261    #[test]
262    fn find_asset_url_v_prefix_fallback() {
263        let mut server = mockito::Server::new();
264        let base = server.url();
265
266        // v-prefixed tag returns 404
267        let _m1 = server
268            .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
269            .with_status(404)
270            .with_body(r#"{"message": "Not Found"}"#)
271            .create();
272
273        // bare version tag succeeds
274        let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
275        let _m2 = server
276            .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
277            .with_status(200)
278            .with_header("content-type", "application/json")
279            .with_body(&body)
280            .create();
281
282        let client = GitHubClientWithBase::new(&base);
283        let url = client.find_asset_url("owner", "repo", "1.0.0", "myasset.so").unwrap();
284        assert_eq!(url, "https://dl.example.com/myasset.so");
285    }
286
287    #[test]
288    fn find_asset_url_v_prefix_succeeds() {
289        let mut server = mockito::Server::new();
290        let base = server.url();
291
292        let body = make_release_json(&[("myasset.so", "https://dl.example.com/myasset.so")]);
293        let _m = server
294            .mock("GET", "/repos/owner/repo/releases/tags/v2.0.0")
295            .with_status(200)
296            .with_header("content-type", "application/json")
297            .with_body(&body)
298            .create();
299
300        let client = GitHubClientWithBase::new(&base);
301        let url = client.find_asset_url("owner", "repo", "2.0.0", "myasset.so").unwrap();
302        assert_eq!(url, "https://dl.example.com/myasset.so");
303    }
304
305    #[test]
306    fn find_asset_url_asset_not_found() {
307        let mut server = mockito::Server::new();
308        let base = server.url();
309
310        let body = make_release_json(&[("other.so", "https://dl.example.com/other.so")]);
311        let _m = server
312            .mock("GET", "/repos/owner/repo/releases/tags/v3.0.0")
313            .with_status(200)
314            .with_header("content-type", "application/json")
315            .with_body(&body)
316            .create();
317
318        let client = GitHubClientWithBase::new(&base);
319        let err = client.find_asset_url("owner", "repo", "3.0.0", "nonexistent.so").unwrap_err();
320        assert!(err.contains("not found"), "expected not found error, got: {}", err);
321    }
322
323    #[test]
324    fn latest_version_strips_v_prefix() {
325        let mut server = mockito::Server::new();
326        let base = server.url();
327
328        let _m = server
329            .mock("GET", "/repos/owner/repo/releases/latest")
330            .with_status(200)
331            .with_header("content-type", "application/json")
332            .with_body(r#"{"tag_name": "v4.5.6"}"#)
333            .create();
334
335        let client = GitHubClientWithBase::new(&base);
336        let version = client.latest_version("owner", "repo").unwrap();
337        assert_eq!(version, "4.5.6");
338    }
339
340    #[test]
341    fn latest_version_no_v_prefix() {
342        let mut server = mockito::Server::new();
343        let base = server.url();
344
345        let _m = server
346            .mock("GET", "/repos/owner/repo/releases/latest")
347            .with_status(200)
348            .with_header("content-type", "application/json")
349            .with_body(r#"{"tag_name": "1.0.0"}"#)
350            .create();
351
352        let client = GitHubClientWithBase::new(&base);
353        let version = client.latest_version("owner", "repo").unwrap();
354        assert_eq!(version, "1.0.0");
355    }
356
357    #[test]
358    fn latest_version_no_releases_gives_helpful_error() {
359        let mut server = mockito::Server::new();
360        let base = server.url();
361
362        let _m = server
363            .mock("GET", "/repos/owner/repo/releases/latest")
364            .with_status(404)
365            .with_body(r#"{"message": "Not Found"}"#)
366            .create();
367
368        let client = GitHubClientWithBase::new(&base);
369        let err = client.latest_version("owner", "repo").unwrap_err();
370        assert!(
371            err.contains("no releases found for owner/repo"),
372            "expected helpful error, got: {}",
373            err
374        );
375        assert!(
376            err.contains("publish a GitHub Release first"),
377            "expected hint about publishing a release, got: {}",
378            err
379        );
380    }
381
382    #[test]
383    fn find_asset_url_both_tags_404_gives_helpful_error() {
384        let mut server = mockito::Server::new();
385        let base = server.url();
386
387        let _m1 = server
388            .mock("GET", "/repos/owner/repo/releases/tags/v1.0.0")
389            .with_status(404)
390            .with_body(r#"{"message": "Not Found"}"#)
391            .create();
392
393        let _m2 = server
394            .mock("GET", "/repos/owner/repo/releases/tags/1.0.0")
395            .with_status(404)
396            .with_body(r#"{"message": "Not Found"}"#)
397            .create();
398
399        let client = GitHubClientWithBase::new(&base);
400        let err = client
401            .find_asset_url("owner", "repo", "1.0.0", "myasset.so")
402            .unwrap_err();
403        assert!(
404            err.contains("release not found for owner/repo"),
405            "expected helpful error, got: {}",
406            err
407        );
408        assert!(
409            err.contains("v1.0.0"),
410            "expected tried tags in error, got: {}",
411            err
412        );
413    }
414}