Skip to main content

nexus_core/
update.rs

1//! Startup update check + auto-update: compare the running version against
2//! the latest release on crates.io and, when a newer one exists, install it
3//! via `cargo install` in a detached background process so the TUI boots
4//! immediately and the new binary is live on the next launch. Best-effort
5//! by design — any failure (offline, index hiccup, missing cargo) is
6//! silent or falls back to a plain notice; the app never blocks or fails
7//! startup on it. `NEXUS_NO_UPDATE=1` opts out of the auto-install.
8
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12/// The version this binary was built from (Cargo.toml at compile time).
13pub const CURRENT: &str = env!("CARGO_PKG_VERSION");
14
15/// How long an in-flight auto-update marker stays valid. A cold `cargo
16/// install` of this crate's dependency tree takes minutes; anything older
17/// than this is assumed finished (or dead) and may be retried.
18const MARKER_STALE_AFTER: std::time::Duration = std::time::Duration::from_mins(30);
19
20/// The crates.io sparse index doc for `nexus-chat` — one small NDJSON line
21/// per published version, newest last, no auth, no API quota.
22const SPARSE_INDEX: &str = "https://index.crates.io/ne/xu/nexus-chat";
23
24/// Fetch the newest non-yanked published version, or `None` on any failure
25/// (offline, timeout, malformed index). Runs in a background task — the UI
26/// is never blocked on this.
27pub 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
52/// `a > b` for the dotted versions crates.io publishes (`0.1.1`, `1.2.3`,
53/// `0.1.2-alpha.1`). Numeric components compare numerically (`0.1.10` >
54/// `0.1.9` — string order would get that wrong), missing components count
55/// as 0, and pre-release extras compare alphabetically after the numbers.
56pub 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    // Same numeric core: a release (no pre-release suffix) beats any
68    // pre-release of it ("0.1.2" > "0.1.2-alpha.1").
69    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
79/// Dotted numeric core: missing components count as 0.
80fn 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
97/// Pre-release identifiers ("alpha.1", "beta"): numeric when both are,
98/// otherwise byte order — close enough to semver for update notices.
99fn 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
116// --- auto-update via `cargo install` ---
117
118/// Marker file recording an in-flight auto-update (target version). Written
119/// before `cargo install` is spawned and left in place — a fresh marker
120/// blocks a second install; a stale one is reclaimed by the next launch.
121fn marker_path(data_dir: &Path) -> PathBuf {
122    data_dir.join("auto-update.marker")
123}
124
125/// Where the detached installer's output goes — `cargo install` runs
126/// outside the TUI, so its progress is only visible here.
127fn log_path(data_dir: &Path) -> PathBuf {
128    data_dir.join("auto-update.log")
129}
130
131/// Is `path` a binary built by `cargo run`/`cargo build` (inside a target
132/// dir)? Auto-update skips those — a dev build must not silently replace
133/// itself with the registry release.
134fn 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
139/// Is the running binary a dev build? Determined from the executable's own
140/// path, so `cargo run` and `cargo build` artifacts never self-update.
141fn is_dev_build() -> bool {
142    std::env::current_exe().is_ok_and(|p| path_is_dev_build(&p))
143}
144
145/// Is `cargo` on PATH? Auto-update is only possible when it is.
146fn cargo_available() -> bool {
147    std::process::Command::new("cargo")
148        .arg("--version")
149        .output()
150        .is_ok()
151}
152
153/// A marker written less than [`MARKER_STALE_AFTER`] ago means an install
154/// is (or was, minutes ago) running; a missing or stale marker means the
155/// slot is free.
156fn 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
166/// Freshness window for an update marker: `modified` within
167/// [`MARKER_STALE_AFTER`] of `now`. Future timestamps (clock skew — the
168/// marker was just written) count as fresh.
169fn 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/// What [`try_start_auto_update`] decided.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum AutoUpdateOutcome {
179    /// `cargo install` was spawned in the background; a restart applies it.
180    Started,
181    /// A previous launch is already installing — don't start a second one.
182    InFlight,
183    /// Auto-update isn't possible here (dev build, no cargo, opted out);
184    /// the caller should fall back to telling the user the manual command.
185    Unavailable,
186}
187
188/// Start an automatic update to `latest` when the environment allows it:
189/// the running binary must be a real install (not a dev build), `cargo`
190/// must be on PATH, `NEXUS_NO_UPDATE` must be unset, and no other
191/// auto-update may be in flight (marker guard). Spawns
192/// `cargo install --force nexus-chat` fully detached — the caller returns
193/// immediately and the install runs to completion (or failure, logged to
194/// `auto-update.log` next to the marker) on its own, outliving this
195/// process. The new binary is live on the next launch; the running one is
196/// untouched (Unix keeps the old inode alive).
197pub 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    // Reclaim a stale marker from a dead install, then claim the slot
206    // before spawning so a concurrent launch can't double-install.
207    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    // Detached: progress goes to the log, never to the TUI's terminal.
214    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        // Spawn failed (cargo vanished between check and spawn): free the
237        // slot so the next launch can retry.
238        let _ = std::fs::remove_file(&marker);
239        return AutoUpdateOutcome::Unavailable;
240    }
241    AutoUpdateOutcome::Started
242}
243
244/// Run `cargo install --force nexus-chat` in the foreground, streaming its
245/// output to the terminal — the deliberate `nexus update` path. Returns
246/// the exit status; the caller decides how to report it.
247pub 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        // A base time comfortably after the epoch so both directions of the
302        // window are representable (SystemTime can't go below the epoch).
303        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        // Clock skew: a future mtime counts as fresh (just written).
309        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        // Fresh marker → in flight.
320        std::fs::write(&marker, "0.9.9\n").unwrap();
321        assert!(marker_in_flight(&marker));
322        // Age it past the window → not in flight (the next launch reclaims it).
323        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    /// A throwaway temp dir unique per test run (tests run in parallel).
335    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}