use std::io::Write as _;
use std::path::{Path, PathBuf};
pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
const MARKER_STALE_AFTER: std::time::Duration = std::time::Duration::from_mins(30);
const SPARSE_INDEX: &str = "https://index.crates.io/ne/xu/nexus-chat";
pub async fn latest_version() -> Option<String> {
let body = reqwest::Client::new()
.get(SPARSE_INDEX)
.timeout(std::time::Duration::from_secs(4))
.send()
.await
.ok()?
.text()
.await
.ok()?;
body.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|v| {
!v.get("yanked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.filter_map(|v| {
v.get("vers")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.next_back()
}
pub fn version_gt(a: &str, b: &str) -> bool {
compare(a, b).is_gt()
}
fn compare(a: &str, b: &str) -> std::cmp::Ordering {
let core_a = a.split('-').next().unwrap_or(a);
let core_b = b.split('-').next().unwrap_or(b);
let core = compare_core(core_a, core_b);
if core != std::cmp::Ordering::Equal {
return core;
}
let pre_a = a.strip_prefix(core_a).and_then(|s| s.strip_prefix('-'));
let pre_b = b.strip_prefix(core_b).and_then(|s| s.strip_prefix('-'));
match (pre_a, pre_b) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(x), Some(y)) => compare_pre(x, y),
}
}
fn compare_core(a: &str, b: &str) -> std::cmp::Ordering {
let pa: Vec<&str> = a.split('.').collect();
let pb: Vec<&str> = b.split('.').collect();
for i in 0..pa.len().max(pb.len()) {
let x = pa.get(i).copied().unwrap_or("0");
let y = pb.get(i).copied().unwrap_or("0");
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(xn), Ok(yn)) => xn.cmp(&yn),
_ => x.cmp(y),
};
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
std::cmp::Ordering::Equal
}
fn compare_pre(a: &str, b: &str) -> std::cmp::Ordering {
let pa: Vec<&str> = a.split('.').collect();
let pb: Vec<&str> = b.split('.').collect();
for i in 0..pa.len().max(pb.len()) {
let x = pa.get(i).copied().unwrap_or("0");
let y = pb.get(i).copied().unwrap_or("0");
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(xn), Ok(yn)) => xn.cmp(&yn),
_ => x.cmp(y),
};
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
std::cmp::Ordering::Equal
}
fn marker_path(data_dir: &Path) -> PathBuf {
data_dir.join("auto-update.marker")
}
fn log_path(data_dir: &Path) -> PathBuf {
data_dir.join("auto-update.log")
}
fn path_is_dev_build(path: &Path) -> bool {
let s = path.to_string_lossy();
s.contains("/target/debug/") || s.contains("/target/release/")
}
fn is_dev_build() -> bool {
std::env::current_exe().is_ok_and(|p| path_is_dev_build(&p))
}
fn cargo_available() -> bool {
std::process::Command::new("cargo")
.arg("--version")
.output()
.is_ok()
}
fn marker_in_flight(marker: &Path) -> bool {
let Ok(meta) = std::fs::metadata(marker) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
marker_is_fresh(modified, std::time::SystemTime::now())
}
fn marker_is_fresh(modified: std::time::SystemTime, now: std::time::SystemTime) -> bool {
match now.duration_since(modified) {
Ok(age) => age < MARKER_STALE_AFTER,
Err(_) => true,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoUpdateOutcome {
Started,
InFlight,
Unavailable,
}
pub fn try_start_auto_update(data_dir: &Path, latest: &str) -> AutoUpdateOutcome {
if std::env::var_os("NEXUS_NO_UPDATE").is_some() || is_dev_build() || !cargo_available() {
return AutoUpdateOutcome::Unavailable;
}
let marker = marker_path(data_dir);
if marker_in_flight(&marker) {
return AutoUpdateOutcome::InFlight;
}
let _ = std::fs::remove_file(&marker);
if std::fs::write(&marker, format!("{latest}\n")).is_err() {
return AutoUpdateOutcome::Unavailable;
}
let mut cmd = std::process::Command::new("cargo");
cmd.args(["install", "--force", "nexus-chat"]);
if let Ok(log) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path(data_dir))
{
let _ = writeln!(
&log,
"--- auto-update to v{latest} started at {} ---",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
);
if let Ok(out) = log.try_clone() {
cmd.stdout(out);
cmd.stderr(log);
} else {
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
}
} else {
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
}
if cmd.spawn().is_err() {
let _ = std::fs::remove_file(&marker);
return AutoUpdateOutcome::Unavailable;
}
AutoUpdateOutcome::Started
}
pub fn install_now() -> anyhow::Result<std::process::ExitStatus> {
let status = std::process::Command::new("cargo")
.args(["install", "--force", "nexus-chat"])
.status()
.map_err(|e| {
anyhow::anyhow!("running `cargo install nexus-chat`: {e} (is cargo on PATH?)")
})?;
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numeric_components_beat_string_order() {
assert!(version_gt("0.1.10", "0.1.9"));
assert!(version_gt("1.0.0", "0.9.9"));
assert!(version_gt("0.2.0", "0.1.99"));
}
#[test]
fn missing_components_count_as_zero() {
assert!(version_gt("0.2", "0.1.9"));
assert!(!version_gt("0.1", "0.1.0"));
assert!(version_gt("0.1.1", "0.1"));
}
#[test]
fn equal_versions_are_not_greater() {
assert!(!version_gt("0.1.1", "0.1.1"));
assert!(version_gt("0.1.2", "0.1.1"));
}
#[test]
fn prerelease_extras_compare() {
assert!(version_gt("0.1.2", "0.1.2-alpha.1"));
assert!(version_gt("0.1.2-beta", "0.1.2-alpha"));
}
#[test]
fn dev_build_detection() {
assert!(path_is_dev_build(Path::new(
"/home/u/nexus-chat/target/debug/nexus"
)));
assert!(path_is_dev_build(Path::new(
"/home/u/nexus-chat/target/release/nexus"
)));
assert!(!path_is_dev_build(Path::new("/home/u/.cargo/bin/nexus")));
assert!(!path_is_dev_build(Path::new("/usr/local/bin/nexus")));
}
#[test]
fn marker_freshness_window() {
let now = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_hours(1);
let fresh = now - std::time::Duration::from_mins(5);
assert!(marker_is_fresh(fresh, now));
let stale = now - std::time::Duration::from_mins(31);
assert!(!marker_is_fresh(stale, now));
assert!(marker_is_fresh(
now + std::time::Duration::from_mins(1),
now
));
}
#[test]
fn stale_marker_is_reclaimed_and_fresh_one_blocks() {
let dir = test_dir();
let marker = marker_path(&dir);
std::fs::write(&marker, "0.9.9\n").unwrap();
assert!(marker_in_flight(&marker));
let file = std::fs::OpenOptions::new()
.write(true)
.open(&marker)
.unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_mins(31))
.unwrap();
drop(file);
assert!(!marker_in_flight(&marker));
let _ = std::fs::remove_dir_all(&dir);
}
fn test_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"nexus-update-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn index_parse_takes_last_nonyanked() {
let body = "{\"name\":\"nexus-chat\",\"vers\":\"0.1.0\",\"yanked\":false}\n\
{\"name\":\"nexus-chat\",\"vers\":\"0.1.1\",\"yanked\":true}\n\
{\"name\":\"nexus-chat\",\"vers\":\"0.1.2\",\"yanked\":false}\n";
let lines = body
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| {
!v.get("yanked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.filter_map(|v| {
v.get("vers")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.next_back();
assert_eq!(lines.as_deref(), Some("0.1.2"));
}
}