1use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
14
15const MARKER_STALE_AFTER: std::time::Duration = std::time::Duration::from_mins(30);
19
20const SPARSE_INDEX: &str = "https://index.crates.io/ne/xu/nexus-chat";
23
24pub async fn latest_version() -> Option<String> {
28 let body = reqwest::Client::new()
29 .get(SPARSE_INDEX)
30 .timeout(std::time::Duration::from_secs(4))
31 .send()
32 .await
33 .ok()?
34 .text()
35 .await
36 .ok()?;
37 body.lines()
38 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
39 .filter(|v| {
40 !v.get("yanked")
41 .and_then(serde_json::Value::as_bool)
42 .unwrap_or(false)
43 })
44 .filter_map(|v| {
45 v.get("vers")
46 .and_then(serde_json::Value::as_str)
47 .map(str::to_string)
48 })
49 .next_back()
50}
51
52pub fn version_gt(a: &str, b: &str) -> bool {
57 compare(a, b).is_gt()
58}
59
60fn compare(a: &str, b: &str) -> std::cmp::Ordering {
61 let core_a = a.split('-').next().unwrap_or(a);
62 let core_b = b.split('-').next().unwrap_or(b);
63 let core = compare_core(core_a, core_b);
64 if core != std::cmp::Ordering::Equal {
65 return core;
66 }
67 let pre_a = a.strip_prefix(core_a).and_then(|s| s.strip_prefix('-'));
70 let pre_b = b.strip_prefix(core_b).and_then(|s| s.strip_prefix('-'));
71 match (pre_a, pre_b) {
72 (None, None) => std::cmp::Ordering::Equal,
73 (None, Some(_)) => std::cmp::Ordering::Greater,
74 (Some(_), None) => std::cmp::Ordering::Less,
75 (Some(x), Some(y)) => compare_pre(x, y),
76 }
77}
78
79fn compare_core(a: &str, b: &str) -> std::cmp::Ordering {
81 let pa: Vec<&str> = a.split('.').collect();
82 let pb: Vec<&str> = b.split('.').collect();
83 for i in 0..pa.len().max(pb.len()) {
84 let x = pa.get(i).copied().unwrap_or("0");
85 let y = pb.get(i).copied().unwrap_or("0");
86 let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
87 (Ok(xn), Ok(yn)) => xn.cmp(&yn),
88 _ => x.cmp(y),
89 };
90 if ord != std::cmp::Ordering::Equal {
91 return ord;
92 }
93 }
94 std::cmp::Ordering::Equal
95}
96
97fn compare_pre(a: &str, b: &str) -> std::cmp::Ordering {
100 let pa: Vec<&str> = a.split('.').collect();
101 let pb: Vec<&str> = b.split('.').collect();
102 for i in 0..pa.len().max(pb.len()) {
103 let x = pa.get(i).copied().unwrap_or("0");
104 let y = pb.get(i).copied().unwrap_or("0");
105 let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
106 (Ok(xn), Ok(yn)) => xn.cmp(&yn),
107 _ => x.cmp(y),
108 };
109 if ord != std::cmp::Ordering::Equal {
110 return ord;
111 }
112 }
113 std::cmp::Ordering::Equal
114}
115
116fn marker_path(data_dir: &Path) -> PathBuf {
122 data_dir.join("auto-update.marker")
123}
124
125fn log_path(data_dir: &Path) -> PathBuf {
128 data_dir.join("auto-update.log")
129}
130
131fn path_is_dev_build(path: &Path) -> bool {
135 let s = path.to_string_lossy();
136 s.contains("/target/debug/") || s.contains("/target/release/")
137}
138
139fn is_dev_build() -> bool {
142 std::env::current_exe().is_ok_and(|p| path_is_dev_build(&p))
143}
144
145fn cargo_available() -> bool {
147 std::process::Command::new("cargo")
148 .arg("--version")
149 .output()
150 .is_ok()
151}
152
153fn marker_in_flight(marker: &Path) -> bool {
157 let Ok(meta) = std::fs::metadata(marker) else {
158 return false;
159 };
160 let Ok(modified) = meta.modified() else {
161 return false;
162 };
163 marker_is_fresh(modified, std::time::SystemTime::now())
164}
165
166fn marker_is_fresh(modified: std::time::SystemTime, now: std::time::SystemTime) -> bool {
170 match now.duration_since(modified) {
171 Ok(age) => age < MARKER_STALE_AFTER,
172 Err(_) => true,
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum AutoUpdateOutcome {
179 Started,
181 InFlight,
183 Unavailable,
186}
187
188pub fn try_start_auto_update(data_dir: &Path, latest: &str) -> AutoUpdateOutcome {
198 if std::env::var_os("NEXUS_NO_UPDATE").is_some() || is_dev_build() || !cargo_available() {
199 return AutoUpdateOutcome::Unavailable;
200 }
201 let marker = marker_path(data_dir);
202 if marker_in_flight(&marker) {
203 return AutoUpdateOutcome::InFlight;
204 }
205 let _ = std::fs::remove_file(&marker);
208 if std::fs::write(&marker, format!("{latest}\n")).is_err() {
209 return AutoUpdateOutcome::Unavailable;
210 }
211 let mut cmd = std::process::Command::new("cargo");
212 cmd.args(["install", "--force", "nexus-chat"]);
213 if let Ok(log) = std::fs::OpenOptions::new()
215 .create(true)
216 .append(true)
217 .open(log_path(data_dir))
218 {
219 let _ = writeln!(
220 &log,
221 "--- auto-update to v{latest} started at {} ---",
222 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
223 );
224 if let Ok(out) = log.try_clone() {
225 cmd.stdout(out);
226 cmd.stderr(log);
227 } else {
228 cmd.stdout(std::process::Stdio::null());
229 cmd.stderr(std::process::Stdio::null());
230 }
231 } else {
232 cmd.stdout(std::process::Stdio::null());
233 cmd.stderr(std::process::Stdio::null());
234 }
235 if cmd.spawn().is_err() {
236 let _ = std::fs::remove_file(&marker);
239 return AutoUpdateOutcome::Unavailable;
240 }
241 AutoUpdateOutcome::Started
242}
243
244pub fn install_now() -> anyhow::Result<std::process::ExitStatus> {
248 let status = std::process::Command::new("cargo")
249 .args(["install", "--force", "nexus-chat"])
250 .status()
251 .map_err(|e| {
252 anyhow::anyhow!("running `cargo install nexus-chat`: {e} (is cargo on PATH?)")
253 })?;
254 Ok(status)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn numeric_components_beat_string_order() {
263 assert!(version_gt("0.1.10", "0.1.9"));
264 assert!(version_gt("1.0.0", "0.9.9"));
265 assert!(version_gt("0.2.0", "0.1.99"));
266 }
267
268 #[test]
269 fn missing_components_count_as_zero() {
270 assert!(version_gt("0.2", "0.1.9"));
271 assert!(!version_gt("0.1", "0.1.0"));
272 assert!(version_gt("0.1.1", "0.1"));
273 }
274
275 #[test]
276 fn equal_versions_are_not_greater() {
277 assert!(!version_gt("0.1.1", "0.1.1"));
278 assert!(version_gt("0.1.2", "0.1.1"));
279 }
280
281 #[test]
282 fn prerelease_extras_compare() {
283 assert!(version_gt("0.1.2", "0.1.2-alpha.1"));
284 assert!(version_gt("0.1.2-beta", "0.1.2-alpha"));
285 }
286
287 #[test]
288 fn dev_build_detection() {
289 assert!(path_is_dev_build(Path::new(
290 "/home/u/nexus-chat/target/debug/nexus"
291 )));
292 assert!(path_is_dev_build(Path::new(
293 "/home/u/nexus-chat/target/release/nexus"
294 )));
295 assert!(!path_is_dev_build(Path::new("/home/u/.cargo/bin/nexus")));
296 assert!(!path_is_dev_build(Path::new("/usr/local/bin/nexus")));
297 }
298
299 #[test]
300 fn marker_freshness_window() {
301 let now = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_hours(1);
304 let fresh = now - std::time::Duration::from_mins(5);
305 assert!(marker_is_fresh(fresh, now));
306 let stale = now - std::time::Duration::from_mins(31);
307 assert!(!marker_is_fresh(stale, now));
308 assert!(marker_is_fresh(
310 now + std::time::Duration::from_mins(1),
311 now
312 ));
313 }
314
315 #[test]
316 fn stale_marker_is_reclaimed_and_fresh_one_blocks() {
317 let dir = test_dir();
318 let marker = marker_path(&dir);
319 std::fs::write(&marker, "0.9.9\n").unwrap();
321 assert!(marker_in_flight(&marker));
322 let file = std::fs::OpenOptions::new()
324 .write(true)
325 .open(&marker)
326 .unwrap();
327 file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_mins(31))
328 .unwrap();
329 drop(file);
330 assert!(!marker_in_flight(&marker));
331 let _ = std::fs::remove_dir_all(&dir);
332 }
333
334 fn test_dir() -> PathBuf {
336 let dir = std::env::temp_dir().join(format!(
337 "nexus-update-test-{}-{}",
338 std::process::id(),
339 std::time::SystemTime::now()
340 .duration_since(std::time::SystemTime::UNIX_EPOCH)
341 .unwrap()
342 .as_nanos()
343 ));
344 std::fs::create_dir_all(&dir).unwrap();
345 dir
346 }
347
348 #[test]
349 fn index_parse_takes_last_nonyanked() {
350 let body = "{\"name\":\"nexus-chat\",\"vers\":\"0.1.0\",\"yanked\":false}\n\
351 {\"name\":\"nexus-chat\",\"vers\":\"0.1.1\",\"yanked\":true}\n\
352 {\"name\":\"nexus-chat\",\"vers\":\"0.1.2\",\"yanked\":false}\n";
353 let lines = body
354 .lines()
355 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
356 .filter(|v| {
357 !v.get("yanked")
358 .and_then(serde_json::Value::as_bool)
359 .unwrap_or(false)
360 })
361 .filter_map(|v| {
362 v.get("vers")
363 .and_then(serde_json::Value::as_str)
364 .map(str::to_string)
365 })
366 .next_back();
367 assert_eq!(lines.as_deref(), Some("0.1.2"));
368 }
369}