use std::time::Duration;
use anyhow::{Context, anyhow};
const SPARSE_INDEX_URL: &str = "https://index.crates.io";
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(3);
pub async fn latest_version_req(crate_name: &str) -> anyhow::Result<String> {
let url = format!("{SPARSE_INDEX_URL}/{}", index_path(crate_name));
let response = reqwest::Client::builder()
.timeout(LOOKUP_TIMEOUT)
.build()?
.get(&url)
.send()
.await
.with_context(|| format!("querying {url}"))?;
if !response.status().is_success() {
return Err(anyhow!("{url} returned {}", response.status()));
}
let body = response.text().await.context("reading crates.io index response")?;
let version =
latest_stable_version(&body).ok_or_else(|| anyhow!("no stable versions of {crate_name} found on crates.io"))?;
Ok(format!("{}.{}", version.0, version.1))
}
fn index_path(crate_name: &str) -> String {
let name = crate_name.to_lowercase();
match name.len() {
0 => name,
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{name}", &name[0..1]),
_ => format!("{}/{}/{name}", &name[0..2], &name[2..4]),
}
}
fn latest_stable_version(body: &str) -> Option<(u64, u64, u64)> {
body.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|entry| entry["yanked"].as_bool() != Some(true))
.filter_map(|entry| parse_stable_version(entry["vers"].as_str()?))
.max()
}
fn parse_stable_version(vers: &str) -> Option<(u64, u64, u64)> {
if vers.contains(['-', '+']) {
return None;
}
let mut parts = vers.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_paths_shard_by_name_length() {
assert_eq!(index_path("a"), "1/a");
assert_eq!(index_path("ab"), "2/ab");
assert_eq!(index_path("abc"), "3/a/abc");
assert_eq!(
index_path("tari_ootle_template_build"),
"ta/ri/tari_ootle_template_build"
);
}
#[test]
fn index_paths_are_lowercased() {
assert_eq!(index_path("Inflector"), "in/fl/inflector");
}
#[test]
fn picks_the_highest_version_not_the_last_line() {
let body = concat!(
r#"{"name":"c","vers":"0.9.0","yanked":false}"#,
"\n",
r#"{"name":"c","vers":"0.11.0","yanked":false}"#,
"\n",
r#"{"name":"c","vers":"0.9.1","yanked":false}"#,
"\n",
);
assert_eq!(latest_stable_version(body), Some((0, 11, 0)));
}
#[test]
fn skips_yanked_and_prerelease_versions() {
let body = concat!(
r#"{"name":"c","vers":"0.7.0","yanked":false}"#,
"\n",
r#"{"name":"c","vers":"0.8.0","yanked":true}"#,
"\n",
r#"{"name":"c","vers":"0.9.0-rc.1","yanked":false}"#,
"\n",
);
assert_eq!(latest_stable_version(body), Some((0, 7, 0)));
}
#[test]
fn ignores_unparseable_lines() {
let body = concat!("not json\n", r#"{"name":"c","vers":"1.2.3","yanked":false}"#, "\n");
assert_eq!(latest_stable_version(body), Some((1, 2, 3)));
}
#[test]
fn no_stable_versions_yields_none() {
let body = concat!(r#"{"name":"c","vers":"0.1.0","yanked":true}"#, "\n");
assert_eq!(latest_stable_version(body), None);
}
#[test]
fn version_parsing_rejects_junk() {
assert_eq!(parse_stable_version("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_stable_version("1.2"), Some((1, 2, 0)));
assert_eq!(parse_stable_version("1.2.3.4"), None);
assert_eq!(parse_stable_version("1.2.3-beta"), None);
assert_eq!(parse_stable_version("nope"), None);
}
}