use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::Path;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use super::platform::release_base_url;
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManifestAsset {
pub name: String,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
#[serde(default)]
pub schema_version: u32,
pub name: String,
pub version: String,
pub tag: String,
#[serde(default)]
pub released_at: Option<String>,
#[serde(default)]
pub assets: BTreeMap<String, ManifestAsset>,
#[serde(default = "default_checksums_name")]
pub checksums: String,
#[serde(default = "default_signature_name")]
pub signature: String,
#[serde(default)]
pub minimum_version: Option<String>,
}
fn default_checksums_name() -> String {
"checksums.txt".to_string()
}
fn default_signature_name() -> String {
"checksums.txt.minisig".to_string()
}
impl Manifest {
pub fn parse(text: &str) -> Result<Manifest> {
serde_json::from_str(text).context("manifest.json is not a valid release manifest")
}
}
#[derive(Debug)]
pub enum DownloadError {
StillPublishing { version: String, url: String },
Status { url: String, status: u16 },
}
impl fmt::Display for DownloadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DownloadError::StillPublishing { version, url } => write!(
f,
"Release {version} is still publishing; retry in a few minutes (HTTP 404 for {url})"
),
DownloadError::Status { url, status } => write!(f, "{url} returned HTTP {status}"),
}
}
}
impl std::error::Error for DownloadError {}
pub fn release_file_url(version: &str, file_name: &str) -> String {
format!("{}/{file_name}", release_base_url(version))
}
pub fn download_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::limited(10))
.user_agent(format!("a4/{}", env!("CARGO_PKG_VERSION")))
.build()
.context("Failed to build HTTP client")
}
pub fn fetch_release_bytes(
client: &reqwest::blocking::Client,
version: &str,
file_name: &str,
) -> Result<Vec<u8>> {
let url = release_file_url(version, file_name);
let response = client
.get(&url)
.send()
.with_context(|| format!("Failed to download {url}"))?;
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(DownloadError::StillPublishing {
version: version.to_string(),
url,
}
.into());
}
if !status.is_success() {
return Err(DownloadError::Status {
url,
status: status.as_u16(),
}
.into());
}
let bytes = response
.bytes()
.with_context(|| format!("Failed to read {url}"))?;
Ok(bytes.to_vec())
}
pub fn download_release_file(
client: &reqwest::blocking::Client,
version: &str,
file_name: &str,
dest: &Path,
) -> Result<()> {
let bytes = fetch_release_bytes(client, version, file_name)?;
fs::write(dest, bytes).with_context(|| format!("Failed to write {}", dest.display()))?;
Ok(())
}
pub fn fetch_manifest(client: &reqwest::blocking::Client, version: &str) -> Result<Manifest> {
let bytes = fetch_release_bytes(client, version, "manifest.json")?;
let text = String::from_utf8(bytes).map_err(|_| anyhow!("manifest.json is not UTF-8"))?;
Manifest::parse(&text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_tolerates_null_minimum_version_and_defaults() {
let text = r#"{
"schemaVersion": 1, "name": "a4", "version": "0.13.0", "tag": "a4-cli-v0.13.0",
"releasedAt": "2026-09-10T12:00:00Z",
"assets": { "linux-x64": { "name": "a4-linux-x64", "sha256": "ab" } },
"checksums": "checksums.txt", "signature": "checksums.txt.minisig",
"minimumVersion": null
}"#;
let manifest = Manifest::parse(text).unwrap();
assert_eq!(manifest.minimum_version, None);
assert_eq!(manifest.assets["linux-x64"].name, "a4-linux-x64");
let minimal = r#"{ "name": "a4", "version": "0.13.0", "tag": "a4-cli-v0.13.0" }"#;
let manifest = Manifest::parse(minimal).unwrap();
assert_eq!(manifest.checksums, "checksums.txt");
assert_eq!(manifest.signature, "checksums.txt.minisig");
assert!(manifest.assets.is_empty());
}
#[test]
fn still_publishing_message_names_the_version() {
let error = DownloadError::StillPublishing {
version: "0.13.0".into(),
url: "https://example.invalid/manifest.json".into(),
};
assert!(error
.to_string()
.starts_with("Release 0.13.0 is still publishing; retry in a few minutes"));
}
}