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