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