use std::fs;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::GlobalConfig;
const CURRENT: &str = env!("CARGO_PKG_VERSION");
const CHECK_EVERY_SECS: u64 = 60 * 60 * 24;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UpdateStatus {
Checking,
Current,
Available(String),
Unknown,
}
impl UpdateStatus {
pub fn label(&self) -> String {
match self {
Self::Checking => "checking in the background".into(),
Self::Current => format!("{CURRENT} is current"),
Self::Available(version) => format!("{version} available (installed: {CURRENT})"),
Self::Unknown => "not checked".into(),
}
}
}
pub fn background_status() -> (UpdateStatus, Option<Receiver<UpdateStatus>>) {
let Some(path) = cache_path() else {
return (UpdateStatus::Unknown, None);
};
if let Some(version) = read_fresh_cache(&path) {
return (status_for(version), None);
}
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let status = match fetch_latest() {
Some(version) => {
let _ = write_cache(&path, &version);
status_for(version)
}
None => UpdateStatus::Unknown,
};
let _ = sender.send(status);
});
(UpdateStatus::Checking, Some(receiver))
}
fn status_for(version: String) -> UpdateStatus {
if is_newer(&version, CURRENT) {
UpdateStatus::Available(version)
} else {
UpdateStatus::Current
}
}
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}");
}
}
#[test]
fn cached_versions_map_to_dashboard_states() {
assert_eq!(status_for(CURRENT.into()), UpdateStatus::Current);
assert_eq!(
status_for("999.0.0".into()),
UpdateStatus::Available("999.0.0".into())
);
}
}