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