use anyhow::Result;
use semver::Version;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct IndexEntry {
vers: String,
#[serde(default)]
yanked: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct LatestVersions {
pub stable: Option<String>,
pub prerelease: Option<String>,
}
pub fn index_path(name: &str) -> String {
let lower = name.to_lowercase();
let chars: Vec<char> = lower.chars().collect();
match chars.len() {
0 => String::new(),
1 => format!("1/{}", lower),
2 => format!("2/{}", lower),
3 => format!("3/{}/{}", chars[0], lower),
_ => format!(
"{}{}/{}{}/{}",
chars[0], chars[1], chars[2], chars[3], lower
),
}
}
pub fn parse_index_body(body: &str) -> LatestVersions {
let mut max_stable: Option<Version> = None;
let mut max_prerelease: Option<Version> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<IndexEntry>(line) else {
continue;
};
if entry.yanked {
continue;
}
let Ok(version) = Version::parse(&entry.vers) else {
continue;
};
if version.pre.is_empty() {
if max_stable.as_ref().is_none_or(|cur| version > *cur) {
max_stable = Some(version);
}
} else if max_prerelease.as_ref().is_none_or(|cur| version > *cur) {
max_prerelease = Some(version);
}
}
LatestVersions {
stable: max_stable.map(|v| v.to_string()),
prerelease: max_prerelease.map(|v| v.to_string()),
}
}
pub async fn fetch_latest(
client: &reqwest::Client,
base_url: &str,
name: &str,
) -> Result<LatestVersions> {
let path = index_path(name);
if path.is_empty() {
anyhow::bail!("empty package name");
}
let url = format!("{}/{}", base_url.trim_end_matches('/'), path);
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("sparse index HTTP {}", resp.status());
}
let body = resp.text().await?;
Ok(parse_index_body(&body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_path_one_char() {
assert_eq!(index_path("a"), "1/a");
}
#[test]
fn index_path_two_chars() {
assert_eq!(index_path("ab"), "2/ab");
}
#[test]
fn index_path_three_chars() {
assert_eq!(index_path("abc"), "3/a/abc");
}
#[test]
fn index_path_four_plus_chars() {
assert_eq!(index_path("cargo"), "ca/rg/cargo");
assert_eq!(index_path("cargo-fresh"), "ca/rg/cargo-fresh");
assert_eq!(index_path("ripgrep"), "ri/pg/ripgrep");
}
#[test]
fn index_path_lowercases() {
assert_eq!(index_path("Cargo-Fresh"), "ca/rg/cargo-fresh");
}
#[test]
fn parse_picks_max_stable() {
let body = r#"
{"name":"ripgrep","vers":"13.0.0","yanked":false}
{"name":"ripgrep","vers":"14.1.1","yanked":false}
{"name":"ripgrep","vers":"14.0.3","yanked":false}
"#;
let v = parse_index_body(body);
assert_eq!(v.stable.as_deref(), Some("14.1.1"));
assert_eq!(v.prerelease, None);
}
#[test]
fn parse_separates_stable_and_prerelease() {
let body = r#"
{"name":"foo","vers":"1.0.0","yanked":false}
{"name":"foo","vers":"2.0.0-rc.1","yanked":false}
{"name":"foo","vers":"2.0.0-beta.5","yanked":false}
"#;
let v = parse_index_body(body);
assert_eq!(v.stable.as_deref(), Some("1.0.0"));
assert_eq!(v.prerelease.as_deref(), Some("2.0.0-rc.1"));
}
#[test]
fn parse_skips_yanked() {
let body = r#"
{"name":"foo","vers":"1.0.0","yanked":false}
{"name":"foo","vers":"2.0.0","yanked":true}
"#;
let v = parse_index_body(body);
assert_eq!(v.stable.as_deref(), Some("1.0.0"));
}
#[test]
fn parse_skips_unparseable_lines() {
let body = r#"
not valid json
{"name":"foo","vers":"1.0.0","yanked":false}
{"name":"foo","vers":"not.a.version","yanked":false}
{"name":"foo","vers":"2.0.0","yanked":false}
"#;
let v = parse_index_body(body);
assert_eq!(v.stable.as_deref(), Some("2.0.0"));
}
#[test]
fn parse_empty_body_returns_empty() {
let v = parse_index_body("");
assert_eq!(v, LatestVersions::default());
}
#[test]
fn parse_only_yanked_returns_empty() {
let body = r#"
{"name":"foo","vers":"1.0.0","yanked":true}
{"name":"foo","vers":"2.0.0","yanked":true}
"#;
let v = parse_index_body(body);
assert_eq!(v, LatestVersions::default());
}
}