use anyhow::{anyhow, Context as _};
use super::version::Version;
pub(super) const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
pub(super) const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024;
pub(super) fn repository_parts() -> anyhow::Result<(String, String)> {
let not_github = || {
anyhow!(
"`pmpx self update` only knows how to read GitHub releases, and {REPOSITORY} is not a \
GitHub repository"
)
};
let rest = REPOSITORY
.trim_end_matches('/')
.trim_end_matches(".git")
.strip_prefix("https://github.com/")
.or_else(|| REPOSITORY.strip_prefix("http://github.com/"))
.ok_or_else(not_github)?;
let (owner, repo) = rest.split_once('/').ok_or_else(not_github)?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return Err(not_github());
}
Ok((owner.to_string(), repo.to_string()))
}
pub(super) fn asset_stem() -> String {
format!("pmpx-{}", env!("PMPX_TARGET"))
}
pub(super) fn archive_ext(os: &str) -> &'static str {
if os == "windows" {
"zip"
} else {
"tar.gz"
}
}
pub(super) fn binary_name(os: &str) -> &'static str {
if os == "windows" {
"pmpx.exe"
} else {
"pmpx"
}
}
pub(super) fn latest_version() -> anyhow::Result<Version> {
let (owner, repo) = repository_parts()?;
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
let body = match download(&url) {
Ok(Some(body)) => body,
Ok(None) => return Err(anyhow!("no release has been published yet")),
Err(error) => return Err(error.context(format!("cannot ask {url}"))),
};
let json: serde_json::Value =
serde_json::from_slice(&body).context("the releases API did not return JSON")?;
let tag = json
.get("tag_name")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("the releases API returned no tag_name"))?;
Version::parse(tag)
.ok_or_else(|| anyhow!("the latest release is tagged {tag}, which is not a version"))
}
fn refused_message(code: u16, url: &str) -> String {
format!(
"GitHub refused the request ({code}): it rate-limits unauthenticated requests, and a \
repository that is not public answers the same way ({url})"
)
}
pub(super) fn download(url: &str) -> anyhow::Result<Option<Vec<u8>>> {
let request = ureq::get(url).header("User-Agent", &user_agent());
match request.call() {
Ok(mut response) => {
let body = response
.body_mut()
.read_to_vec()
.with_context(|| format!("cannot read the response from {url}"))?;
if body.len() as u64 > MAX_ASSET_BYTES {
return Err(anyhow!(
"{url} is larger than the {MAX_ASSET_BYTES} byte limit ({} bytes)",
body.len()
));
}
Ok(Some(body))
}
Err(ureq::Error::StatusCode(404)) => Ok(None),
Err(ureq::Error::StatusCode(code)) if code == 403 || code == 429 => {
Err(anyhow!("{}", refused_message(code, url)))
}
Err(error) => Err(anyhow!("{error} ({url})")),
}
}
pub(super) fn user_agent() -> String {
format!("pmpx/{} (+{REPOSITORY})", env!("CARGO_PKG_VERSION"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_archive_matches_the_platform() {
assert_eq!(archive_ext("windows"), "zip");
assert_eq!(archive_ext("linux"), "tar.gz");
assert_eq!(archive_ext("macos"), "tar.gz");
assert_eq!(binary_name("windows"), "pmpx.exe");
assert_eq!(binary_name("linux"), "pmpx");
assert_eq!(binary_name("macos"), "pmpx");
}
#[test]
fn the_asset_stem_carries_the_target() {
assert_eq!(asset_stem(), format!("pmpx-{}", env!("PMPX_TARGET")));
}
#[test]
fn a_refused_request_explains_both_causes() {
let message = refused_message(403, "https://example.invalid/x");
assert!(message.contains("403"), "{message}");
assert!(message.contains("rate-limits"), "{message}");
assert!(message.contains("not public"), "{message}");
assert!(message.contains("https://example.invalid/x"), "{message}");
}
}