use anyhow::{Context, Result, bail};
use semver::Version;
use serde::Deserialize;
const INDEX_BASE: &str = "https://index.crates.io";
pub(crate) const USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (cargo-install wrapper)"
);
#[derive(Deserialize)]
struct IndexLine {
vers: String,
#[serde(default)]
yanked: bool,
}
pub fn index_path(name: &str) -> String {
let lower = name.to_lowercase();
match lower.len() {
0 => String::new(), 1 => format!("1/{lower}"),
2 => format!("2/{lower}"),
3 => match lower.get(..1) {
Some(first) => format!("3/{first}/{lower}"),
None => lower,
},
_ => match (lower.get(..2), lower.get(2..4)) {
(Some(a), Some(b)) => format!("{a}/{b}/{lower}"),
_ => lower,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Release {
pub version: Version,
pub yanked: bool,
}
pub fn published_versions(name: &str) -> Result<Vec<Version>> {
let releases = releases(name)?.ok_or_else(|| not_found(name))?;
non_yanked(name, releases)
}
pub fn not_found(name: &str) -> anyhow::Error {
anyhow::anyhow!("crate `{name}` not found on crates.io")
}
pub fn releases(name: &str) -> Result<Option<Vec<Release>>> {
let url = format!("{INDEX_BASE}/{}", index_path(name));
let response = match ureq::get(&url).set("User-Agent", USER_AGENT).call() {
Ok(response) => response,
Err(ureq::Error::Status(404, _)) => return Ok(None),
Err(other) => bail!("index request for `{name}` failed: {other}"),
};
let body = response
.into_string()
.with_context(|| format!("reading index response for `{name}`"))?;
parse_index_body(name, &body).map(Some)
}
fn parse_index_body(name: &str, body: &str) -> Result<Vec<Release>> {
let mut releases = Vec::new();
for line in body.lines().filter(|l| !l.trim().is_empty()) {
let entry: IndexLine = serde_json::from_str(line)
.with_context(|| format!("malformed index line for `{name}`"))?;
let version = Version::parse(&entry.vers)
.with_context(|| format!("unparsable version `{}` for `{name}`", entry.vers))?;
releases.push(Release {
version,
yanked: entry.yanked,
});
}
Ok(releases)
}
fn non_yanked(name: &str, releases: Vec<Release>) -> Result<Vec<Version>> {
let versions: Vec<Version> = releases
.into_iter()
.filter(|r| !r.yanked)
.map(|r| r.version)
.collect();
if versions.is_empty() {
bail!("crate `{name}` has no non-yanked versions");
}
Ok(versions)
}
#[derive(Debug, PartialEq, Eq)]
pub struct Summary {
pub latest_stable: Option<Release>,
pub latest_pre: Option<Release>,
pub total: usize,
pub yanked: usize,
}
pub fn summarize(releases: &[Release]) -> Summary {
let newest = |pre: bool| {
releases
.iter()
.filter(|r| r.version.pre.is_empty() != pre)
.max_by(|a, b| a.version.cmp(&b.version))
.cloned()
};
let latest_stable = newest(false);
let latest_pre = newest(true).filter(|pre| {
latest_stable
.as_ref()
.is_none_or(|stable| pre.version > stable.version)
});
Summary {
latest_stable,
latest_pre,
total: releases.len(),
yanked: releases.iter().filter(|r| r.yanked).count(),
}
}
pub fn latest_relevant(versions: &[Version], current: &Version) -> Option<Version> {
let allow_pre = !current.pre.is_empty();
versions
.iter()
.filter(|v| allow_pre || v.pre.is_empty())
.max()
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_path_matches_rfc_scheme() {
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("scxtui"), "sc/xt/scxtui");
assert_eq!(index_path("ripgrep"), "ri/pg/ripgrep");
assert_eq!(index_path("Inflector"), "in/fl/inflector");
}
#[test]
fn multibyte_names_never_panic() {
let _ = index_path("żółć");
let _ = index_path("€uro");
let _ = index_path("€ab");
}
#[test]
fn yanked_versions_are_skipped() {
let body = r#"{"vers":"0.1.0","yanked":false}
{"vers":"0.2.0","yanked":true}
{"vers":"0.1.5","yanked":false}"#;
let versions = non_yanked("demo", parse_index_body("demo", body).unwrap()).unwrap();
let current = Version::parse("0.1.0").unwrap();
let latest = latest_relevant(&versions, ¤t).unwrap();
assert_eq!(latest, Version::parse("0.1.5").unwrap());
}
#[test]
fn semver_not_lexicographic() {
let body = r#"{"vers":"0.9.0","yanked":false}
{"vers":"0.10.0","yanked":false}"#;
let versions = non_yanked("demo", parse_index_body("demo", body).unwrap()).unwrap();
let current = Version::parse("0.9.0").unwrap();
let latest = latest_relevant(&versions, ¤t).unwrap();
assert_eq!(latest, Version::parse("0.10.0").unwrap());
}
#[test]
fn prereleases_hidden_from_stable_users() {
let body = r#"{"vers":"1.0.0","yanked":false}
{"vers":"1.1.0-rc.1","yanked":false}"#;
let versions = non_yanked("demo", parse_index_body("demo", body).unwrap()).unwrap();
let stable = Version::parse("1.0.0").unwrap();
assert_eq!(latest_relevant(&versions, &stable).unwrap(), stable);
let pre = Version::parse("1.0.0-beta.2").unwrap();
assert_eq!(
latest_relevant(&versions, &pre).unwrap(),
Version::parse("1.1.0-rc.1").unwrap()
);
}
#[test]
fn all_versions_yanked_is_an_error() {
let body = r#"{"vers":"0.1.0","yanked":true}"#;
let releases = parse_index_body("demo", body).unwrap();
assert_eq!(releases.len(), 1);
assert!(non_yanked("demo", releases).is_err());
}
fn rel(v: &str, yanked: bool) -> Release {
Release {
version: Version::parse(v).unwrap(),
yanked,
}
}
#[test]
fn summary_reports_history_including_yanked() {
let body = r#"{"vers":"0.9.0","yanked":false}
{"vers":"1.0.0-rc.1","yanked":false}
{"vers":"1.0.0","yanked":false}
{"vers":"1.0.1","yanked":true}
{"vers":"1.1.0-beta.1","yanked":false}
{"vers":"1.1.0-beta.2","yanked":true}"#;
let summary = summarize(&parse_index_body("demo", body).unwrap());
assert_eq!(summary.latest_stable, Some(rel("1.0.1", true)));
assert_eq!(summary.latest_pre, Some(rel("1.1.0-beta.2", true)));
assert_eq!((summary.total, summary.yanked), (6, 2));
}
#[test]
fn summary_of_prerelease_only_crate() {
let body = r#"{"vers":"0.1.0-alpha.1","yanked":false}"#;
let summary = summarize(&parse_index_body("demo", body).unwrap());
assert_eq!(summary.latest_stable, None);
assert_eq!(summary.latest_pre, Some(rel("0.1.0-alpha.1", false)));
}
#[test]
fn summary_of_fully_yanked_crate_still_has_a_latest() {
let body = r#"{"vers":"0.1.0","yanked":true}"#;
let summary = summarize(&parse_index_body("demo", body).unwrap());
assert_eq!(
summary,
Summary {
latest_stable: Some(rel("0.1.0", true)),
latest_pre: None,
total: 1,
yanked: 1
}
);
}
}