use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
const CRATE_NAME: &str = "quorum-rs";
const CACHE_TTL_SECS: i64 = 24 * 3600;
const FETCH_TIMEOUT: Duration = Duration::from_secs(3);
fn current_version() -> Option<Version> {
Version::parse(env!("CARGO_PKG_VERSION")).ok()
}
fn latest_for_channel(candidates: &[Version], current: &Version) -> Option<Version> {
let on_stable = current.pre.is_empty();
candidates
.iter()
.filter(|v| !on_stable || v.pre.is_empty())
.max()
.cloned()
}
fn parse_versions(body: &str) -> Vec<Version> {
let json: serde_json::Value = match serde_json::from_str(body) {
Ok(j) => j,
Err(_) => return Vec::new(),
};
json["versions"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|v| !v["yanked"].as_bool().unwrap_or(false))
.filter_map(|v| v["num"].as_str())
.filter_map(|s| Version::parse(s).ok())
.collect()
})
.unwrap_or_default()
}
fn upgrade_notice(current: &Version, latest: &Version) -> Option<String> {
(latest > current).then(|| {
format!(
"\u{2b06} quorum {latest} available (you have {current}) — \
upgrade: cargo install quorum-rs --version {latest}"
)
})
}
#[derive(Serialize, Deserialize)]
struct Cache {
checked_at: i64,
latest: String,
}
fn cache_path() -> Option<PathBuf> {
crate::cli::endpoint::nsed_dir().map(|d| d.join("version_check.json"))
}
fn read_cache() -> Option<Cache> {
let body = std::fs::read_to_string(cache_path()?).ok()?;
serde_json::from_str(&body).ok()
}
fn write_cache(latest: &str, now: i64) {
let Some(path) = cache_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(body) = serde_json::to_string(&Cache {
checked_at: now,
latest: latest.to_string(),
}) {
let _ = std::fs::write(path, body);
}
}
fn is_fresh(cache: &Cache, now: i64) -> bool {
now.saturating_sub(cache.checked_at) < CACHE_TTL_SECS
}
pub fn cached_notice() -> Option<String> {
let current = current_version()?;
let latest = Version::parse(&read_cache()?.latest).ok()?;
upgrade_notice(¤t, &latest)
}
pub async fn refreshed_notice(now: i64) -> Option<String> {
let current = current_version()?;
if let Some(cache) = read_cache()
&& is_fresh(&cache, now)
&& let Ok(latest) = Version::parse(&cache.latest)
{
return upgrade_notice(¤t, &latest);
}
let versions = fetch_versions().await?;
let latest = latest_for_channel(&versions, ¤t)?;
write_cache(&latest.to_string(), now);
upgrade_notice(¤t, &latest)
}
async fn fetch_versions() -> Option<Vec<Version>> {
let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
let client = reqwest::Client::builder()
.timeout(FETCH_TIMEOUT)
.user_agent(concat!("quorum-cli/", env!("CARGO_PKG_VERSION")))
.build()
.ok()?;
let body = client.get(url).send().await.ok()?.text().await.ok()?;
Some(parse_versions(&body))
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn stable_user_only_sees_newer_stable() {
let candidates = [v("0.6.0"), v("0.7.0-rc.5"), v("0.6.1")];
let got = latest_for_channel(&candidates, &v("0.6.0")).unwrap();
assert_eq!(got, v("0.6.1"));
}
#[test]
fn stable_user_ignores_higher_prerelease() {
let candidates = [v("0.6.0"), v("0.7.0-rc.1")];
assert_eq!(
latest_for_channel(&candidates, &v("0.6.0")).unwrap(),
v("0.6.0")
);
}
#[test]
fn rc_user_sees_newer_rc() {
let candidates = [v("0.6.0-rc.2"), v("0.6.0-rc.16"), v("0.6.0-rc.9")];
assert_eq!(
latest_for_channel(&candidates, &v("0.6.0-rc.2")).unwrap(),
v("0.6.0-rc.16")
);
}
#[test]
fn rc_user_also_sees_the_superseding_stable() {
let candidates = [v("0.6.0-rc.16"), v("0.6.0")];
assert_eq!(
latest_for_channel(&candidates, &v("0.6.0-rc.16")).unwrap(),
v("0.6.0")
);
}
#[test]
fn notice_only_when_strictly_newer() {
assert!(upgrade_notice(&v("0.6.0"), &v("0.6.0")).is_none());
assert!(upgrade_notice(&v("0.6.1"), &v("0.6.0")).is_none());
let msg = upgrade_notice(&v("0.6.0"), &v("0.6.1")).unwrap();
assert!(msg.contains("0.6.1") && msg.contains("0.6.0"));
assert!(msg.contains("cargo install quorum-rs --version 0.6.1"));
}
#[test]
fn parse_versions_skips_yanked_and_malformed() {
let body = r#"{
"versions": [
{"num": "0.6.0", "yanked": false},
{"num": "0.6.1", "yanked": true},
{"num": "not-a-version", "yanked": false},
{"num": "0.7.0-rc.1", "yanked": false}
]
}"#;
let vs = parse_versions(body);
assert!(vs.contains(&v("0.6.0")));
assert!(vs.contains(&v("0.7.0-rc.1")));
assert!(!vs.contains(&v("0.6.1")), "yanked excluded");
assert_eq!(vs.len(), 2, "malformed + yanked dropped");
}
#[test]
fn parse_versions_tolerates_garbage() {
assert!(parse_versions("not json").is_empty());
assert!(parse_versions("{}").is_empty());
}
#[test]
fn freshness_window() {
let c = Cache {
checked_at: 1000,
latest: "0.6.0".into(),
};
assert!(is_fresh(&c, 1000 + CACHE_TTL_SECS - 1));
assert!(!is_fresh(&c, 1000 + CACHE_TTL_SECS + 1));
}
}