Skip to main content

distronomicon/
github.rs

1use anyhow::Result;
2use jiff::Timestamp;
3use regex::Regex;
4use reqwest::{
5    StatusCode,
6    header::{ACCEPT, AUTHORIZATION, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED},
7};
8use serde::Deserialize;
9
10use crate::{DEFAULT_GITHUB_HOST, DEFAULT_TIMEOUT};
11
12#[derive(Debug, Clone, Deserialize)]
13pub struct Release {
14    pub tag_name: String,
15    pub assets: Vec<Asset>,
16    pub prerelease: bool,
17    #[serde(default)]
18    pub draft: bool,
19    #[serde(default)]
20    pub created_at: Option<Timestamp>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct Asset {
25    pub name: String,
26    pub url: String,
27    pub browser_download_url: String,
28    pub size: u64,
29}
30
31#[derive(Debug, Clone, Default)]
32pub struct Validators {
33    pub etag: Option<String>,
34    pub last_modified: Option<String>,
35}
36
37#[derive(Debug)]
38pub struct ValidatorsOut {
39    pub etag: Option<String>,
40    pub last_modified: Option<String>,
41}
42
43#[derive(Debug)]
44pub struct FetchResult {
45    pub release: Option<Release>,
46    pub validators: ValidatorsOut,
47    pub was_modified: bool,
48}
49
50/// Fetches the latest release from GitHub.
51///
52/// Uses conditional requests via `ETag` and `Last-Modified` headers when validators
53/// are provided. Returns an optional release (None on 304), updated validators, and
54/// whether content changed.
55///
56/// # Errors
57///
58/// Returns an error if:
59/// - Network request fails
60/// - Response cannot be parsed as JSON
61/// - No releases are found when `allow_prerelease` is true
62#[bon::builder(derive(IntoFuture(Box)))]
63pub async fn fetch_latest(
64    repo: &str,
65    token: Option<&str>,
66    #[builder(default = crate::build_http_client(DEFAULT_TIMEOUT).unwrap())]
67    client: reqwest::Client,
68    #[builder(default = DEFAULT_GITHUB_HOST)] host: &str,
69    #[builder(default = false)] allow_prerelease: bool,
70    #[builder(default)] validators: Validators,
71) -> Result<FetchResult> {
72    let url = if allow_prerelease {
73        format!("{host}/repos/{repo}/releases")
74    } else {
75        format!("{host}/repos/{repo}/releases/latest")
76    };
77
78    let mut request = client
79        .get(&url)
80        .header(ACCEPT, "application/vnd.github+json");
81
82    if let Some(token) = token {
83        request = request.header(AUTHORIZATION, format!("Bearer {token}"));
84    }
85
86    if let Some(etag) = &validators.etag {
87        request = request.header(IF_NONE_MATCH, etag);
88    }
89    if let Some(last_modified) = &validators.last_modified {
90        request = request.header(IF_MODIFIED_SINCE, last_modified);
91    }
92
93    let response = request.send().await?;
94    let status = response.status();
95    let headers = response.headers();
96    let validators_out = ValidatorsOut {
97        etag: headers
98            .get(ETAG)
99            .and_then(|h| h.to_str().ok())
100            .map(String::from),
101        last_modified: headers
102            .get(LAST_MODIFIED)
103            .and_then(|h| h.to_str().ok())
104            .map(String::from),
105    };
106
107    if status == StatusCode::NOT_MODIFIED {
108        return Ok(FetchResult {
109            release: None,
110            validators: validators_out,
111            was_modified: false,
112        });
113    }
114
115    let response = response.error_for_status()?;
116
117    let release = if allow_prerelease {
118        let mut releases = response.json::<Vec<Release>>().await?;
119        releases.retain(|r| !r.draft);
120        releases.sort_by(|a, b| b.created_at.cmp(&a.created_at));
121        releases
122            .into_iter()
123            .next()
124            .ok_or_else(|| anyhow::anyhow!("No releases found"))?
125    } else {
126        response.json::<Release>().await?
127    };
128
129    Ok(FetchResult {
130        release: Some(release),
131        validators: validators_out,
132        was_modified: true,
133    })
134}
135
136#[must_use]
137pub fn select_asset<'a>(assets: &'a [Asset], pattern: &Regex) -> Option<&'a Asset> {
138    assets.iter().find(|asset| pattern.is_match(&asset.name))
139}
140
141#[cfg(test)]
142mod tests {
143    use wiremock::{
144        Mock, MockServer, ResponseTemplate,
145        matchers::{header, header_exists, method, path},
146    };
147
148    use super::*;
149
150    #[tokio::test]
151    async fn test_fetch_latest_returns_release_with_etag() {
152        let mock_server = MockServer::start().await;
153
154        let release_json = serde_json::json!({
155            "tag_name": "v0.1.3",
156            "prerelease": false,
157            "assets": [
158                {
159                    "name": "app-linux-amd64.tar.gz",
160                    "url": "https://api.github.com/repos/owner/repo/releases/assets/1",
161                    "browser_download_url": "https://github.com/owner/repo/releases/download/v0.1.3/app-linux-amd64.tar.gz",
162                    "size": 1024
163                }
164            ]
165        });
166
167        Mock::given(method("GET"))
168            .and(path("/repos/owner/repo/releases/latest"))
169            .respond_with(
170                ResponseTemplate::new(200)
171                    .set_body_json(&release_json)
172                    .insert_header("etag", "\"abc123\"")
173                    .insert_header("last-modified", "Mon, 27 Oct 2025 12:00:00 GMT"),
174            )
175            .mount(&mock_server)
176            .await;
177
178        let result = fetch_latest()
179            .repo("owner/repo")
180            .host(&mock_server.uri())
181            .await;
182
183        assert!(result.is_ok());
184        let fetch_result = result.unwrap();
185
186        assert!(fetch_result.release.is_some());
187        let release = fetch_result.release.unwrap();
188        assert_eq!(release.tag_name, "v0.1.3");
189        assert!(!release.prerelease);
190        assert_eq!(release.assets.len(), 1);
191        assert_eq!(release.assets[0].name, "app-linux-amd64.tar.gz");
192        assert_eq!(release.assets[0].size, 1024);
193
194        assert_eq!(fetch_result.validators.etag, Some("\"abc123\"".to_string()));
195        assert_eq!(
196            fetch_result.validators.last_modified,
197            Some("Mon, 27 Oct 2025 12:00:00 GMT".to_string())
198        );
199        assert!(fetch_result.was_modified);
200    }
201
202    #[tokio::test]
203    async fn test_fetch_latest_returns_not_modified_on_304() {
204        let mock_server = MockServer::start().await;
205
206        Mock::given(method("GET"))
207            .and(path("/repos/owner/repo/releases/latest"))
208            .respond_with(
209                ResponseTemplate::new(304)
210                    .insert_header("etag", "\"abc123\"")
211                    .insert_header("last-modified", "Mon, 27 Oct 2025 12:00:00 GMT"),
212            )
213            .mount(&mock_server)
214            .await;
215
216        let validators = Validators {
217            etag: Some("\"abc123\"".to_string()),
218            last_modified: Some("Mon, 27 Oct 2025 12:00:00 GMT".to_string()),
219        };
220
221        let result = fetch_latest()
222            .repo("owner/repo")
223            .host(&mock_server.uri())
224            .validators(validators)
225            .await;
226
227        assert!(result.is_ok());
228        let fetch_result = result.unwrap();
229
230        assert!(fetch_result.release.is_none());
231        assert_eq!(fetch_result.validators.etag, Some("\"abc123\"".to_string()));
232        assert_eq!(
233            fetch_result.validators.last_modified,
234            Some("Mon, 27 Oct 2025 12:00:00 GMT".to_string())
235        );
236        assert!(!fetch_result.was_modified);
237    }
238
239    #[tokio::test]
240    async fn test_fetch_latest_sends_validators_in_request() {
241        let mock_server = MockServer::start().await;
242
243        let release_json = serde_json::json!({
244            "tag_name": "v0.1.0",
245            "prerelease": false,
246            "assets": []
247        });
248
249        Mock::given(method("GET"))
250            .and(path("/repos/owner/repo/releases/latest"))
251            .and(header_exists("if-none-match"))
252            .and(header_exists("if-modified-since"))
253            .respond_with(ResponseTemplate::new(200).set_body_json(&release_json))
254            .expect(1)
255            .mount(&mock_server)
256            .await;
257
258        let validators = Validators {
259            etag: Some("\"etag-value\"".to_string()),
260            last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
261        };
262
263        let result = fetch_latest()
264            .repo("owner/repo")
265            .host(&mock_server.uri())
266            .validators(validators)
267            .await;
268
269        assert!(result.is_ok());
270    }
271
272    #[tokio::test]
273    async fn test_fetch_latest_selects_prerelease_when_newer() {
274        let mock_server = MockServer::start().await;
275
276        let releases_json = serde_json::json!([
277            {
278                "tag_name": "v0.2.0-beta.1",
279                "prerelease": true,
280                "created_at": "2025-10-27T12:00:00Z",
281                "assets": [
282                    {
283                        "name": "app-beta.tar.gz",
284                        "url": "https://api.github.com/repos/owner/repo/releases/assets/2",
285                        "browser_download_url": "https://github.com/owner/repo/releases/download/v0.2.0-beta.1/app-beta.tar.gz",
286                        "size": 2048
287                    }
288                ]
289            },
290            {
291                "tag_name": "v0.1.5",
292                "prerelease": false,
293                "created_at": "2025-10-20T12:00:00Z",
294                "assets": [
295                    {
296                        "name": "app-stable.tar.gz",
297                        "url": "https://api.github.com/repos/owner/repo/releases/assets/3",
298                        "browser_download_url": "https://github.com/owner/repo/releases/download/v0.1.5/app-stable.tar.gz",
299                        "size": 1536
300                    }
301                ]
302            }
303        ]);
304
305        Mock::given(method("GET"))
306            .and(path("/repos/owner/repo/releases"))
307            .respond_with(
308                ResponseTemplate::new(200)
309                    .set_body_json(&releases_json)
310                    .insert_header("etag", "\"xyz789\""),
311            )
312            .mount(&mock_server)
313            .await;
314
315        let result = fetch_latest()
316            .repo("owner/repo")
317            .host(&mock_server.uri())
318            .allow_prerelease(true)
319            .await;
320
321        assert!(result.is_ok());
322        let fetch_result = result.unwrap();
323
324        assert!(fetch_result.release.is_some());
325        let release = fetch_result.release.unwrap();
326        assert_eq!(release.tag_name, "v0.2.0-beta.1");
327        assert!(release.prerelease);
328        assert_eq!(release.assets[0].name, "app-beta.tar.gz");
329
330        assert_eq!(fetch_result.validators.etag, Some("\"xyz789\"".to_string()));
331        assert!(fetch_result.was_modified);
332    }
333
334    #[tokio::test]
335    async fn test_fetch_latest_includes_bearer_token_when_provided() {
336        let mock_server = MockServer::start().await;
337
338        let release_json = serde_json::json!({
339            "tag_name": "v0.1.0",
340            "prerelease": false,
341            "assets": []
342        });
343
344        Mock::given(method("GET"))
345            .and(path("/repos/owner/repo/releases/latest"))
346            .and(header("Authorization", "Bearer secret-token"))
347            .respond_with(ResponseTemplate::new(200).set_body_json(&release_json))
348            .expect(1)
349            .mount(&mock_server)
350            .await;
351
352        let result = fetch_latest()
353            .repo("owner/repo")
354            .token("secret-token")
355            .host(&mock_server.uri())
356            .await;
357
358        assert!(result.is_ok());
359        let fetch_result = result.unwrap();
360        assert!(fetch_result.release.is_some());
361        assert_eq!(fetch_result.release.unwrap().tag_name, "v0.1.0");
362    }
363
364    #[tokio::test]
365    async fn test_fetch_latest_no_auth_header_when_token_absent() {
366        let mock_server = MockServer::start().await;
367
368        let release_json = serde_json::json!({
369            "tag_name": "v0.1.0",
370            "prerelease": false,
371            "assets": []
372        });
373
374        Mock::given(method("GET"))
375            .and(path("/repos/owner/repo/releases/latest"))
376            .respond_with(ResponseTemplate::new(200).set_body_json(&release_json))
377            .mount(&mock_server)
378            .await;
379
380        let result = fetch_latest()
381            .repo("owner/repo")
382            .host(&mock_server.uri())
383            .await;
384
385        assert!(result.is_ok());
386    }
387
388    #[tokio::test]
389    async fn test_fetch_latest_skips_draft_releases() {
390        let mock_server = MockServer::start().await;
391
392        let releases_json = serde_json::json!([
393            {
394                "tag_name": "v0.3.0",
395                "prerelease": false,
396                "draft": true,
397                "created_at": "2025-10-28T12:00:00Z",
398                "assets": [
399                    {
400                        "name": "app-draft.tar.gz",
401                        "url": "https://api.github.com/repos/owner/repo/releases/assets/4",
402                        "browser_download_url": "https://github.com/owner/repo/releases/download/v0.3.0/app-draft.tar.gz",
403                        "size": 3072
404                    }
405                ]
406            },
407            {
408                "tag_name": "v0.2.0",
409                "prerelease": false,
410                "draft": false,
411                "created_at": "2025-10-27T12:00:00Z",
412                "assets": [
413                    {
414                        "name": "app-stable.tar.gz",
415                        "url": "https://api.github.com/repos/owner/repo/releases/assets/5",
416                        "browser_download_url": "https://github.com/owner/repo/releases/download/v0.2.0/app-stable.tar.gz",
417                        "size": 2048
418                    }
419                ]
420            }
421        ]);
422
423        Mock::given(method("GET"))
424            .and(path("/repos/owner/repo/releases"))
425            .respond_with(
426                ResponseTemplate::new(200)
427                    .set_body_json(&releases_json)
428                    .insert_header("etag", "\"draft789\""),
429            )
430            .mount(&mock_server)
431            .await;
432
433        let result = fetch_latest()
434            .repo("owner/repo")
435            .host(&mock_server.uri())
436            .allow_prerelease(true)
437            .await;
438
439        assert!(result.is_ok());
440        let fetch_result = result.unwrap();
441
442        assert!(fetch_result.release.is_some());
443        let release = fetch_result.release.unwrap();
444        assert_eq!(release.tag_name, "v0.2.0");
445        assert!(!release.draft);
446        assert_eq!(release.assets[0].name, "app-stable.tar.gz");
447    }
448
449    #[tokio::test]
450    async fn test_fetch_latest_returns_error_for_404() {
451        let mock_server = MockServer::start().await;
452
453        Mock::given(method("GET"))
454            .and(path("/repos/owner/repo/releases/latest"))
455            .respond_with(ResponseTemplate::new(404))
456            .mount(&mock_server)
457            .await;
458
459        let result = fetch_latest()
460            .repo("owner/repo")
461            .host(&mock_server.uri())
462            .await;
463
464        assert!(result.is_err());
465        let err = result.unwrap_err();
466        assert!(err.to_string().contains("404"));
467    }
468
469    #[tokio::test]
470    async fn test_fetch_latest_returns_error_for_403() {
471        let mock_server = MockServer::start().await;
472
473        Mock::given(method("GET"))
474            .and(path("/repos/owner/repo/releases/latest"))
475            .respond_with(ResponseTemplate::new(403))
476            .mount(&mock_server)
477            .await;
478
479        let result = fetch_latest()
480            .repo("owner/repo")
481            .host(&mock_server.uri())
482            .await;
483
484        assert!(result.is_err());
485        let err = result.unwrap_err();
486        assert!(err.to_string().contains("403"));
487    }
488
489    #[test]
490    fn test_select_asset_returns_first_match() {
491        let assets = vec![
492            Asset {
493                name: "app-linux-amd64.tar.gz".to_string(),
494                url: "https://api.github.com/repos/owner/repo/releases/assets/1".to_string(),
495                browser_download_url: "https://example.com/app-linux-amd64.tar.gz".to_string(),
496                size: 1024,
497            },
498            Asset {
499                name: "app-darwin-amd64.tar.gz".to_string(),
500                url: "https://api.github.com/repos/owner/repo/releases/assets/2".to_string(),
501                browser_download_url: "https://example.com/app-darwin-amd64.tar.gz".to_string(),
502                size: 2048,
503            },
504            Asset {
505                name: "app-linux-arm64.tar.gz".to_string(),
506                url: "https://api.github.com/repos/owner/repo/releases/assets/3".to_string(),
507                browser_download_url: "https://example.com/app-linux-arm64.tar.gz".to_string(),
508                size: 3072,
509            },
510        ];
511
512        let pattern = Regex::new(r"app-linux-.*\.tar\.gz").unwrap();
513        let result = select_asset(&assets, &pattern);
514
515        assert!(result.is_some());
516        assert_eq!(result.unwrap().name, "app-linux-amd64.tar.gz");
517    }
518
519    #[test]
520    fn test_select_asset_returns_none_when_no_match() {
521        let assets = vec![
522            Asset {
523                name: "app-darwin-amd64.tar.gz".to_string(),
524                url: "https://api.github.com/repos/owner/repo/releases/assets/1".to_string(),
525                browser_download_url: "https://example.com/app-darwin-amd64.tar.gz".to_string(),
526                size: 1024,
527            },
528            Asset {
529                name: "app-windows-amd64.zip".to_string(),
530                url: "https://api.github.com/repos/owner/repo/releases/assets/2".to_string(),
531                browser_download_url: "https://example.com/app-windows-amd64.zip".to_string(),
532                size: 2048,
533            },
534        ];
535
536        let pattern = Regex::new(r"app-linux-.*\.tar\.gz").unwrap();
537        let result = select_asset(&assets, &pattern);
538
539        assert!(result.is_none());
540    }
541
542    #[test]
543    fn test_select_asset_returns_first_when_multiple_matches() {
544        let assets = vec![
545            Asset {
546                name: "checksums.txt".to_string(),
547                url: "https://api.github.com/repos/owner/repo/releases/assets/1".to_string(),
548                browser_download_url: "https://example.com/checksums.txt".to_string(),
549                size: 128,
550            },
551            Asset {
552                name: "SHA256SUMS".to_string(),
553                url: "https://api.github.com/repos/owner/repo/releases/assets/2".to_string(),
554                browser_download_url: "https://example.com/SHA256SUMS".to_string(),
555                size: 256,
556            },
557            Asset {
558                name: "checksums.sha256".to_string(),
559                url: "https://api.github.com/repos/owner/repo/releases/assets/3".to_string(),
560                browser_download_url: "https://example.com/checksums.sha256".to_string(),
561                size: 200,
562            },
563        ];
564
565        let pattern = Regex::new(r"checksum|SHA256").unwrap();
566        let result = select_asset(&assets, &pattern);
567
568        assert!(result.is_some());
569        assert_eq!(result.unwrap().name, "checksums.txt");
570    }
571}