use std::cmp::Ordering;
use std::time::Duration;
const USER_AGENT: &str = "nordisk-release (rickard@x14.se)";
const CRATES_IO_SPARSE_BASE: &str = "https://index.crates.io";
pub trait RegistryBackend {
fn published_max_version(&self, krate: &str) -> Option<String>;
}
pub struct CratesIoBackend;
impl RegistryBackend for CratesIoBackend {
fn published_max_version(&self, krate: &str) -> Option<String> {
let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
if let Some(body) = http_get(&url) {
if let Some(v) = parse_sparse_index_max_version(&body) {
return Some(v);
}
}
let url = format!("https://crates.io/api/v1/crates/{krate}");
let body = http_get(&url)?;
parse_json_api_max_version(&body)
}
}
pub struct CustomBackend {
pub base_url: String,
}
impl RegistryBackend for CustomBackend {
fn published_max_version(&self, krate: &str) -> Option<String> {
let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
let body = http_get(&url)?;
parse_sparse_index_max_version(&body)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackendChoice {
CratesIo,
Custom(String),
}
pub fn choose(backend: &str, url: &str, flag_url: Option<&str>) -> BackendChoice {
if let Some(u) = flag_url.filter(|u| !u.trim().is_empty()) {
return BackendChoice::Custom(u.trim().to_string());
}
match backend {
"custom" if !url.trim().is_empty() => BackendChoice::Custom(url.trim().to_string()),
_ => BackendChoice::CratesIo,
}
}
pub fn resolve(backend: &str, url: &str, flag_url: Option<&str>) -> Box<dyn RegistryBackend> {
match choose(backend, url, flag_url) {
BackendChoice::Custom(base_url) => Box::new(CustomBackend { base_url }),
BackendChoice::CratesIo => Box::new(CratesIoBackend),
}
}
pub fn sparse_index_url(base: &str, krate: &str) -> String {
let name = krate.to_lowercase();
let path = match name.chars().count() {
0 => name.clone(),
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{}", &name[..1], name),
_ => format!("{}/{}/{}", &name[..2], &name[2..4], name),
};
format!("{base}/{path}")
}
pub fn parse_sparse_index_max_version(body: &str) -> Option<String> {
let mut best: Option<(semver::Version, String)> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
continue;
}
let Some(vers) = v.get("vers").and_then(|s| s.as_str()) else {
continue;
};
let Ok(parsed) = semver::Version::parse(vers) else {
continue;
};
if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
best = Some((parsed, vers.to_string()));
}
}
best.map(|(_, raw)| raw)
}
pub fn parse_json_api_max_version(body: &str) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(body).ok()?;
if let Some(arr) = json.get("versions").and_then(|v| v.as_array()) {
let mut best: Option<(semver::Version, String)> = None;
for e in arr {
if e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
continue;
}
let Some(num) = e.get("num").and_then(|n| n.as_str()) else {
continue;
};
let Ok(parsed) = semver::Version::parse(num) else {
continue;
};
if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
best = Some((parsed, num.to_string()));
}
}
if best.is_some() {
return best.map(|(_, raw)| raw);
}
}
json.get("crate")
.and_then(|c| c.get("max_stable_version").or_else(|| c.get("max_version")))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryState {
WillPublish,
AlreadyPublished,
Desync,
Unpublished,
Unknown,
}
pub fn classify(local: &str, published: Option<&str>) -> RegistryState {
let Some(published) = published else {
return RegistryState::Unpublished;
};
let (Ok(l), Ok(p)) = (semver::Version::parse(local), semver::Version::parse(published))
else {
return RegistryState::Unknown;
};
match l.cmp(&p) {
Ordering::Greater => RegistryState::WillPublish,
Ordering::Equal => RegistryState::AlreadyPublished,
Ordering::Less => RegistryState::Desync,
}
}
pub fn state_of(backend: &dyn RegistryBackend, krate: &str, local: &str) -> RegistryState {
classify(local, backend.published_max_version(krate).as_deref())
}
fn http_get(url: &str) -> Option<String> {
ureq::get(url)
.set("User-Agent", USER_AGENT)
.timeout(Duration::from_secs(10))
.call()
.ok()?
.into_string()
.ok()
}
pub fn read_crates_io_token() -> Option<String> {
if let Ok(t) = std::env::var("CARGO_REGISTRY_TOKEN") {
if !t.trim().is_empty() {
return Some(t);
}
}
let home = std::env::var_os("HOME")?;
let txt = std::fs::read_to_string(std::path::Path::new(&home).join(".cargo/credentials.toml"))
.ok()?;
let doc: toml::Value = txt.parse().ok()?;
let pick = |t: &toml::Value| t.get("token").and_then(|v| v.as_str()).map(str::to_string);
doc.get("registry")
.and_then(pick)
.or_else(|| doc.get("registries").and_then(|r| r.get("crates-io")).and_then(pick))
}
pub fn crates_io_login(token: &str) -> Option<String> {
let body = ureq::get("https://crates.io/api/v1/me")
.set("User-Agent", USER_AGENT)
.set("Authorization", token)
.timeout(Duration::from_secs(10))
.call()
.ok()?
.into_string()
.ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
json.get("user")?.get("login")?.as_str().map(str::to_string)
}
pub fn crate_owner_logins(krate: &str) -> Option<Vec<String>> {
let json: serde_json::Value =
serde_json::from_str(&http_get(&format!("https://crates.io/api/v1/crates/{krate}/owners"))?)
.ok()?;
let logins: Vec<String> = json
.get("users")?
.as_array()?
.iter()
.filter_map(|u| u.get("login").and_then(|l| l.as_str()).map(str::to_string))
.collect();
(!logins.is_empty()).then_some(logins)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipBlock {
pub krate: String,
pub owners: Vec<String>,
}
pub fn ownership_blocks_with(
names: &[String],
my_login: Option<&str>,
owners_of: impl Fn(&str) -> Option<Vec<String>>,
) -> Vec<OwnershipBlock> {
let Some(me) = my_login else { return Vec::new() };
names
.iter()
.filter_map(|n| {
let owners = owners_of(n)?; (!owners.iter().any(|o| o.eq_ignore_ascii_case(me)))
.then(|| OwnershipBlock { krate: n.clone(), owners })
})
.collect()
}
pub fn ownership_blocks(names: &[String], my_login: Option<&str>) -> Vec<OwnershipBlock> {
ownership_blocks_with(names, my_login, crate_owner_logins)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ownership_blocks_flags_only_foreign_existing_names() {
let owners = |k: &str| -> Option<Vec<String>> {
match k {
"gatling" => Some(vec!["AbdelStark".to_string()]), "rotary" => Some(vec!["udoprog".to_string()]), "minigun" => None, "znippy-common" => Some(vec!["Nordisk".to_string(), "rickard".to_string()]), _ => None,
}
};
let names: Vec<String> =
["gatling", "rotary", "minigun", "znippy-common"].iter().map(|s| s.to_string()).collect();
let flagged: Vec<String> =
ownership_blocks_with(&names, Some("rickard"), owners).into_iter().map(|b| b.krate).collect();
assert_eq!(flagged, vec!["gatling", "rotary"], "only foreign existing names block");
assert!(ownership_blocks_with(&names, None, owners).is_empty());
}
#[test]
fn sparse_index_parses_max_non_yanked_version() {
let body = concat!(
r#"{"name":"gatling","vers":"0.1.3","yanked":false}"#,
"\n",
r#"{"name":"gatling","vers":"0.1.4","yanked":false}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.4"));
}
#[test]
fn sparse_index_skips_yanked_and_is_order_independent() {
let body = concat!(
r#"{"name":"c","vers":"0.1.9","yanked":false}"#,
"\n",
r#"{"name":"c","vers":"0.2.0","yanked":true}"#,
"\n",
r#"{"name":"c","vers":"0.1.4","yanked":false}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.9"));
}
#[test]
fn sparse_index_tolerates_junk_and_all_yanked() {
assert_eq!(parse_sparse_index_max_version(""), None);
assert_eq!(parse_sparse_index_max_version("not json\n\n{bad"), None);
let all_yanked = concat!(
r#"{"name":"z","vers":"1.0.0","yanked":true}"#,
"\n",
r#"{"name":"z","vers":"1.1.0","yanked":true}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(all_yanked), None);
}
#[test]
fn json_api_parses_versions_then_max_stable() {
let body = r#"{
"crate": {"max_stable_version": "0.9.9"},
"versions": [
{"num": "0.1.4", "yanked": false},
{"num": "0.2.0", "yanked": true},
{"num": "0.1.9", "yanked": false}
]
}"#;
assert_eq!(parse_json_api_max_version(body).as_deref(), Some("0.1.9"));
let only_crate = r#"{"crate": {"max_stable_version": "3.2.1"}}"#;
assert_eq!(parse_json_api_max_version(only_crate).as_deref(), Some("3.2.1"));
}
#[test]
fn classify_covers_the_full_table() {
assert_eq!(classify("0.1.7", Some("0.1.4")), RegistryState::WillPublish);
assert_eq!(classify("0.1.4", Some("0.1.4")), RegistryState::AlreadyPublished);
assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
assert_eq!(classify("0.1.0", None), RegistryState::Unpublished);
assert_eq!(classify("not-a-semver", Some("0.1.0")), RegistryState::Unknown);
}
#[test]
fn classify_flags_the_gatling_desync() {
assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
}
#[test]
#[ignore]
fn live_cratesio_backend_resolves_gatling() {
let v = CratesIoBackend.published_max_version("gatling").expect("gatling published");
eprintln!("live gatling published max = {v}");
let published = semver::Version::parse(&v).expect("valid semver");
let behind = format!("{}.{}.{}", published.major, published.minor, published.patch.saturating_sub(1));
assert_eq!(classify(&behind, Some(&v)), RegistryState::Desync);
}
#[test]
fn sparse_index_url_follows_cargo_path_convention() {
assert_eq!(sparse_index_url("https://index.crates.io", "a"), "https://index.crates.io/1/a");
assert_eq!(sparse_index_url("https://index.crates.io", "ab"), "https://index.crates.io/2/ab");
assert_eq!(sparse_index_url("https://index.crates.io", "abc"), "https://index.crates.io/3/a/abc");
assert_eq!(
sparse_index_url("https://index.crates.io", "gatling"),
"https://index.crates.io/ga/tl/gatling"
);
assert_eq!(sparse_index_url("https://reg/idx", "Serde"), "https://reg/idx/se/rd/serde");
}
#[test]
fn choose_precedence_flag_then_config_then_default() {
assert_eq!(
choose("crates-io", "", Some("https://flag/idx")),
BackendChoice::Custom("https://flag/idx".into())
);
assert_eq!(
choose("custom", "https://cfg/idx", None),
BackendChoice::Custom("https://cfg/idx".into())
);
assert_eq!(choose("crates-io", "", None), BackendChoice::CratesIo);
assert_eq!(choose("custom", "", None), BackendChoice::CratesIo);
assert_eq!(choose("custom", "https://cfg/idx", Some(" ")), BackendChoice::Custom("https://cfg/idx".into()));
}
}