use crate::github::GithubEndpoints; use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; #[derive(Debug, Deserialize)] struct Release { assets: Vec, } #[derive(Debug, Deserialize)] struct Asset { name: String, browser_download_url: String, } /// Downloads the release asset named exactly `asset_name` for `repo`@`tag` /// into `dest_dir`, returning the local path. pub fn download_asset( client: &reqwest::blocking::Client, endpoints: &GithubEndpoints, repo: &str, tag: &str, asset_name: &str, dest_dir: &Path, ) -> Result { let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api); let release: Release = client .get(&api_url) .send()? .error_for_status() .with_context(|| format!("fetching release metadata from {api_url}"))? .json()?; let asset = release .assets .iter() .find(|a| a.name == asset_name) .with_context(|| format!("no asset named '{asset_name}' in {repo}@{tag}"))?; std::fs::create_dir_all(dest_dir)?; let dest_path = dest_dir.join(&asset.name); let bytes = client .get(&asset.browser_download_url) .send()? .error_for_status()? .bytes()?; std::fs::write(&dest_path, &bytes)?; Ok(dest_path) } #[cfg(test)] mod tests { use super::*; #[test] fn download_asset_writes_matching_asset_to_dest_dir() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; let asset_url = format!("{}/download/thing.tar.gz", server.url()); let release_body = format!( r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"# ); let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(200) .with_body(release_body) .create(); let _download = server .mock("GET", "/download/thing.tar.gz") .with_status(200) .with_body(b"artifact-bytes".as_slice()) .create(); let client = reqwest::blocking::Client::new(); let dest_dir = tempfile::tempdir().unwrap(); let path = download_asset( &client, &endpoints, "o/r", "v1.0.0", "thing.tar.gz", dest_dir.path(), ) .unwrap(); assert_eq!(path, dest_dir.path().join("thing.tar.gz")); assert_eq!(std::fs::read(&path).unwrap(), b"artifact-bytes"); } #[test] fn download_asset_errors_when_no_asset_matches() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(200) .with_body(r#"{"assets": [{"name": "other", "browser_download_url": "https://example.invalid/other"}]}"#) .create(); let client = reqwest::blocking::Client::new(); let dest_dir = tempfile::tempdir().unwrap(); let err = download_asset( &client, &endpoints, "o/r", "v1.0.0", "thing.tar.gz", dest_dir.path(), ) .unwrap_err(); assert!(err.to_string().contains("no asset named")); } #[test] fn download_asset_errors_when_release_not_found() { let mut server = mockito::Server::new(); let endpoints = GithubEndpoints { web: server.url(), api: server.url(), }; let _release = server .mock("GET", "/repos/o/r/releases/tags/v1.0.0") .with_status(404) .create(); let client = reqwest::blocking::Client::new(); let dest_dir = tempfile::tempdir().unwrap(); let err = download_asset( &client, &endpoints, "o/r", "v1.0.0", "thing.tar.gz", dest_dir.path(), ) .unwrap_err(); assert!(err.to_string().contains("fetching release metadata")); } }