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(&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 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 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 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 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#[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 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 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}