use std::time::Duration;
use serde::Deserialize;
pub const INDEX_URL: &str = "https://crates.io/api/v1/crates/aion-cli";
const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
const USER_AGENT: &str = concat!("aion-cli/", env!("CARGO_PKG_VERSION"), " (aion update)");
#[derive(Debug)]
pub enum IndexError {
Unreachable {
source: String,
},
Unreadable {
status: u16,
detail: String,
},
}
impl std::fmt::Display for IndexError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreachable { source } => write!(
formatter,
"could not reach the package index at {INDEX_URL}: {source}. Nothing \
was installed and nothing running was touched; retry when the network \
is back, or name the version yourself with `--version <x.y.z>`"
),
Self::Unreadable { status, detail } => write!(
formatter,
"the package index at {INDEX_URL} answered {status}, which is not a \
version record this build can read ({detail}). Nothing was installed; \
name the version yourself with `--version <x.y.z>` if you know it"
),
}
}
}
impl std::error::Error for IndexError {}
#[derive(Debug, Deserialize)]
struct IndexAnswer {
#[serde(rename = "crate")]
crate_record: CrateRecord,
}
#[derive(Debug, Deserialize)]
struct CrateRecord {
max_stable_version: String,
}
pub async fn latest_stable() -> Result<String, IndexError> {
let client = reqwest::Client::builder()
.timeout(QUERY_TIMEOUT)
.user_agent(USER_AGENT)
.build()
.map_err(|error| IndexError::Unreachable {
source: error.to_string(),
})?;
let response = client
.get(INDEX_URL)
.send()
.await
.map_err(|error| IndexError::Unreachable {
source: error.to_string(),
})?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| IndexError::Unreadable {
status: status.as_u16(),
detail: error.to_string(),
})?;
parse_latest_stable(status.as_u16(), &body)
}
pub fn parse_latest_stable(status: u16, body: &str) -> Result<String, IndexError> {
if status != 200 {
return Err(IndexError::Unreadable {
status,
detail: format!(
"expected 200; the body begins `{}`",
body.chars().take(120).collect::<String>()
),
});
}
let answer: IndexAnswer =
serde_json::from_str(body).map_err(|error| IndexError::Unreadable {
status,
detail: error.to_string(),
})?;
let version = answer.crate_record.max_stable_version;
if version.trim().is_empty() {
return Err(IndexError::Unreadable {
status,
detail: "the record carries an empty max_stable_version".to_owned(),
});
}
Ok(version)
}
#[cfg(test)]
mod tests {
use super::{IndexError, parse_latest_stable};
#[test]
fn the_registry_record_yields_its_max_stable_version() -> Result<(), IndexError> {
let body = r#"{
"crate": {
"id": "aion-cli",
"name": "aion-cli",
"max_version": "0.26.0-rc.1",
"newest_version": "0.26.0-rc.1",
"max_stable_version": "0.25.1",
"description": "The aion binary",
"downloads": 1234
},
"versions": [{"num": "0.25.1"}]
}"#;
assert_eq!(parse_latest_stable(200, body)?, "0.25.1");
Ok(())
}
#[test]
fn a_prerelease_is_never_the_default_target() -> Result<(), IndexError> {
let body = r#"{"crate":{"max_version":"0.26.0-rc.1","newest_version":"0.26.0-rc.1","max_stable_version":"0.25.1"}}"#;
assert_eq!(parse_latest_stable(200, body)?, "0.25.1");
Ok(())
}
#[test]
fn a_non_success_status_is_reported_with_its_body() -> Result<(), Box<dyn std::error::Error>> {
let Err(IndexError::Unreadable { status, detail }) =
parse_latest_stable(503, "<html>upstream busy</html>")
else {
return Err("a 503 must not read as a version".into());
};
assert_eq!(status, 503);
assert!(detail.contains("upstream busy"), "{detail}");
Ok(())
}
#[test]
fn a_two_hundred_that_is_not_a_version_record_refuses() {
assert!(matches!(
parse_latest_stable(200, "<html>sign in to the wifi</html>"),
Err(IndexError::Unreadable { .. })
));
assert!(matches!(
parse_latest_stable(200, r#"{"crate":{"max_stable_version":""}}"#),
Err(IndexError::Unreadable { .. })
));
assert!(matches!(
parse_latest_stable(200, r#"{"crate":{}}"#),
Err(IndexError::Unreadable { .. })
));
}
}