use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::GlobalConfig;
use crate::ui;
const CURRENT: &str = env!("CARGO_PKG_VERSION");
const CHECK_EVERY_SECS: u64 = 60 * 60 * 24;
pub fn nudge_if_outdated() {
if std::env::var_os("RPROJ_NO_UPDATE_CHECK").is_some() {
return;
}
let Some(latest) = latest_version() else { return };
if is_newer(&latest, CURRENT) {
ui::detail(&format!(
"rproj {latest} is available (you have {CURRENT}) - cargo install rproj --force"
));
}
}
fn latest_version() -> Option<String> {
let path = cache_path()?;
if let Some(cached) = read_fresh_cache(&path) {
return Some(cached);
}
let latest = fetch_latest()?;
let _ = write_cache(&path, &latest);
Some(latest)
}
fn cache_path() -> Option<PathBuf> {
Some(GlobalConfig::dirs().ok()?.cache_dir().join("latest-version"))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn read_fresh_cache(path: &PathBuf) -> Option<String> {
let text = fs::read_to_string(path).ok()?;
let (stamp, version) = text.split_once('\n')?;
let stamp: u64 = stamp.trim().parse().ok()?;
if now_secs().saturating_sub(stamp) > CHECK_EVERY_SECS {
return None;
}
let version = version.trim();
(!version.is_empty()).then(|| version.to_string())
}
fn write_cache(path: &PathBuf, version: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n{version}\n", now_secs()))
}
fn fetch_latest() -> Option<String> {
let body = ureq::get("https://crates.io/api/v1/crates/rproj/versions")
.header("User-Agent", concat!("rproj/", env!("CARGO_PKG_VERSION")))
.config()
.timeout_global(Some(std::time::Duration::from_secs(3)))
.build()
.call()
.ok()?
.body_mut()
.read_to_string()
.ok()?;
newest_from_json(&body)
}
fn newest_from_json(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value
.get("versions")?
.as_array()?
.iter()
.filter(|v| v.get("yanked").and_then(|y| y.as_bool()) != Some(true))
.filter_map(|v| v.get("num")?.as_str())
.max_by(|a, b| compare(a, b))
.map(str::to_string)
}
fn compare(a: &str, b: &str) -> std::cmp::Ordering {
fn parts(v: &str) -> Vec<u64> {
v.split(['.', '-', '+'])
.map_while(|p| p.parse::<u64>().ok())
.collect()
}
parts(a).cmp(&parts(b))
}
fn is_newer(candidate: &str, current: &str) -> bool {
compare(candidate, current) == std::cmp::Ordering::Greater
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_compare_numerically_not_lexically() {
assert!(is_newer("0.10.0", "0.2.0"), "10 > 2, despite \"1\" < \"2\"");
assert!(is_newer("0.2.1", "0.2.0"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(!is_newer("0.2.0", "0.2.0"));
assert!(!is_newer("0.1.9", "0.2.0"));
}
#[test]
fn a_prerelease_suffix_does_not_break_the_comparison() {
assert!(!is_newer("0.2.0-rc.1", "0.2.0"));
assert!(is_newer("0.3.0-rc.1", "0.2.0"));
}
#[test]
fn the_newest_non_yanked_version_wins() {
let body = r#"{"versions":[
{"num":"0.1.0","yanked":false},
{"num":"0.10.0","yanked":false},
{"num":"0.2.0","yanked":false}
]}"#;
assert_eq!(newest_from_json(body).as_deref(), Some("0.10.0"));
}
#[test]
fn a_yanked_version_is_ignored() {
let body = r#"{"versions":[
{"num":"0.2.0","yanked":false},
{"num":"0.3.0","yanked":true}
]}"#;
assert_eq!(newest_from_json(body).as_deref(), Some("0.2.0"));
}
#[test]
fn a_malformed_response_is_none() {
for body in ["", "not json", "{}", r#"{"versions":[]}"#, r#"{"versions":{}}"#] {
assert_eq!(newest_from_json(body), None, "{body}");
}
}
}