Skip to main content

dev_prune/commands/
update.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune update`, and the periodic release check behind it.
5//
6// The check is opt-*out*. An out-of-date cleanup tool is a tool whose safety fixes you do
7// not have, so `devp update` asks GitHub for the latest release by default, and `devp
8// run` / `devp status` repeat that quietly at most once a week. Both are switched off by
9// `devp config set update_check false`, and `devp update --offline` skips a single run.
10//
11// What leaves the machine is one unauthenticated GET to the public releases endpoint. It
12// carries no identifier, no configuration, no repository paths and no usage data — the
13// only thing the server learns is that some copy of dev-prune asked what the latest
14// version is. Nothing else in the binary opens a socket. See `docs/PRIVACY.md`.
15//
16// By default the command does not download or install anything: replacing a binary is
17// the package manager's job, and doing it ourselves would mean writing to a PATH
18// directory with whatever privileges the user happened to have. `--install` keeps that
19// division of labour — it works out which package manager owns the running binary and
20// runs *that manager's* own upgrade command, rather than writing files itself. The
21// scheduled pass is never interrupted by an upgrade: it runs the managed copy under
22// `<config>/bin`, which is replaced by atomic rename and refreshed from the new binary
23// on the next healthy run (`setup::stable_exe_path`), so a pass already in flight keeps
24// its loaded image and the next pass picks up the new one.
25
26use std::cmp::Ordering;
27use std::fs;
28use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32use anyhow::{Context, Result};
33use chrono::Utc;
34
35use crate::config::Registry;
36use crate::constants;
37use crate::output;
38
39pub fn run(offline: bool, install: bool) -> Result<()> {
40    if install {
41        return run_install();
42    }
43    output::print_header("dev-prune version & upgrade");
44
45    output::print_info(&format!("Installed version: v{}", constants::VERSION));
46
47    if offline {
48        output::print_info("Skipping the release check because `--offline` was passed.");
49    } else if let Ok(mut registry) = Registry::load() {
50        if registry.settings.update_check {
51            // An explicit `devp update` always asks, regardless of when the last
52            // automatic check ran — the user is standing there waiting for the answer.
53            match refresh_latest(&mut registry) {
54                Ok(latest) => report_comparison(&latest),
55                // A failed check is not a failed command. Someone offline, behind a
56                // proxy, or hitting a rate limit still wants the upgrade instructions.
57                Err(e) => output::print_warning(&format!(
58                    "Could not reach the release API ({e}). The upgrade commands below still apply."
59                )),
60            }
61            let _ = registry.save();
62        } else {
63            output::print_info(
64                "The release check is off (`devp config set update_check true` re-enables it).",
65            );
66        }
67    }
68
69    println!();
70    println!("  Latest releases:  {}", constants::RELEASES_URL);
71    println!();
72    print_upgrade_commands();
73
74    Ok(())
75}
76
77/// Ask GitHub right now — no interval — and say where the installed build stands.
78///
79/// For `devp init`, which is deliberate and infrequent enough to be worth a round trip:
80/// setting a machine up is exactly the moment to learn the binary is a version behind.
81/// `devp run` deliberately does not use this; it goes through [`notify_if_outdated`],
82/// which is interval-gated so everyday work never waits on the network.
83///
84/// Returns `true` when the registry changed and needs saving.
85pub fn check_now(registry: &mut Registry) -> bool {
86    if !registry.settings.update_check {
87        return false;
88    }
89
90    match refresh_latest(registry) {
91        Ok(latest) => {
92            report_comparison(&latest);
93            if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
94                print_upgrade_commands();
95            }
96        }
97        // Not being able to reach GitHub is not a failed `init`.
98        Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
99    }
100    true
101}
102
103/// Every install channel, in one place so they cannot drift apart.
104fn print_upgrade_commands() {
105    println!("  Upgrade with whichever channel you installed from:");
106    println!("    cargo binstall dev-prune --force");
107    println!("    cargo install dev-prune --force");
108    println!("    npm install -g dev-prune@latest");
109    println!("    uv tool upgrade dev-prune  /  pipx upgrade dev-prune");
110    println!("    curl -fsSL {} | sh", constants::INSTALL_SH_URL);
111    println!("    iwr -useb {} | iex", constants::INSTALL_PS1_URL);
112}
113
114/// The package manager that owns the running binary — the one whose upgrade command
115/// `--install` runs. One channel owns one binary: a copy installed through uv is
116/// upgraded through uv, never through npm, because two managers writing the same PATH
117/// entry would fight over it forever.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum Channel {
120    /// `install.sh` / `install.ps1` put it under the managed `<config>/bin`.
121    Installer,
122    /// `cargo install` / `cargo binstall` put it under `~/.cargo/bin`.
123    Cargo,
124    /// `npm install -g` — the binary lives under a `node_modules` tree.
125    Npm,
126    /// `uv tool install` — under uv's tool environments.
127    UvTool,
128    /// `pipx install` — under a `pipx` venv.
129    Pipx,
130    /// Anywhere else: a dev build, a hand-copied binary, a distro package.
131    Unknown,
132}
133
134/// Classify where `exe` came from by the directories in its path.
135///
136/// Purely lexical on purpose: this must not touch the network or spawn anything, and
137/// each channel's layout is stable enough that its marker directory is a reliable
138/// fingerprint. `managed` is passed in (rather than resolved here) so tests can probe
139/// the classification without a config directory on disk.
140fn detect_channel(exe: &std::path::Path, managed: Option<&std::path::Path>) -> Channel {
141    if let Some(managed) = managed
142        && exe == managed
143    {
144        return Channel::Installer;
145    }
146    let has_dir = |name: &str| {
147        exe.components()
148            .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
149    };
150    if has_dir(".cargo") {
151        Channel::Cargo
152    } else if has_dir("node_modules") {
153        Channel::Npm
154    } else if has_dir("uv") || has_dir("uv-tool") {
155        Channel::UvTool
156    } else if has_dir("pipx") {
157        Channel::Pipx
158    } else {
159        Channel::Unknown
160    }
161}
162
163/// `devp update --install`: upgrade this installation to the latest release.
164///
165/// Downloads the release binary from GitHub and replaces the files itself, rather than
166/// asking whichever package manager delivered the first copy to do it. That inversion is
167/// deliberate. There is exactly one binary that matters — the managed copy under
168/// `<config>/bin`, which the git hooks, the scheduler and `PATH` all point at — and it
169/// does not live inside `node_modules`, a uv tool directory or `~/.cargo/bin`. Asking
170/// `uv` to upgrade a file it has never heard of was never going to work, and asking it to
171/// upgrade its *own* copy left the one that actually runs untouched.
172///
173/// So both are replaced: the managed copy first, because that is what runs unattended,
174/// then the running binary if it is a different file, because that is what the user
175/// types. The channel's own bookkeeping (what `uv tool list` believes is installed) is
176/// left stale on purpose — correcting it means running the channel's installer, which is
177/// the one thing this route exists to avoid — and the command to resync it is printed.
178///
179/// Falls back to the channel's own upgrade command when there is no published binary for
180/// this platform or the download fails, so a release-page outage costs the fast path and
181/// not the upgrade.
182fn run_install() -> Result<()> {
183    output::print_header("dev-prune self-update");
184
185    if crate::setup::offline_requested() {
186        anyhow::bail!(
187            "{} is set — an install needs the network by definition.",
188            constants::ENV_OFFLINE
189        );
190    }
191
192    // Know before downloading whether there is anything to download. A failed check is
193    // fatal here (unlike `devp update`): running an installer blind would "upgrade" to
194    // the version already installed.
195    let mut registry = Registry::load()?;
196    let latest = refresh_latest(&mut registry)?;
197    let _ = registry.save();
198    if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
199        output::print_success(&format!(
200            "v{} is already the latest release — nothing to install.",
201            constants::VERSION
202        ));
203        return Ok(());
204    }
205    output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
206
207    let exe = std::env::current_exe().context("could not locate the running binary")?;
208    let managed = crate::setup::managed_exe_path().ok();
209    let channel = detect_channel(&exe, managed.as_deref());
210
211    match install_directly(&latest, &exe, managed.as_deref()) {
212        Ok(()) => {
213            output::print_success(&format!("dev-prune v{latest} installed."));
214            report_channel_bookkeeping(channel);
215            output::print_info(
216                "The scheduled pass was not interrupted: it runs the managed copy, which \
217                 was replaced by atomic rename, so a pass already in flight keeps the \
218                 image it loaded and the next one picks up the new binary.",
219            );
220            return Ok(());
221        }
222        Err(e) => output::print_warning(&format!(
223            "Direct download did not work ({e:#}).\nFalling back to the channel that \
224             installed this copy."
225        )),
226    }
227
228    // On Windows a running executable's file is locked against replacement but not
229    // against rename. Moving it aside first lets the channel write a fresh file at the
230    // real path; the `.old` left behind is swept up by the *next* run, when nothing is
231    // executing it any more.
232    #[cfg(windows)]
233    let aside = {
234        let aside = exe.with_extension("exe.old");
235        let _ = fs::remove_file(&aside);
236        fs::rename(&exe, &aside).ok().map(|_| aside)
237    };
238
239    let result = spawn_channel_upgrade(channel);
240
241    #[cfg(windows)]
242    if let Some(aside) = aside {
243        if result.is_ok() {
244            // Best effort: the file is still our running image, so Windows may refuse
245            // the delete. The sweep at the top of the next `--install` gets it then.
246            let _ = fs::remove_file(&aside);
247        } else if !exe.exists() {
248            // The upgrade never wrote a new binary — put the old one back so the
249            // command the user has on PATH still exists.
250            let _ = fs::rename(&aside, &exe);
251        }
252    }
253    result?;
254
255    output::print_success(&format!("dev-prune v{latest} installed."));
256    output::print_info(
257        "The scheduled pass was not interrupted: it runs the managed copy, which \
258         refreshes itself from the new binary on its next run.",
259    );
260    Ok(())
261}
262
263/// Replace every copy of the binary this installation actually runs, from one download.
264///
265/// The managed copy is done first and is the only one whose failure aborts the upgrade:
266/// it is what the scheduler and the git hooks invoke, so a machine with a fresh managed
267/// copy is upgraded even if nothing else could be written.
268///
269/// Every other path is then written from the same verified bytes, and each is written
270/// with the same rename-aside dance rather than through `ensure_alias`. That matters on
271/// Windows: `ensure_alias` deletes the twin before relinking, and the delete fails when
272/// the twin is the running image — which is exactly the case when the user typed `devp
273/// update --install`. Renaming a running executable is allowed where deleting it is not,
274/// so this route leaves no copy behind on the previous release.
275fn install_directly(latest: &str, exe: &Path, managed: Option<&Path>) -> Result<()> {
276    let bytes = fetch_release_binary(latest)?;
277    let primary = managed.unwrap_or(exe);
278    install_bytes_at(&bytes, primary)?;
279
280    // Every other file that is a copy of the binary just replaced. Left alone they would
281    // keep running the previous release — silently, because the scheduler and the hooks
282    // both discard their own output by design.
283    let mut also: Vec<PathBuf> = Vec::new();
284    if let Some(dir) = primary.parent() {
285        also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
286    }
287    if primary != exe && exe.is_file() {
288        also.push(exe.to_path_buf());
289    }
290    for path in also {
291        if path == primary {
292            continue;
293        }
294        if let Err(e) = install_bytes_at(&bytes, &path) {
295            output::print_warning(&format!(
296                "The managed copy is now v{latest}, but {} could not be replaced ({e:#}).                  Until it is, that copy runs the previous version whenever it is the one                  invoked.",
297                path.display()
298            ));
299        }
300    }
301
302    // The windowless scheduler twin is a *patched* copy, not a plain one, so it is
303    // rebuilt rather than written — from the managed binary that was just replaced.
304    crate::daemon::refresh_hidden_twin();
305    Ok(())
306}
307
308/// Name the channel's own upgrade command after a direct install, for the one thing the
309/// direct route deliberately leaves untouched: the manager's record of what it installed.
310fn report_channel_bookkeeping(channel: Channel) {
311    let resync = match channel {
312        Channel::Cargo => "cargo install dev-prune --force",
313        Channel::Npm => "npm install -g dev-prune@latest",
314        Channel::UvTool => "uv tool upgrade dev-prune",
315        Channel::Pipx => "pipx upgrade dev-prune",
316        // Nothing else keeps a version record to disagree with the binary.
317        Channel::Installer | Channel::Unknown => return,
318    };
319    output::print_info(&format!(
320        "The binaries are up to date. `{resync}` also updates that manager's own record \
321         of the version, which still reads v{}.",
322        constants::VERSION
323    ));
324}
325
326/// Download release `version`'s binary for this platform and put it at `target`.
327///
328/// The direct route, and the reason `devp update --install` no longer depends on the
329/// package manager that happened to deliver the first copy. Whatever installed it, the
330/// binary the hooks, the scheduler and `PATH` all point at is one file in the config
331/// directory, and this replaces that file. `uv`, `npm` and `cargo` are delivery
332/// channels; they are not the source of truth, and asking one of them to upgrade a file
333/// living under another one's directory was never going to work.
334///
335/// Refuses to install anything whose SHA-256 does not match the sidecar published beside
336/// it. That check is the entire safety story for this path: the bytes are about to
337/// become the binary the machine runs on a schedule.
338fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
339    let asset = constants::release_asset_name(version).with_context(|| {
340        format!(
341            "no published binary for {}-{}; upgrade through the channel that installed \
342             this copy instead",
343            std::env::consts::OS,
344            std::env::consts::ARCH
345        )
346    })?;
347    let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
348
349    let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
350    output::print_info(&format!("Downloading {asset} …"));
351    let bytes = fetch_bytes(&base)?;
352
353    let actual = {
354        use sha2::{Digest, Sha256};
355        use std::fmt::Write as _;
356        let mut h = Sha256::new();
357        h.update(&bytes);
358        // Hex-encoded by hand: sha2 0.11 returns a `hybrid_array::Array`, which has no
359        // `LowerHex`, and the sidecar is lower-case hex either way.
360        h.finalize().iter().fold(String::new(), |mut s, b| {
361            let _ = write!(s, "{b:02x}");
362            s
363        })
364    };
365    if actual != expected {
366        anyhow::bail!(
367            "checksum mismatch for {asset}\n  expected {expected}\n  got      {actual}\n\
368             The download was corrupted or tampered with; nothing was installed."
369        );
370    }
371
372    Ok(bytes)
373}
374
375/// Write already-verified bytes over one binary.
376///
377/// Separate from the download so a single transfer can serve every copy that has to be
378/// replaced — the managed binary, its `devp` twin, and whatever the user is running —
379/// instead of fetching the same megabytes once per path.
380fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
381    // Staged beside the target and renamed in, so a write that dies half-way leaves the
382    // working binary untouched rather than a truncated file where the scheduler expects
383    // an executable.
384    let staging = target.with_extension("new");
385    if let Some(parent) = target.parent() {
386        fs::create_dir_all(parent).ok();
387    }
388    fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
389
390    #[cfg(unix)]
391    {
392        use std::os::unix::fs::PermissionsExt;
393        // Downloaded files are 0644; the scheduler needs to be able to run this.
394        let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
395    }
396
397    replace_binary(&staging, target)
398}
399
400/// Read the hash out of a `.sha256` sidecar published beside a release asset.
401fn fetch_expected_hash(url: &str) -> Result<String> {
402    let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
403    parse_sha256_sidecar(&body)
404}
405
406/// The parsing half of [`fetch_expected_hash`], which is `sha256sum` format: the hex
407/// digest, two spaces, the file name.
408///
409/// Validated rather than trusted, because the failure this guards against is not a
410/// malformed checksum — it is a 404 page or a proxy error blob arriving where the sidecar
411/// should be. Comparing a digest against `<!DOCTYPE html>` would report a checksum
412/// mismatch, which reads as "someone tampered with the download" and sends the user
413/// somewhere alarming and wrong.
414fn parse_sha256_sidecar(body: &str) -> Result<String> {
415    let hash = body
416        .split_whitespace()
417        .next()
418        .context("the checksum sidecar was empty")?
419        .to_ascii_lowercase();
420    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
421        anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
422    }
423    Ok(hash)
424}
425
426fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
427    let mut body = ureq::get(url)
428        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
429        .config()
430        .timeout_global(Some(Duration::from_secs(
431            constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
432        )))
433        .build()
434        .call()
435        .with_context(|| format!("could not download {url}"))?;
436    let mut buf = Vec::new();
437    body.body_mut()
438        .as_reader()
439        .read_to_end(&mut buf)
440        .with_context(|| format!("could not read {url}"))?;
441    Ok(buf)
442}
443
444/// Move `staged` onto `target`, working around the one platform that will not overwrite
445/// a file it is executing.
446fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
447    // On Windows a running image is locked against replacement but not against rename,
448    // so the live file steps aside and the new one takes its name. The `.old` is swept
449    // by the next run, when nothing holds it open any more.
450    #[cfg(windows)]
451    let aside = {
452        let aside = target.with_extension("exe.old");
453        let _ = fs::remove_file(&aside);
454        target
455            .exists()
456            .then(|| fs::rename(target, &aside).ok().map(|_| aside))
457            .flatten()
458    };
459
460    match fs::rename(staged, target) {
461        Ok(()) => {
462            #[cfg(windows)]
463            if let Some(aside) = aside {
464                let _ = fs::remove_file(&aside);
465            }
466            Ok(())
467        }
468        Err(e) => {
469            let _ = fs::remove_file(staged);
470            #[cfg(windows)]
471            if let Some(aside) = aside
472                && !target.exists()
473            {
474                // Put the working binary back rather than leaving the machine with no
475                // `dev-prune` at all.
476                let _ = fs::rename(&aside, target);
477            }
478            Err(e).with_context(|| format!("could not install {}", target.display()))
479        }
480    }
481}
482
483/// Run one channel's own upgrade command, wired to the terminal so its progress and
484/// prompts reach the user directly.
485fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
486    let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
487    let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
488    let argv: Vec<&str> = match channel {
489        Channel::Cargo => {
490            // binstall pulls the prebuilt release; plain `cargo install` compiles for
491            // minutes. Prefer the fast one when it exists.
492            if crate::adapters::binary_available("cargo-binstall") {
493                vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
494            } else {
495                vec!["cargo", "install", "dev-prune", "--force"]
496            }
497        }
498        Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
499        Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
500        Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
501        Channel::Installer => {
502            if cfg!(windows) {
503                vec!["powershell", "-NoProfile", "-Command", &install_ps1]
504            } else {
505                vec!["sh", "-c", &install_sh]
506            }
507        }
508        Channel::Unknown => {
509            output::print_warning(
510                "Could not tell which channel installed this binary, so nothing was \
511                 changed. Upgrade it yourself with one of:",
512            );
513            print_upgrade_commands();
514            anyhow::bail!("unrecognised install channel");
515        }
516    };
517
518    output::print_info(&format!("Running: {}", argv.join(" ")));
519    let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
520        .args(&argv[1..])
521        .status()
522        .with_context(|| format!("could not start `{}`", argv[0]))?;
523    if !status.success() {
524        anyhow::bail!("`{}` exited with {status}", argv.join(" "));
525    }
526    Ok(())
527}
528
529/// The end-of-run hook behind `auto_update`: when the setting is on and the last release
530/// check already knows a newer version exists, run the self-update without being asked.
531///
532/// Warn-never-fail, like everything else that runs as a side effect of `devp run` — a
533/// broken upgrade path must not turn a successful prune into a failed command.
534pub fn maybe_auto_update(registry: &Registry) {
535    if !registry.settings.auto_update
536        || crate::setup::offline_requested()
537        || crate::setup::no_auto_setup_requested()
538    {
539        return;
540    }
541    let Some(latest) = registry.latest_known_version.as_deref() else {
542        return;
543    };
544    if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
545        return;
546    }
547    println!();
548    if let Err(e) = run_install() {
549        output::print_warning(&format!(
550            "Automatic update failed ({e}). Run `devp update --install` yourself, or \
551             `devp config set auto_update false` to stop trying."
552        ));
553    }
554}
555
556/// Quietly keep the release check current and print a one-line notice when the installed
557/// build is behind. Returns `true` when the registry changed and needs saving.
558///
559/// Called from `devp run` and `devp status`. Never returns an error: a background
560/// convenience must not be able to fail the command the user actually asked for.
561pub fn notify_if_outdated(registry: &mut Registry) -> bool {
562    if !registry.settings.update_check {
563        return false;
564    }
565
566    let interval = registry.settings.update_check_interval_days;
567    let due = registry
568        .last_update_check
569        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
570
571    if due {
572        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
573        // when the request fails, and retrying on every command while the machine is
574        // offline would put a five-second stall in front of everyday work.
575        let _ = refresh_latest(registry);
576    }
577
578    if let Some(latest) = registry.latest_known_version.as_deref()
579        && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
580    {
581        output::print_info(&format!(
582            "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
583                 `devp config set update_check false` silences this.",
584            constants::VERSION
585        ));
586    }
587
588    due
589}
590
591/// Ask GitHub for the latest release and record the answer on the registry.
592///
593/// The caller is responsible for saving; that keeps this usable from both the
594/// already-loaded-registry path and the standalone command.
595fn refresh_latest(registry: &mut Registry) -> Result<String> {
596    let result = latest_release(registry.settings.update_check_timeout_secs);
597    registry.last_update_check = Some(Utc::now());
598    let latest = result?;
599    registry.latest_known_version = Some(latest.clone());
600    Ok(latest)
601}
602
603/// Say whether the installed build is behind, current, or ahead of the latest release.
604fn report_comparison(latest: &str) {
605    let installed = constants::VERSION;
606    match compare_versions(installed, latest) {
607        Some(Ordering::Less) => {
608            output::print_warning(&format!(
609                "Latest release:    v{latest} — an upgrade is available."
610            ));
611        }
612        Some(Ordering::Equal) => {
613            output::print_success(&format!(
614                "Latest release:    v{latest} — you are up to date."
615            ));
616        }
617        Some(Ordering::Greater) => {
618            // Normal when running a local build between releases.
619            output::print_info(&format!(
620                "Latest release:    v{latest} — your build is newer than the last published one."
621            ));
622        }
623        None => {
624            output::print_info(&format!(
625                "Latest release:    v{latest} (could not compare it to v{installed})."
626            ));
627        }
628    }
629}
630
631/// Fetch the tag name of the most recent published release.
632///
633/// Returns the version without any leading `v`, so it can be compared to
634/// `CARGO_PKG_VERSION` directly.
635fn latest_release(timeout_secs: u64) -> Result<String> {
636    if crate::setup::offline_requested() {
637        anyhow::bail!("{} is set", constants::ENV_OFFLINE);
638    }
639    let body = ureq::get(constants::LATEST_RELEASE_API_URL)
640        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
641        .header("Accept", "application/vnd.github+json")
642        .config()
643        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
644        .build()
645        .call()
646        .context("request failed")?
647        .body_mut()
648        .read_to_string()
649        .context("could not read the response")?;
650
651    let json: serde_json::Value =
652        serde_json::from_str(&body).context("the response was not JSON")?;
653    let tag = json
654        .get("tag_name")
655        .and_then(|v| v.as_str())
656        .context("the response carried no tag_name")?;
657
658    Ok(tag.trim_start_matches('v').to_string())
659}
660
661/// Compare two dotted numeric versions, ignoring any pre-release suffix.
662///
663/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
664/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
665/// as a string.
666pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
667    let parse = |v: &str| -> Option<[u64; 3]> {
668        let core = v.split(['-', '+']).next()?;
669        let mut parts = core.split('.');
670        let out = [
671            parts.next()?.parse().ok()?,
672            parts.next()?.parse().ok()?,
673            parts.next()?.parse().ok()?,
674        ];
675        // A fourth component means this is not the scheme we release under.
676        if parts.next().is_some() {
677            return None;
678        }
679        Some(out)
680    };
681    Some(parse(a)?.cmp(&parse(b)?))
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use chrono::Duration as ChronoDuration;
688
689    #[test]
690    fn orders_by_component_not_lexically() {
691        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
692        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
693        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
694        assert_eq!(
695            compare_versions("2.0.0", "1.99.99"),
696            Some(Ordering::Greater)
697        );
698    }
699
700    #[test]
701    fn pre_release_suffixes_compare_by_their_core() {
702        assert_eq!(
703            compare_versions("1.0.0", "1.0.0-rc.1"),
704            Some(Ordering::Equal)
705        );
706        assert_eq!(
707            compare_versions("1.0.0+build7", "1.0.1"),
708            Some(Ordering::Less)
709        );
710    }
711
712    #[test]
713    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
714        assert_eq!(compare_versions("1.0", "1.0.0"), None);
715        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
716        assert_eq!(compare_versions("nightly", "1.0.0"), None);
717    }
718
719    #[test]
720    fn the_check_is_on_unless_the_user_turns_it_off() {
721        assert!(Registry::default().settings.update_check);
722    }
723
724    #[test]
725    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
726        let mut registry = Registry::default();
727        registry.settings.update_check = false;
728        assert!(!notify_if_outdated(&mut registry));
729        assert!(registry.last_update_check.is_none());
730    }
731
732    #[test]
733    fn each_channel_is_recognised_by_its_marker_directory() {
734        use std::path::Path;
735        let cases: &[(&str, Channel)] = &[
736            ("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
737            (
738                "/usr/lib/node_modules/dev-prune/bin/dev-prune",
739                Channel::Npm,
740            ),
741            (
742                "/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
743                Channel::UvTool,
744            ),
745            (
746                "/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
747                Channel::Pipx,
748            ),
749            ("/opt/somewhere/dev-prune", Channel::Unknown),
750        ];
751        for (path, expected) in cases {
752            assert_eq!(detect_channel(Path::new(path), None), *expected, "{path}");
753        }
754    }
755
756    #[test]
757    fn the_managed_copy_wins_over_every_path_heuristic() {
758        use std::path::Path;
759        // Even a managed dir that happens to live under `.cargo` is the installer's.
760        let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
761        assert_eq!(detect_channel(managed, Some(managed)), Channel::Installer);
762    }
763
764    #[test]
765    fn auto_update_is_off_by_default_and_silent_when_off() {
766        let registry = Registry::default();
767        assert!(!registry.settings.auto_update);
768        // Must return without touching the network or the terminal.
769        maybe_auto_update(&registry);
770    }
771
772    #[test]
773    fn a_recent_check_is_not_repeated() {
774        let mut registry = Registry::default();
775        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
776        registry.last_update_check = Some(stamp);
777        // No network call, so the stamp survives untouched and nothing needs saving.
778        assert!(!notify_if_outdated(&mut registry));
779        assert_eq!(registry.last_update_check, Some(stamp));
780    }
781
782    #[test]
783    fn the_asset_name_matches_what_the_release_workflow_builds() {
784        // This string is a contract with `.github/workflows/release.yml`. Getting it
785        // wrong is not a compile error and not a test failure anywhere else — it is a
786        // self-update that 404s for every user on the day of a release.
787        let name = constants::release_asset_name("1.4.0");
788        let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
789            ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
790            ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
791            ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
792            ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
793            ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
794            ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
795            ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
796            // A platform the release does not build for must decline the direct route
797            // rather than download some other platform's binary.
798            _ => None,
799        };
800        assert_eq!(name.as_deref(), expected);
801    }
802
803    #[test]
804    fn only_windows_has_a_32_bit_asset() {
805        // The matrix builds `x86` for Windows alone. On a 32-bit Linux there is nothing
806        // to download, and guessing `x64` would install a binary that cannot run.
807        let name = constants::release_asset_name("9.9.9");
808        if std::env::consts::ARCH == "x86" {
809            assert_eq!(name.is_some(), std::env::consts::OS == "windows");
810        }
811    }
812
813    #[test]
814    fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
815        let digest = "a".repeat(64);
816        assert_eq!(
817            parse_sha256_sidecar(&format!("{digest}  dev-prune-v1.4.0-linux-x64\n")).unwrap(),
818            digest
819        );
820        // The Windows step writes it with no trailing newline, and GitHub may serve
821        // either line ending.
822        assert_eq!(
823            parse_sha256_sidecar(&format!("{digest}  asset.exe")).unwrap(),
824            digest
825        );
826        assert_eq!(
827            parse_sha256_sidecar(&format!("{}  asset\r\n", digest.to_uppercase())).unwrap(),
828            digest,
829            "an upper-case digest must compare equal to the one we compute"
830        );
831    }
832
833    #[test]
834    fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
835        // A 404 page, an error blob or a truncated read must fail as "not a digest"
836        // rather than as a mismatch — the two send the user to very different places.
837        for bad in [
838            "",
839            "   ",
840            "<!DOCTYPE html>",
841            "not-a-hash  asset",
842            &"a".repeat(63),
843            &"a".repeat(65),
844            &format!("{}g  asset", "a".repeat(63)),
845        ] {
846            assert!(
847                parse_sha256_sidecar(bad).is_err(),
848                "{bad:?} must not be accepted as a digest"
849            );
850        }
851    }
852}