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    // Every other file that is a copy of the binary just replaced. Left alone they would
351    // keep running the previous release — silently, because the scheduler and the hooks
352    // both discard their own output by design.
353    let mut also: Vec<PathBuf> = Vec::new();
354    if let Some(dir) = primary.parent() {
355        also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
356    }
357    // …except when a package manager owns the directory the running copy sits in and
358    // replaces that directory wholesale on upgrade. Writing new bytes there leaves WinGet,
359    // Scoop or Homebrew certain they still have the old version installed, and the next
360    // `winget upgrade` puts the old binary back over the top. Their copy is left exactly
361    // as the manager wrote it; `report_channel_bookkeeping` names the command that
362    // actually moves it forward.
363    if primary != exe && exe.is_file() && !channel.replaces_its_directory() {
364        also.push(exe.to_path_buf());
365    }
366    for path in also {
367        if path == primary {
368            continue;
369        }
370        if let Err(e) = install_bytes_at(&bytes, &path) {
371            output::print_warning(&format!(
372                "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
373                 is, that copy runs the previous version whenever it is the one invoked.",
374                path.display()
375            ));
376        }
377    }
378
379    // The windowless scheduler twin is a *patched* copy, not a plain one, so it is
380    // rebuilt rather than written — from the managed binary that was just replaced.
381    crate::daemon::refresh_hidden_twin();
382
383    // The receipt beside the managed copy now names the version that was there a minute
384    // ago. Only ever updated, never created: this path also upgrades a managed copy some
385    // other manager installed, and writing a fresh receipt there would claim one of our
386    // installers ran when none did.
387    crate::receipt::refresh_after_upgrade(latest);
388    Ok(())
389}
390
391/// Name the channel's own upgrade command after a direct install, for the one thing the
392/// direct route deliberately leaves untouched: the manager's record of what it installed.
393fn report_channel_bookkeeping(channel: Channel) {
394    // The installer's copy *is* the managed one, and an unrecognised copy has no manager
395    // keeping a version record that could disagree with the binary.
396    let Some(resync) = channel
397        .owns_its_files()
398        .then(|| channel.upgrade_command())
399        .flatten()
400    else {
401        return;
402    };
403    if channel.replaces_its_directory() {
404        output::print_info(&format!(
405            "The managed copy is now v{}. The copy {} installed was left exactly as it \
406             wrote it — replacing a file inside a versioned package directory only makes \
407             the manager and the disk disagree. Run `{resync}` to move that one forward \
408             too.",
409            constants::VERSION,
410            channel.label()
411        ));
412    } else {
413        output::print_info(&format!(
414            "The binaries are up to date. `{resync}` also updates that manager's own \
415             record of the version, which still reads v{}.",
416            constants::VERSION
417        ));
418    }
419}
420
421/// Download release `version`'s binary for this platform and put it at `target`.
422///
423/// The direct route, and the reason `devp update --install` no longer depends on the
424/// package manager that happened to deliver the first copy. Whatever installed it, the
425/// binary the hooks, the scheduler and `PATH` all point at is one file in the config
426/// directory, and this replaces that file. `uv`, `npm` and `cargo` are delivery
427/// channels; they are not the source of truth, and asking one of them to upgrade a file
428/// living under another one's directory was never going to work.
429///
430/// Refuses to install anything whose SHA-256 does not match the sidecar published beside
431/// it. That check is the entire safety story for this path: the bytes are about to
432/// become the binary the machine runs on a schedule.
433fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
434    let asset = constants::release_asset_name(version).with_context(|| {
435        format!(
436            "no published binary for {}-{}; upgrade through the channel that installed \
437             this copy instead",
438            std::env::consts::OS,
439            std::env::consts::ARCH
440        )
441    })?;
442    let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
443
444    let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
445    output::print_info(&format!("Downloading {asset} …"));
446    let bytes = fetch_bytes(&base)?;
447
448    let actual = {
449        use sha2::{Digest, Sha256};
450        use std::fmt::Write as _;
451        let mut h = Sha256::new();
452        h.update(&bytes);
453        // Hex-encoded by hand: sha2 0.11 returns a `hybrid_array::Array`, which has no
454        // `LowerHex`, and the sidecar is lower-case hex either way.
455        h.finalize().iter().fold(String::new(), |mut s, b| {
456            let _ = write!(s, "{b:02x}");
457            s
458        })
459    };
460    if actual != expected {
461        anyhow::bail!(
462            "checksum mismatch for {asset}\n  expected {expected}\n  got      {actual}\n\
463             The download was corrupted or tampered with; nothing was installed."
464        );
465    }
466
467    Ok(bytes)
468}
469
470/// Write already-verified bytes over one binary.
471///
472/// Separate from the download so a single transfer can serve every copy that has to be
473/// replaced — the managed binary, its `devp` twin, and whatever the user is running —
474/// instead of fetching the same megabytes once per path.
475fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
476    // Staged beside the target and renamed in, so a write that dies half-way leaves the
477    // working binary untouched rather than a truncated file where the scheduler expects
478    // an executable.
479    let staging = target.with_extension("new");
480    if let Some(parent) = target.parent() {
481        fs::create_dir_all(parent).ok();
482    }
483    fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
484
485    #[cfg(unix)]
486    {
487        use std::os::unix::fs::PermissionsExt;
488        // Downloaded files are 0644; the scheduler needs to be able to run this.
489        let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
490    }
491
492    replace_binary(&staging, target)
493}
494
495/// Read the hash out of a `.sha256` sidecar published beside a release asset.
496fn fetch_expected_hash(url: &str) -> Result<String> {
497    let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
498    parse_sha256_sidecar(&body)
499}
500
501/// The parsing half of [`fetch_expected_hash`], which is `sha256sum` format: the hex
502/// digest, two spaces, the file name.
503///
504/// Validated rather than trusted, because the failure this guards against is not a
505/// malformed checksum — it is a 404 page or a proxy error blob arriving where the sidecar
506/// should be. Comparing a digest against `<!DOCTYPE html>` would report a checksum
507/// mismatch, which reads as "someone tampered with the download" and sends the user
508/// somewhere alarming and wrong.
509fn parse_sha256_sidecar(body: &str) -> Result<String> {
510    let hash = body
511        .split_whitespace()
512        .next()
513        .context("the checksum sidecar was empty")?
514        .to_ascii_lowercase();
515    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
516        anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
517    }
518    Ok(hash)
519}
520
521fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
522    let mut body = ureq::get(url)
523        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
524        .config()
525        .timeout_global(Some(Duration::from_secs(
526            constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
527        )))
528        .build()
529        .call()
530        .with_context(|| format!("could not download {url}"))?;
531    let mut buf = Vec::new();
532    body.body_mut()
533        .as_reader()
534        .read_to_end(&mut buf)
535        .with_context(|| format!("could not read {url}"))?;
536    Ok(buf)
537}
538
539/// Move `staged` onto `target`, working around the one platform that will not overwrite
540/// a file it is executing.
541fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
542    // On Windows a running image is locked against replacement but not against rename,
543    // so the live file steps aside and the new one takes its name. The `.old` is swept
544    // by the next run, when nothing holds it open any more.
545    #[cfg(windows)]
546    let aside = {
547        let aside = target.with_extension("exe.old");
548        let _ = fs::remove_file(&aside);
549        target
550            .exists()
551            .then(|| fs::rename(target, &aside).ok().map(|_| aside))
552            .flatten()
553    };
554
555    match fs::rename(staged, target) {
556        Ok(()) => {
557            #[cfg(windows)]
558            if let Some(aside) = aside {
559                let _ = fs::remove_file(&aside);
560            }
561            Ok(())
562        }
563        Err(e) => {
564            let _ = fs::remove_file(staged);
565            #[cfg(windows)]
566            if let Some(aside) = aside
567                && !target.exists()
568            {
569                // Put the working binary back rather than leaving the machine with no
570                // `dev-prune` at all.
571                let _ = fs::rename(&aside, target);
572            }
573            Err(e).with_context(|| format!("could not install {}", target.display()))
574        }
575    }
576}
577
578/// Run one channel's own upgrade command, wired to the terminal so its progress and
579/// prompts reach the user directly.
580fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
581    let Some(argv) = channel.upgrade_argv() else {
582        output::print_warning(
583            "Could not tell which channel installed this binary, so nothing was \
584             changed. Upgrade it yourself with one of:",
585        );
586        print_upgrade_commands();
587        anyhow::bail!("unrecognised install channel");
588    };
589
590    output::print_info(&format!("Running: {}", argv.join(" ")));
591    let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
592        .args(&argv[1..])
593        .status()
594        .with_context(|| format!("could not start `{}`", argv[0]))?;
595    if !status.success() {
596        anyhow::bail!("`{}` exited with {status}", argv.join(" "));
597    }
598    Ok(())
599}
600
601/// The end-of-run hook behind `auto_update`: when the setting is on and the last release
602/// check already knows a newer version exists, replace the binary without being asked.
603///
604/// Warn-never-fail, like everything else that runs as a side effect of `devp run` — a
605/// broken upgrade path must not turn a successful prune into a failed command.
606///
607/// Deliberately *not* `run_install`. That function falls back to running the package
608/// manager that installed this copy, and this is the path that runs unattended: from the
609/// scheduled pass, from a git hook, from `devp run` in the middle of someone else's
610/// work. Spawning `winget upgrade` there can raise an elevation prompt and can pull in
611/// upgrades nobody asked about. Download-and-replace is safe unattended; handing the
612/// machine to a package manager is a decision, and decisions stay with the person.
613pub fn maybe_auto_update(registry: &Registry) {
614    if !registry.settings.auto_update
615        || crate::setup::offline_requested()
616        || crate::setup::no_auto_setup_requested()
617    {
618        return;
619    }
620    let Some(latest) = registry.latest_known_version.as_deref() else {
621        return;
622    };
623    if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
624        return;
625    }
626
627    // Announced here rather than at the top of the function, so the line appears on
628    // exactly the runs where the pin changed the outcome. A pass with nothing to
629    // install stays as silent as it has always been.
630    if registry.settings.version_lock {
631        println!();
632        output::print_info(&locked_notice(Some(latest)));
633        return;
634    }
635
636    let Ok(exe) = std::env::current_exe() else {
637        return;
638    };
639    let managed = crate::setup::managed_exe_path().ok();
640    let channel = Channel::detect_at(&exe, managed.as_deref());
641
642    // WinGet, Scoop and Homebrew swap their whole package directory on upgrade, so bytes
643    // written there are undone by the next `winget upgrade` — which would still believe
644    // the old version is installed. Those channels own the upgrade, and
645    // `notify_if_outdated` has already printed the line naming the right command.
646    if channel.replaces_its_directory() {
647        return;
648    }
649
650    println!();
651    output::print_info(&format!(
652        "Updating dev-prune v{} -> v{latest} …",
653        constants::VERSION
654    ));
655    match install_directly(latest, &exe, managed.as_deref(), channel) {
656        Ok(()) => {
657            output::print_success(&format!("dev-prune v{latest} installed."));
658            report_channel_bookkeeping(channel);
659        }
660        Err(e) => output::print_warning(&format!(
661            "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
662             `devp config set auto_update false` to stop trying."
663        )),
664    }
665}
666
667/// Quietly keep the release check current and print a one-line notice when the installed
668/// build is behind. Returns `true` when the registry changed and needs saving.
669///
670/// Called from `devp run` and `devp status`. Never returns an error: a background
671/// convenience must not be able to fail the command the user actually asked for.
672pub fn notify_if_outdated(registry: &mut Registry) -> bool {
673    if !registry.settings.update_check {
674        return false;
675    }
676
677    let interval = registry.settings.update_check_interval_days;
678    let due = registry
679        .last_update_check
680        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
681
682    if due {
683        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
684        // when the request fails, and retrying on every command while the machine is
685        // offline would put a five-second stall in front of everyday work.
686        let _ = refresh_latest(registry);
687    }
688
689    if let Some(latest) = registry.latest_known_version.as_deref()
690        && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
691    {
692        if registry.settings.version_lock {
693            output::print_info(&locked_notice(Some(latest)));
694        } else {
695            output::print_info(&format!(
696                "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
697                 `devp config set update_check false` silences this.",
698                constants::VERSION
699            ));
700        }
701    }
702
703    due
704}
705
706/// Ask GitHub for the latest release and record the answer on the registry.
707///
708/// The caller is responsible for saving; that keeps this usable from both the
709/// already-loaded-registry path and the standalone command.
710fn refresh_latest(registry: &mut Registry) -> Result<String> {
711    let result = latest_release(registry.settings.update_check_timeout_secs);
712    registry.last_update_check = Some(Utc::now());
713    let latest = result?;
714    registry.latest_known_version = Some(latest.clone());
715    Ok(latest)
716}
717
718/// Say whether the installed build is behind, current, or ahead of the latest release.
719fn report_comparison(latest: &str) {
720    let installed = constants::VERSION;
721    match compare_versions(installed, latest) {
722        Some(Ordering::Less) => {
723            output::print_warning(&format!(
724                "Latest release:    v{latest} — an upgrade is available."
725            ));
726        }
727        Some(Ordering::Equal) => {
728            output::print_success(&format!(
729                "Latest release:    v{latest} — you are up to date."
730            ));
731        }
732        Some(Ordering::Greater) => {
733            // Normal when running a local build between releases.
734            output::print_info(&format!(
735                "Latest release:    v{latest} — your build is newer than the last published one."
736            ));
737        }
738        None => {
739            output::print_info(&format!(
740                "Latest release:    v{latest} (could not compare it to v{installed})."
741            ));
742        }
743    }
744}
745
746/// Fetch the tag name of the most recent published release.
747///
748/// Returns the version without any leading `v`, so it can be compared to
749/// `CARGO_PKG_VERSION` directly.
750fn latest_release(timeout_secs: u64) -> Result<String> {
751    if crate::setup::offline_requested() {
752        anyhow::bail!("{} is set", constants::ENV_OFFLINE);
753    }
754    let body = ureq::get(constants::LATEST_RELEASE_API_URL)
755        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
756        .header("Accept", "application/vnd.github+json")
757        .config()
758        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
759        .build()
760        .call()
761        .context("request failed")?
762        .body_mut()
763        .read_to_string()
764        .context("could not read the response")?;
765
766    let json: serde_json::Value =
767        serde_json::from_str(&body).context("the response was not JSON")?;
768    let tag = json
769        .get("tag_name")
770        .and_then(|v| v.as_str())
771        .context("the response carried no tag_name")?;
772
773    Ok(tag.trim_start_matches('v').to_string())
774}
775
776/// Compare two dotted numeric versions, ignoring any pre-release suffix.
777///
778/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
779/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
780/// as a string.
781pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
782    let parse = |v: &str| -> Option<[u64; 3]> {
783        let core = v.split(['-', '+']).next()?;
784        let mut parts = core.split('.');
785        let out = [
786            parts.next()?.parse().ok()?,
787            parts.next()?.parse().ok()?,
788            parts.next()?.parse().ok()?,
789        ];
790        // A fourth component means this is not the scheme we release under.
791        if parts.next().is_some() {
792            return None;
793        }
794        Some(out)
795    };
796    Some(parse(a)?.cmp(&parse(b)?))
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use chrono::Duration as ChronoDuration;
803
804    #[test]
805    fn orders_by_component_not_lexically() {
806        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
807        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
808        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
809        assert_eq!(
810            compare_versions("2.0.0", "1.99.99"),
811            Some(Ordering::Greater)
812        );
813    }
814
815    #[test]
816    fn pre_release_suffixes_compare_by_their_core() {
817        assert_eq!(
818            compare_versions("1.0.0", "1.0.0-rc.1"),
819            Some(Ordering::Equal)
820        );
821        assert_eq!(
822            compare_versions("1.0.0+build7", "1.0.1"),
823            Some(Ordering::Less)
824        );
825    }
826
827    #[test]
828    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
829        assert_eq!(compare_versions("1.0", "1.0.0"), None);
830        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
831        assert_eq!(compare_versions("nightly", "1.0.0"), None);
832    }
833
834    #[test]
835    fn the_check_is_on_unless_the_user_turns_it_off() {
836        assert!(Registry::default().settings.update_check);
837    }
838
839    #[test]
840    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
841        let mut registry = Registry::default();
842        registry.settings.update_check = false;
843        assert!(!notify_if_outdated(&mut registry));
844        assert!(registry.last_update_check.is_none());
845    }
846
847    #[test]
848    fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
849        let registry = Registry::default();
850        assert!(registry.settings.auto_update);
851        // No release check has run, so `latest_known_version` is unset and this must
852        // return without touching the network or the terminal. The default being *on* is
853        // what makes that early return load-bearing rather than incidental.
854        assert!(registry.latest_known_version.is_none());
855        maybe_auto_update(&registry);
856    }
857
858    #[test]
859    fn the_pin_is_off_until_somebody_asks_for_it() {
860        // Every other path in this file is written on the assumption that the pin costs
861        // nothing when nobody has set it, so the default is the part worth asserting.
862        assert!(!Registry::default().settings.version_lock);
863    }
864
865    #[test]
866    fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
867        // Both halves matter. A refusal that does not say which version it is holding
868        // cannot be audited, and one that does not say how to release it is
869        // indistinguishable, to the person reading it, from an update path that broke.
870        let notice = locked_notice(None);
871        assert!(notice.contains(constants::VERSION), "{notice}");
872        assert!(
873            notice.contains("devp config set version_lock false"),
874            "{notice}"
875        );
876        assert!(!notice.contains("is out"), "{notice}");
877    }
878
879    #[test]
880    fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
881        // "You are pinned" and "there is a 2.0.0 out that you are not getting" are
882        // different facts, and the second is the one that makes somebody go and look at
883        // the setting.
884        let notice = locked_notice(Some("2.0.0"));
885        assert!(notice.contains("v2.0.0 is out"), "{notice}");
886        assert!(notice.contains(constants::VERSION), "{notice}");
887    }
888
889    #[test]
890    fn a_recent_check_is_not_repeated() {
891        let mut registry = Registry::default();
892        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
893        registry.last_update_check = Some(stamp);
894        // No network call, so the stamp survives untouched and nothing needs saving.
895        assert!(!notify_if_outdated(&mut registry));
896        assert_eq!(registry.last_update_check, Some(stamp));
897    }
898
899    #[test]
900    fn the_asset_name_matches_what_the_release_workflow_builds() {
901        // This string is a contract with `.github/workflows/release.yml`. Getting it
902        // wrong is not a compile error and not a test failure anywhere else — it is a
903        // self-update that 404s for every user on the day of a release.
904        let name = constants::release_asset_name("1.4.0");
905        let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
906            ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
907            ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
908            ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
909            ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
910            ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
911            ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
912            ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
913            // A platform the release does not build for must decline the direct route
914            // rather than download some other platform's binary.
915            _ => None,
916        };
917        assert_eq!(name.as_deref(), expected);
918    }
919
920    #[test]
921    fn only_windows_has_a_32_bit_asset() {
922        // The matrix builds `x86` for Windows alone. On a 32-bit Linux there is nothing
923        // to download, and guessing `x64` would install a binary that cannot run.
924        let name = constants::release_asset_name("9.9.9");
925        if std::env::consts::ARCH == "x86" {
926            assert_eq!(name.is_some(), std::env::consts::OS == "windows");
927        }
928    }
929
930    #[test]
931    fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
932        let digest = "a".repeat(64);
933        assert_eq!(
934            parse_sha256_sidecar(&format!("{digest}  dev-prune-v1.4.0-linux-x64\n")).unwrap(),
935            digest
936        );
937        // The Windows step writes it with no trailing newline, and GitHub may serve
938        // either line ending.
939        assert_eq!(
940            parse_sha256_sidecar(&format!("{digest}  asset.exe")).unwrap(),
941            digest
942        );
943        assert_eq!(
944            parse_sha256_sidecar(&format!("{}  asset\r\n", digest.to_uppercase())).unwrap(),
945            digest,
946            "an upper-case digest must compare equal to the one we compute"
947        );
948    }
949
950    #[test]
951    fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
952        // A 404 page, an error blob or a truncated read must fail as "not a digest"
953        // rather than as a mismatch — the two send the user to very different places.
954        for bad in [
955            "",
956            "   ",
957            "<!DOCTYPE html>",
958            "not-a-hash  asset",
959            &"a".repeat(63),
960            &"a".repeat(65),
961            &format!("{}g  asset", "a".repeat(63)),
962        ] {
963            assert!(
964                parse_sha256_sidecar(bad).is_err(),
965                "{bad:?} must not be accepted as a digest"
966            );
967        }
968    }
969}