use std::path::Path;
use std::sync::OnceLock;
use std::time::Duration;
const GITHUB_REPO: &str = "leynos/whitaker";
const ROLLING_TAG: &str = "rolling";
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg_attr(test, mockall::automock)]
pub trait ArtefactDownloader {
fn download_manifest(&self, target: &str) -> Result<String, DownloadError>;
fn download_archive(&self, filename: &str, dest: &Path) -> Result<(), DownloadError>;
}
#[derive(Debug, thiserror::Error)]
pub enum DownloadError {
#[error("download failed for {url}: {reason}")]
HttpError {
url: String,
reason: String,
},
#[error("artefact not found: {url}")]
NotFound {
url: String,
},
#[error("I/O error writing download: {0}")]
Io(#[from] std::io::Error),
}
pub struct HttpDownloader;
impl HttpDownloader {
#[must_use]
pub fn asset_url(filename: &str) -> String {
format!("https://github.com/{GITHUB_REPO}/releases/download/{ROLLING_TAG}/{filename}")
}
}
impl ArtefactDownloader for HttpDownloader {
fn download_manifest(&self, target: &str) -> Result<String, DownloadError> {
let filename = format!("manifest-{target}.json");
let url = Self::asset_url(&filename);
download_text(&url)
}
fn download_archive(&self, filename: &str, dest: &Path) -> Result<(), DownloadError> {
let url = Self::asset_url(filename);
download_to_file(&url, dest)
}
}
fn download_text(url: &str) -> Result<String, DownloadError> {
let response = http_agent()
.get(url)
.call()
.map_err(|e| map_ureq_error(url, &e))?;
response
.into_body()
.read_to_string()
.map_err(|e| DownloadError::HttpError {
url: url.to_owned(),
reason: e.to_string(),
})
}
fn download_to_file(url: &str, dest: &Path) -> Result<(), DownloadError> {
let response = http_agent()
.get(url)
.call()
.map_err(|e| map_ureq_error(url, &e))?;
let mut file = std::fs::File::create(dest)?;
std::io::copy(&mut response.into_body().as_reader(), &mut file).map_err(DownloadError::Io)?;
Ok(())
}
fn http_agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
let config = ureq::Agent::config_builder()
.timeout_global(Some(DOWNLOAD_TIMEOUT))
.build();
ureq::Agent::new_with_config(config)
})
}
fn map_ureq_error(url: &str, err: &ureq::Error) -> DownloadError {
match err {
ureq::Error::StatusCode(404 | 410) => DownloadError::NotFound {
url: url.to_owned(),
},
other => DownloadError::HttpError {
url: url.to_owned(),
reason: other.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn asset_url_contains_repo_and_tag() {
let url = HttpDownloader::asset_url("test.tar.zst");
assert!(url.contains(GITHUB_REPO));
assert!(url.contains(ROLLING_TAG));
assert!(url.ends_with("test.tar.zst"));
}
#[test]
fn asset_url_for_manifest() {
let url = HttpDownloader::asset_url("manifest-x86_64-unknown-linux-gnu.json");
assert!(url.ends_with("manifest-x86_64-unknown-linux-gnu.json"));
}
#[rstest]
#[case(404, true)]
#[case(410, true)]
#[case(500, false)]
fn map_ureq_error_maps_status_codes(#[case] status: u16, #[case] is_not_found: bool) {
let err = ureq::Error::StatusCode(status);
let mapped = map_ureq_error("https://example.test/manifest", &err);
if is_not_found {
assert!(matches!(mapped, DownloadError::NotFound { .. }));
} else {
assert!(matches!(mapped, DownloadError::HttpError { .. }));
}
}
}