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 page. 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// Nothing is downloaded until someone says so: `devp update` asks `[y/N]` at a terminal
17// when a newer release exists, `--install` (or `-y`) is the same answer given up front,
18// and a copy run from a script gets the printed upgrade command and nothing else. The
19// scheduled pass is never interrupted by an upgrade: it runs the managed copy under
20// `<config>/bin`, which is replaced by atomic rename and refreshed from the new binary
21// on the next healthy run (`setup::stable_exe_path`), so a pass already in flight keeps
22// its loaded image and the next pass picks up the new one.
23
24use std::cmp::Ordering;
25use std::fs;
26use std::io::Read;
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use anyhow::{Context, Result};
31use chrono::Utc;
32
33use crate::channel::Channel;
34use crate::config::Registry;
35use crate::constants;
36use crate::output;
37
38pub fn run(offline: bool, install: bool, channels: bool, yes: bool) -> Result<()> {
39    if install {
40        return run_install();
41    }
42    if channels {
43        return run_channels();
44    }
45    output::print_header("dev-prune version & upgrade");
46
47    output::print_info(&format!("Installed version: v{}", constants::VERSION));
48
49    let mut registry = Registry::load().ok();
50    let mut newer = None;
51
52    if offline {
53        output::print_info("Skipping the release check because `--offline` was passed.");
54    } else if let Some(reg) = registry.as_mut() {
55        if reg.settings.update_check {
56            // An explicit `devp update` always asks, regardless of when the last
57            // automatic check ran — the user is standing there waiting for the answer.
58            match refresh_latest(reg) {
59                Ok(latest) => {
60                    report_comparison(&latest);
61                    if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
62                        newer = Some(latest);
63                    }
64                }
65                // A failed check is not a failed command. Someone offline, behind a
66                // proxy, or hitting a rate limit still wants the upgrade instructions.
67                // `:#` so the cause is printed under the label: "request failed" alone
68                // once hid an HTTP 403 and sent a user looking at their network.
69                Err(e) => output::print_warning(&format!(
70                    "Could not check the latest release ({e:#}). The upgrade commands below still apply."
71                )),
72            }
73            let _ = reg.save();
74        } else {
75            output::print_info(
76                "The release check is off (`devp config set update_check true` re-enables it).",
77            );
78        }
79    }
80
81    println!();
82    println!("  Latest releases:  {}", constants::RELEASES_URL);
83    println!();
84
85    // Under a pin, the upgrade command is the one command that undoes it. Printing it
86    // here would answer "how do I upgrade this" with the wrong answer, so the pin is
87    // what the section says instead.
88    if registry.is_some_and(|r| r.settings.version_lock) {
89        output::print_info(&locked_notice(None));
90        return Ok(());
91    }
92    print_upgrade_commands();
93
94    // The command already knows a newer release exists and how to fetch it, so making
95    // the user retype it with `--install` was one round trip too many. The default is
96    // still "no": Enter, a pipe, or a script leaves the binary exactly as it was.
97    if let Some(latest) = newer
98        && confirm_install(&latest, yes)
99    {
100        println!();
101        return install_latest(&latest);
102    }
103
104    Ok(())
105}
106
107/// Ask whether to install `latest` right now. `yes` answers without asking.
108///
109/// Without a terminal on stdin the answer is silently "no": the upgrade command was
110/// just printed, and a prompt nobody can answer would hang whatever called us.
111fn confirm_install(latest: &str, yes: bool) -> bool {
112    use std::io::{IsTerminal, Write};
113
114    if yes {
115        return true;
116    }
117    if !std::io::stdin().is_terminal() {
118        return false;
119    }
120    eprint!("Install v{latest} now? [y/N]: ");
121    if std::io::stderr().flush().is_err() {
122        return false;
123    }
124    let mut input = String::new();
125    if std::io::stdin().read_line(&mut input).is_err() {
126        return false;
127    }
128    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
129}
130
131/// The one sentence every refusal prints, so the pin and the way out of it always
132/// arrive together.
133///
134/// A lock that silently does nothing is indistinguishable from an update path that has
135/// broken, and "it stopped updating" is what people conclude when a tool goes quiet.
136/// `latest` is passed on the paths that already know a newer release exists, because
137/// "there is one, and you are deliberately not getting it" is a different fact from
138/// "you are pinned".
139pub(crate) fn locked_notice(latest: Option<&str>) -> String {
140    let head = match latest {
141        Some(latest) => format!("dev-prune v{latest} is out. "),
142        None => String::new(),
143    };
144    format!(
145        "{head}`version_lock` is on, so this copy stays at v{}. \
146         `devp config set version_lock false` releases it.",
147        constants::VERSION
148    )
149}
150
151/// Ask GitHub right now — no interval — and say where the installed build stands.
152///
153/// For `devp init`, which is deliberate and infrequent enough to be worth a round trip:
154/// setting a machine up is exactly the moment to learn the binary is a version behind.
155/// `devp run` deliberately does not use this; it goes through [`notify_if_outdated`],
156/// which is interval-gated so everyday work never waits on the network.
157///
158/// Returns `true` when the registry changed and needs saving.
159pub fn check_now(registry: &mut Registry) -> bool {
160    if !registry.settings.update_check {
161        return false;
162    }
163
164    match refresh_latest(registry) {
165        Ok(latest) => {
166            report_comparison(&latest);
167            if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
168                print_upgrade_commands();
169            }
170        }
171        // Not being able to reach GitHub is not a failed `init`.
172        Err(e) => output::print_info(&format!("Could not check for a newer release ({e:#}).")),
173    }
174    true
175}
176
177/// Name the one command that upgrades *this* copy, and only fall back to the menu.
178///
179/// The old version printed all eight channels and told the reader to pick the one they
180/// installed from. Nobody remembers that — it was a decision made once, possibly a year
181/// ago, on a machine they have since reimaged. The channel is written in the path of the
182/// running binary and `Channel::detect` already reads it, so asking the user to recall it
183/// was asking for information dev-prune already had.
184fn print_upgrade_commands() {
185    let channel = Channel::detect();
186    match channel.upgrade_command() {
187        Some(command) => {
188            println!("  Installed with {} — upgrade with:", channel.label());
189            println!("    {command}");
190            println!();
191            println!("  Or `devp update --install` to let dev-prune do it for you.");
192            println!();
193            // Named, not printed. The channel above is the answer for this copy; the
194            // rest of the table is for the reader who has a second machine, or who does
195            // not believe the detection.
196            println!("  `devp update --channels` lists the command for every channel.");
197        }
198        // `Unknown` means the binary sits somewhere no channel owns — a dev build, a
199        // hand-copied file, a distro package. There is no manager to name, so this is the
200        // one case where the full list is the honest answer.
201        None => {
202            println!("  This copy is not in a location any install channel owns, so there");
203            println!("  is no package manager to name. Replace it in place with:");
204            println!("    devp update --install");
205            println!();
206            println!("  Or install through a channel, which keeps it upgradeable:");
207            print_every_upgrade_command();
208        }
209    }
210}
211
212/// `devp update --channels`: the whole table, and which row this copy is on.
213///
214/// Deliberately offline. The one question it answers — "what do I type to upgrade a
215/// dev-prune installed through X" — does not depend on what the latest release is, and
216/// making it wait on the network would make it useless on the machine where it is most
217/// often needed.
218fn run_channels() -> Result<()> {
219    output::print_header("dev-prune upgrade commands");
220    let current = Channel::detect();
221    println!();
222    println!("  This copy came from {}.", current.label());
223    println!();
224    print_every_upgrade_command();
225    println!();
226    output::print_info(
227        "`devp update --install` replaces this copy directly, without the manager. \
228         `devp install --channel <name>` moves it to a different one.",
229    );
230    Ok(())
231}
232
233/// Every channel's own upgrade command, one per line, widest label first.
234///
235/// Printed from the table rather than typed out. The version of this list that was typed
236/// out named five channels of the nine that existed, and the copy the user was holding
237/// had been installed through one of the four it did not mention.
238fn print_every_upgrade_command() {
239    let channels = [
240        Channel::Installer,
241        Channel::Cargo,
242        Channel::Npm,
243        Channel::Bun,
244        Channel::Pnpm,
245        Channel::Yarn,
246        Channel::UvTool,
247        Channel::Pipx,
248        Channel::Pip,
249        Channel::WinGet,
250        Channel::Scoop,
251        Channel::Homebrew,
252    ];
253    let width = channels.iter().map(|c| c.label().len()).max().unwrap_or(0);
254    for channel in channels {
255        if let Some(command) = channel.upgrade_command() {
256            println!("    {:<width$}  {command}", channel.label());
257        }
258    }
259}
260
261/// `devp update --install`: upgrade this installation to the latest release.
262///
263/// Downloads the release binary from GitHub and replaces the files itself, rather than
264/// asking whichever package manager delivered the first copy to do it. That inversion is
265/// deliberate. There is exactly one binary that matters — the managed copy under
266/// `<config>/bin`, which the git hooks, the scheduler and `PATH` all point at — and it
267/// does not live inside `node_modules`, a uv tool directory or `~/.cargo/bin`. Asking
268/// `uv` to upgrade a file it has never heard of was never going to work, and asking it to
269/// upgrade its *own* copy left the one that actually runs untouched.
270///
271/// So both are replaced: the managed copy first, because that is what runs unattended,
272/// then the running binary if it is a different file, because that is what the user
273/// types. The channel's own bookkeeping (what `uv tool list` believes is installed) is
274/// left stale on purpose — correcting it means running the channel's installer, which is
275/// the one thing this route exists to avoid — and the command to resync it is printed.
276///
277/// Falls back to the channel's own upgrade command when there is no published binary for
278/// this platform or the download fails, so a release-page outage costs the fast path and
279/// not the upgrade.
280fn run_install() -> Result<()> {
281    output::print_header("dev-prune self-update");
282
283    let mut registry = Registry::load()?;
284
285    // Checked before the network is touched: a refusal the configuration already
286    // guarantees should cost nothing and say why.
287    if registry.settings.version_lock {
288        anyhow::bail!("{}", locked_notice(None));
289    }
290
291    if crate::setup::offline_requested() {
292        anyhow::bail!(
293            "{} is set — an install needs the network by definition.",
294            constants::ENV_OFFLINE
295        );
296    }
297
298    // Know before downloading whether there is anything to download. A failed check is
299    // fatal here (unlike `devp update`): running an installer blind would "upgrade" to
300    // the version already installed.
301    let latest = refresh_latest(&mut registry)?;
302    let _ = registry.save();
303    if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
304        output::print_success(&format!(
305            "v{} is already the latest release — nothing to install.",
306            constants::VERSION
307        ));
308        // "Nothing to install" is about the console binaries. The scheduler twin can
309        // still be behind them (the incident: consoles on the current release, devpw
310        // three releases back, every scheduled pass silently running old code), so it
311        // is reconciled even when there is nothing else to do.
312        #[cfg(windows)]
313        {
314            let dirs = default_twin_dirs();
315            let refs: Vec<&Path> = dirs.iter().map(|p| p.as_path()).collect();
316            reconcile_windowless_twins(constants::VERSION, &refs);
317        }
318        return Ok(());
319    }
320    install_latest(&latest)
321}
322
323/// Replace the running binary with release `latest`, already known to be newer.
324///
325/// Shared by `--install` and by the `[y/N]` prompt at the end of plain `devp update`,
326/// which has already fetched `latest` and checked the pin — this does not ask again.
327fn install_latest(latest: &str) -> Result<()> {
328    output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
329
330    let exe = std::env::current_exe().context("could not locate the running binary")?;
331    let managed = crate::setup::managed_exe_path().ok();
332    let channel = Channel::detect_at(&exe, managed.as_deref());
333
334    match install_directly(latest, &exe, managed.as_deref(), channel) {
335        Ok(()) => {
336            output::print_success(&format!("dev-prune v{latest} installed."));
337            report_channel_bookkeeping(channel);
338            output::print_info(
339                "The scheduled pass was not interrupted: it runs the managed copy, which \
340                 was replaced by atomic rename, so a pass already in flight keeps the \
341                 image it loaded and the next one picks up the new binary.",
342            );
343            return Ok(());
344        }
345        Err(e) => output::print_warning(&format!(
346            "Direct download did not work ({e:#}).\nFalling back to the channel that \
347             installed this copy."
348        )),
349    }
350
351    // On Windows a running executable's file is locked against replacement but not
352    // against rename. Moving it aside first lets the channel write a fresh file at the
353    // real path; the `.old` left behind is swept up by the *next* run, when nothing is
354    // executing it any more.
355    #[cfg(windows)]
356    let aside = {
357        let aside = exe.with_extension("exe.old");
358        let _ = fs::remove_file(&aside);
359        fs::rename(&exe, &aside).ok().map(|_| aside)
360    };
361
362    let result = spawn_channel_upgrade(channel);
363
364    #[cfg(windows)]
365    if let Some(aside) = aside {
366        if result.is_ok() {
367            // Best effort: the file is still our running image, so Windows may refuse
368            // the delete. The sweep at the top of the next `--install` gets it then.
369            let _ = fs::remove_file(&aside);
370        } else if !exe.exists() {
371            // The upgrade never wrote a new binary — put the old one back so the
372            // command the user has on PATH still exists.
373            let _ = fs::rename(&aside, &exe);
374        }
375    }
376    result?;
377
378    output::print_success(&format!("dev-prune v{latest} installed."));
379    output::print_info(
380        "The scheduled pass was not interrupted: it runs the managed copy, which \
381         refreshes itself from the new binary on its next run.",
382    );
383    Ok(())
384}
385
386/// Replace every copy of the binary this installation actually runs, from one download.
387///
388/// The managed copy is done first and is the only one whose failure aborts the upgrade:
389/// it is what the scheduler and the git hooks invoke, so a machine with a fresh managed
390/// copy is upgraded even if nothing else could be written.
391///
392/// Every other path is then written from the same verified bytes, and each is written
393/// with the same rename-aside dance rather than through `ensure_alias`. That matters on
394/// Windows: `ensure_alias` deletes the twin before relinking, and the delete fails when
395/// the twin is the running image — which is exactly the case when the user typed `devp
396/// update --install`. Renaming a running executable is allowed where deleting it is not,
397/// so this route leaves no copy behind on the previous release.
398fn install_directly(
399    latest: &str,
400    exe: &Path,
401    managed: Option<&Path>,
402    channel: Channel,
403) -> Result<()> {
404    let bytes = fetch_release_binary(latest)?;
405    let primary = managed.unwrap_or(exe);
406    install_bytes_at(&bytes, primary)?;
407
408    // …except when a package manager owns the directory the running copy sits in and
409    // replaces that directory wholesale on upgrade. Writing new bytes there leaves WinGet,
410    // Scoop or Homebrew certain they still have the old version installed, and the next
411    // `winget upgrade` puts the old binary back over the top. Their copy is left exactly
412    // as the manager wrote it; `report_channel_bookkeeping` names the command that
413    // actually moves it forward. A foreign tree — Volta, mise, Nix, the system package
414    // manager — is the same ownership situation without a resync command to print:
415    // overwriting a shim breaks the shim manager, and writing into `/nix/store` or
416    // `/usr/bin` desyncs a store this tool cannot correct. Its copy is left alone too.
417    let replace_exe_dir = primary != exe && exe.is_file() && !manager_owns_exe_dir(channel);
418    let mut also = companion_copies(primary, managed.is_some(), exe, replace_exe_dir);
419    let mut skip = also.clone();
420    skip.push(primary.to_path_buf());
421    let cargo_bin = cargo_bin_dir(managed);
422    let cargo_copies = cargo_bin
423        .as_deref()
424        .map(|bin| cargo_channel_copies(bin, latest, &skip))
425        .unwrap_or_default();
426    also.extend(cargo_copies.iter().cloned());
427    for path in also {
428        if let Err(e) = install_bytes_at(&bytes, &path) {
429            output::print_warning(&format!(
430                "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
431                 is, that copy runs the previous version whenever it is the one invoked.",
432                path.display()
433            ));
434        }
435    }
436
437    // The console binaries are now on `latest`; the scheduler twin must not be left
438    // behind them. It is a separate `[[bin]]` image, so it cannot be written from the
439    // bytes above: it gets its own verified download, and only where a twin already
440    // exists.
441    #[cfg(windows)]
442    {
443        let mut dirs: Vec<&Path> = Vec::new();
444        if let Some(dir) = primary.parent() {
445            dirs.push(dir);
446        }
447        if replace_exe_dir && let Some(dir) = exe.parent() {
448            dirs.push(dir);
449        }
450        // Only when its console copies were just moved forward: a cargo directory whose
451        // copies were skipped as already current or newer gets no twin from an older
452        // release either.
453        if !cargo_copies.is_empty()
454            && let Some(dir) = cargo_bin.as_deref()
455        {
456            dirs.push(dir);
457        }
458        reconcile_windowless_twins(latest, &dirs);
459    }
460
461    if !cargo_copies.is_empty()
462        && let Some(resync) = Channel::Cargo.upgrade_command()
463    {
464        output::print_info(&format!(
465            "The cargo-installed copies were replaced too. Cargo's own record still lists \
466             the version it installed; `{resync}` brings that record forward."
467        ));
468    }
469
470    // Still worth running for the copy `get_exe_path` resolves when that is neither
471    // the managed directory nor the running one. `place_windowless_twin` never
472    // regresses a twin past what sits beside the running binary, so after the
473    // reconcile above this can only move a stale copy forward.
474    crate::daemon::refresh_windowless_twin();
475
476    // The receipt beside the managed copy now names the version that was there a minute
477    // ago. Only ever updated, never created: this path also upgrades a managed copy some
478    // other manager installed, and writing a fresh receipt there would claim one of our
479    // installers ran when none did.
480    crate::receipt::refresh_after_upgrade(latest);
481    Ok(())
482}
483
484/// True when a package manager owns the running copy's directory outright, so no file
485/// in it may be rewritten: the versioned-directory trio, which swaps the whole
486/// directory on upgrade, and a foreign manager's tree — a shim, a Nix store path, a
487/// system package — whose contents dev-prune has no command to resync afterwards.
488fn manager_owns_exe_dir(channel: Channel) -> bool {
489    channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
490}
491
492/// Every other file that is a copy of the binary being replaced — both public names,
493/// in both directories that hold one.
494///
495/// Left alone, a copy keeps running the previous release silently, because the
496/// scheduler and the hooks both discard their own output by design. `devp` is a full
497/// second executable rather than a link, so a directory that holds one name usually
498/// holds the other. The managed directory owns both names outright and gets both
499/// written whether they exist yet or not; the running copy's directory belongs to
500/// whatever put the binary there, so only files already present are touched.
501///
502/// Both names, deliberately: through 1.12.0 this list held only the primary's `devp`
503/// twin plus the running file itself, so `devp update --install` typed at a
504/// cargo-installed `devp` upgraded everything except the `dev-prune` sitting beside
505/// it — the exact silent staleness the list exists to prevent.
506fn companion_copies(
507    primary: &Path,
508    primary_is_managed: bool,
509    exe: &Path,
510    replace_exe_dir: bool,
511) -> Vec<PathBuf> {
512    let names: [&str; 2] = if cfg!(windows) {
513        ["dev-prune.exe", "devp.exe"]
514    } else {
515        ["dev-prune", "devp"]
516    };
517    let mut also: Vec<PathBuf> = Vec::new();
518    if let Some(dir) = primary.parent() {
519        for name in names {
520            let twin = dir.join(name);
521            if twin != primary && (primary_is_managed || twin.is_file()) {
522                also.push(twin);
523            }
524        }
525    }
526    if replace_exe_dir {
527        if !also.contains(&exe.to_path_buf()) {
528            also.push(exe.to_path_buf());
529        }
530        if let Some(dir) = exe.parent() {
531            for name in names {
532                let twin = dir.join(name);
533                if twin != *exe && twin != primary && twin.is_file() && !also.contains(&twin) {
534                    also.push(twin);
535                }
536            }
537        }
538    }
539    also
540}
541
542/// Cargo's `bin` directory, when the copies there are cargo's own.
543///
544/// `CARGO_HOME` first, as cargo itself resolves it. The channel check matters for a Rust
545/// toolchain installed through Scoop, whose `.cargo` sits inside Scoop's tree: that is
546/// Scoop's directory to rewrite, not this one's.
547fn cargo_bin_dir(managed: Option<&Path>) -> Option<PathBuf> {
548    let home = std::env::var_os("CARGO_HOME")
549        .map(PathBuf::from)
550        .or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))?;
551    let bin = home.join("bin");
552    let probe = bin.join(if cfg!(windows) {
553        "dev-prune.exe"
554    } else {
555        "dev-prune"
556    });
557    (Channel::detect_at(&probe, managed) == Channel::Cargo).then_some(bin)
558}
559
560/// The `dev-prune` and `devp` files in cargo's `bin` directory that release `latest`
561/// should replace.
562///
563/// The scheduler runs the managed copy, so an unattended update has `exe == primary` and
564/// [`companion_copies`] never looks past the managed directory. The copies `cargo
565/// binstall` put on `PATH` stayed on whatever release installed them, and the `devp` the
566/// user typed kept answering with old code long after every scheduled pass had moved on.
567/// Cargo has no package directory of its own to desync, only a version record, which the
568/// note printed after the install names the command for.
569///
570/// Only files that exist, never one already in `skip`, and never one whose build stamp is
571/// already `latest` or newer: a `cargo install --path .` development build lives here
572/// too, and replacing it with an older release would be a downgrade nobody asked for. A
573/// copy with no readable stamp predates stamping and is behind by definition.
574fn cargo_channel_copies(cargo_bin: &Path, latest: &str, skip: &[PathBuf]) -> Vec<PathBuf> {
575    let names: [&str; 2] = if cfg!(windows) {
576        ["dev-prune.exe", "devp.exe"]
577    } else {
578        ["dev-prune", "devp"]
579    };
580    names
581        .iter()
582        .map(|name| cargo_bin.join(name))
583        .filter(|path| path.is_file() && !skip.contains(path))
584        .filter(|path| {
585            let stamped = fs::read(path)
586                .ok()
587                .and_then(|b| crate::commands::trust::version_from_stamp(&b));
588            !stamped
589                .as_deref()
590                .and_then(|v| compare_versions(v, latest))
591                .is_some_and(|o| o != Ordering::Less)
592        })
593        .collect()
594}
595
596/// Name the channel's own upgrade command after a direct install, for the one thing the
597/// direct route deliberately leaves untouched: the manager's record of what it installed.
598fn report_channel_bookkeeping(channel: Channel) {
599    // A foreign manager's copy was left alone the same way the trio's is, but there is
600    // no resync command to name — dev-prune does not know how to drive Volta, mise or
601    // Nix. Saying nothing here read as "everything was replaced", which is exactly what
602    // did not happen to that copy.
603    if let Channel::Foreign(manager) = channel {
604        output::print_info(&format!(
605            "The managed copy is now v{}. The copy that {manager} installed was left \
606             exactly as it wrote it — replacing a file inside a manager-owned tree only \
607             makes {manager} and the disk disagree. Upgrade that copy through {manager} \
608             itself.",
609            constants::VERSION
610        ));
611        return;
612    }
613    // The installer's copy *is* the managed one, and an unrecognised copy has no manager
614    // keeping a version record that could disagree with the binary.
615    let Some(resync) = channel
616        .owns_its_files()
617        .then(|| channel.upgrade_command())
618        .flatten()
619    else {
620        return;
621    };
622    if channel.replaces_its_directory() {
623        output::print_info(&format!(
624            "The managed copy is now v{}. The copy {} installed was left exactly as it \
625             wrote it — replacing a file inside a versioned package directory only makes \
626             the manager and the disk disagree. Run `{resync}` to move that one forward \
627             too.",
628            constants::VERSION,
629            channel.label()
630        ));
631    } else {
632        output::print_info(&format!(
633            "The binaries are up to date. `{resync}` also updates that manager's own \
634             record of the version, which still reads v{}.",
635            constants::VERSION
636        ));
637    }
638}
639
640/// Download release `version`'s binary for this platform and put it at `target`.
641///
642/// The direct route, and the reason `devp update --install` no longer depends on the
643/// package manager that happened to deliver the first copy. Whatever installed it, the
644/// binary the hooks, the scheduler and `PATH` all point at is one file in the config
645/// directory, and this replaces that file. `uv`, `npm` and `cargo` are delivery
646/// channels; they are not the source of truth, and asking one of them to upgrade a file
647/// living under another one's directory was never going to work.
648///
649/// Refuses to install anything whose SHA-256 does not match the sidecar published beside
650/// it. That check is the entire safety story for this path: the bytes are about to
651/// become the binary the machine runs on a schedule.
652fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
653    let asset = constants::release_asset_name(version).with_context(|| {
654        format!(
655            "no published binary for {}-{}; upgrade through the channel that installed \
656             this copy instead",
657            std::env::consts::OS,
658            std::env::consts::ARCH
659        )
660    })?;
661    fetch_verified_asset(version, &asset)
662}
663
664/// Download release `version`'s windowless scheduler binary, verified the same way.
665///
666/// Separate from [`fetch_release_binary`] because it is a different asset with its own
667/// sidecar, not a different verification story: the scheduled task runs these bytes
668/// unattended, which is exactly why they get the same checksum gate as the console
669/// binary.
670#[cfg(windows)]
671fn fetch_windowless_binary(version: &str) -> Result<Vec<u8>> {
672    let asset = constants::windowless_release_asset_name(version).with_context(|| {
673        format!(
674            "no published windowless binary for windows-{}",
675            std::env::consts::ARCH
676        )
677    })?;
678    fetch_verified_asset(version, &asset)
679}
680
681/// The shared tail of every asset download: fetch the `.sha256` sidecar, fetch the
682/// bytes, and refuse them unless the digests match.
683fn fetch_verified_asset(version: &str, asset: &str) -> Result<Vec<u8>> {
684    let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
685
686    let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
687    output::print_info(&format!("Downloading {asset} …"));
688    let bytes = fetch_bytes(&base)?;
689
690    let actual = {
691        use sha2::{Digest, Sha256};
692        use std::fmt::Write as _;
693        let mut h = Sha256::new();
694        h.update(&bytes);
695        // Hex-encoded by hand: sha2 0.11 returns a `hybrid_array::Array`, which has no
696        // `LowerHex`, and the sidecar is lower-case hex either way.
697        h.finalize().iter().fold(String::new(), |mut s, b| {
698            let _ = write!(s, "{b:02x}");
699            s
700        })
701    };
702    if actual != expected {
703        anyhow::bail!(
704            "checksum mismatch for {asset}\n  expected {expected}\n  got      {actual}\n\
705             The download was corrupted or tampered with; nothing was installed."
706        );
707    }
708
709    Ok(bytes)
710}
711
712/// What one reconcile pass over the windowless twins amounted to.
713#[cfg(windows)]
714enum TwinReconcile {
715    /// Every twin that exists is already on `version`, or none exists at all.
716    UpToDate,
717    /// At least one stale twin was replaced and none failed.
718    Updated,
719    /// The download or a write failed; the message has already been printed.
720    Failed(String),
721}
722
723/// Bring every existing `devpw.exe` in `dirs` up to release `version`.
724///
725/// The upgrade path used to refresh the twin from the devpw *beside* the running
726/// binary, but that copy is the previous delivery's: the console binaries moved forward
727/// and the scheduled task kept running the old release, silently, every night. So the
728/// twin is reconciled against the release itself: any copy whose build stamp is older
729/// than `version`, or missing entirely (built before 1.17.0), is replaced from a
730/// checksum-verified download of the published `devpw` asset.
731///
732/// Only existing files are touched. An installation that never had a windowless twin
733/// does not grow one here; `devp daemon install` is what creates one, and the npm
734/// family ships its own under a different mechanism.
735#[cfg(windows)]
736fn reconcile_windowless_twins(version: &str, dirs: &[&Path]) -> TwinReconcile {
737    let mut stale: Vec<PathBuf> = Vec::new();
738    for dir in dirs {
739        let twin = dir.join(constants::WINDOWS_WINDOWLESS_BIN);
740        if stale.contains(&twin) || !twin.is_file() {
741            continue;
742        }
743        let stamped = fs::read(&twin)
744            .ok()
745            .and_then(|b| crate::commands::trust::version_from_stamp(&b));
746        // Equal or newer is left alone; the twin never regresses. A copy with no
747        // readable stamp predates stamping and is by definition behind.
748        let up_to_date = stamped
749            .as_deref()
750            .and_then(|v| compare_versions(v, version))
751            .is_some_and(|o| o != Ordering::Less);
752        if !up_to_date {
753            stale.push(twin);
754        }
755    }
756    if stale.is_empty() {
757        return TwinReconcile::UpToDate;
758    }
759
760    let bytes = match fetch_windowless_binary(version) {
761        Ok(b) => b,
762        Err(e) => {
763            let why = format!(
764                "The scheduler binary {} could not be downloaded ({e:#}). The scheduled \
765                 pass keeps running the version it has until `devp update --install` or \
766                 `devp doctor --fix` succeeds.",
767                constants::WINDOWS_WINDOWLESS_BIN
768            );
769            output::print_warning(&why);
770            return TwinReconcile::Failed(why);
771        }
772    };
773
774    let mut failed = None;
775    for twin in &stale {
776        if let Err(e) = install_bytes_at(&bytes, twin) {
777            let why = format!(
778                "{} could not be replaced ({e:#}); the scheduled pass keeps running the \
779                 previous version from that copy.",
780                twin.display()
781            );
782            output::print_warning(&why);
783            failed = Some(why);
784        }
785    }
786    match failed {
787        Some(why) => TwinReconcile::Failed(why),
788        None => {
789            output::print_success(&format!(
790                "Scheduler binary {} updated to v{version}.",
791                constants::WINDOWS_WINDOWLESS_BIN
792            ));
793            TwinReconcile::Updated
794        }
795    }
796}
797
798/// The directories whose windowless twin this installation actually runs: the managed
799/// directory the scheduler points at, and the directory of the running binary.
800#[cfg(windows)]
801fn default_twin_dirs() -> Vec<PathBuf> {
802    let mut dirs = Vec::new();
803    if let Ok(managed) = crate::setup::managed_exe_path()
804        && let Some(dir) = managed.parent()
805    {
806        dirs.push(dir.to_path_buf());
807    }
808    if let Ok(exe) = std::env::current_exe()
809        && let Some(dir) = exe.parent()
810    {
811        dirs.push(dir.to_path_buf());
812    }
813    dirs
814}
815
816/// The `devp doctor --fix` entry point for a stale scheduler binary: same reconcile,
817/// same guardrails, reported through the repair machinery instead of the upgrade
818/// transcript.
819pub(crate) fn repair_windowless_twins() -> crate::setup::Outcome {
820    #[cfg(windows)]
821    {
822        use crate::setup::Outcome;
823        match Registry::load() {
824            Ok(r) if r.settings.version_lock => {
825                return Outcome::Skipped(
826                    "version_lock is set, and nothing dev-prune does replaces a binary \
827                     while it is"
828                        .to_string(),
829                );
830            }
831            _ => {}
832        }
833        if crate::setup::offline_requested() {
834            return Outcome::Skipped(format!(
835                "{} is set and this repair needs a download",
836                constants::ENV_OFFLINE
837            ));
838        }
839        // A development build carries whatever version Cargo.toml says next, and the
840        // release asset for that version does not exist until the tag is pushed, so
841        // reconciling from `target/` would report a spurious download failure once per
842        // version bump. The real install heals itself; a dev build stands down.
843        if std::env::current_exe().is_ok_and(|exe| crate::commands::uninstall::is_dev_build(&exe)) {
844            return Outcome::Skipped(
845                "this is a development build, and its version may not be released yet".to_string(),
846            );
847        }
848        let dirs = default_twin_dirs();
849        let refs: Vec<&Path> = dirs.iter().map(|p| p.as_path()).collect();
850        match reconcile_windowless_twins(constants::VERSION, &refs) {
851            TwinReconcile::UpToDate => Outcome::AlreadyPresent,
852            TwinReconcile::Updated => Outcome::Installed,
853            TwinReconcile::Failed(why) => Outcome::Failed(why),
854        }
855    }
856    #[cfg(not(windows))]
857    {
858        // The finding this repairs is only ever raised on Windows; this arm exists for
859        // the compiler, not for a caller.
860        crate::setup::Outcome::AlreadyPresent
861    }
862}
863
864/// Write already-verified bytes over one binary.
865///
866/// Separate from the download so a single transfer can serve every copy that has to be
867/// replaced — the managed binary, its `devp` twin, and whatever the user is running —
868/// instead of fetching the same megabytes once per path.
869fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
870    // Staged beside the target and renamed in, so a write that dies half-way leaves the
871    // working binary untouched rather than a truncated file where the scheduler expects
872    // an executable.
873    let staging = target.with_extension("new");
874    if let Some(parent) = target.parent() {
875        fs::create_dir_all(parent).ok();
876    }
877    if let Err(e) = fs::write(&staging, bytes) {
878        // A write that dies part-way (disk full, permissions revoked mid-stream) leaves
879        // a truncated `.new` beside the binary forever — nothing else ever looks at it.
880        let _ = fs::remove_file(&staging);
881        return Err(e).with_context(|| format!("could not write {}", staging.display()));
882    }
883
884    #[cfg(unix)]
885    {
886        use std::os::unix::fs::PermissionsExt;
887        // Downloaded files are 0644; the scheduler needs to be able to run this.
888        let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
889    }
890
891    replace_binary(&staging, target)
892}
893
894/// Read the hash out of a `.sha256` sidecar published beside a release asset.
895fn fetch_expected_hash(url: &str) -> Result<String> {
896    let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
897    parse_sha256_sidecar(&body)
898}
899
900/// The parsing half of [`fetch_expected_hash`], which is `sha256sum` format: the hex
901/// digest, two spaces, the file name.
902///
903/// Validated rather than trusted, because the failure this guards against is not a
904/// malformed checksum — it is a 404 page or a proxy error blob arriving where the sidecar
905/// should be. Comparing a digest against `<!DOCTYPE html>` would report a checksum
906/// mismatch, which reads as "someone tampered with the download" and sends the user
907/// somewhere alarming and wrong.
908fn parse_sha256_sidecar(body: &str) -> Result<String> {
909    let hash = body
910        .split_whitespace()
911        .next()
912        .context("the checksum sidecar was empty")?
913        .to_ascii_lowercase();
914    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
915        anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
916    }
917    Ok(hash)
918}
919
920fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
921    let mut body = ureq::get(url)
922        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
923        .config()
924        .timeout_global(Some(Duration::from_secs(
925            constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
926        )))
927        .build()
928        .call()
929        .with_context(|| format!("could not download {url}"))?;
930    let mut buf = Vec::new();
931    body.body_mut()
932        .as_reader()
933        .read_to_end(&mut buf)
934        .with_context(|| format!("could not read {url}"))?;
935    Ok(buf)
936}
937
938/// Move `staged` onto `target`, working around the one platform that will not overwrite
939/// a file it is executing.
940fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
941    // On Windows a running image is locked against replacement but not against rename,
942    // so the live file steps aside and the new one takes its name. The `.old` is swept
943    // by the next run, when nothing holds it open any more.
944    #[cfg(windows)]
945    let aside = {
946        let aside = target.with_extension("exe.old");
947        let _ = fs::remove_file(&aside);
948        target
949            .exists()
950            .then(|| fs::rename(target, &aside).ok().map(|_| aside))
951            .flatten()
952    };
953
954    match fs::rename(staged, target) {
955        Ok(()) => {
956            #[cfg(windows)]
957            if let Some(aside) = aside {
958                let _ = fs::remove_file(&aside);
959            }
960            Ok(())
961        }
962        Err(e) => {
963            let _ = fs::remove_file(staged);
964            #[cfg(windows)]
965            if let Some(aside) = aside
966                && !target.exists()
967            {
968                // Put the working binary back rather than leaving the machine with no
969                // `dev-prune` at all.
970                let _ = fs::rename(&aside, target);
971            }
972            Err(e).with_context(|| format!("could not install {}", target.display()))
973        }
974    }
975}
976
977/// Run one channel's own upgrade command, wired to the terminal so its progress and
978/// prompts reach the user directly.
979fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
980    let Some(argv) = channel.upgrade_argv() else {
981        output::print_warning(
982            "Could not tell which channel installed this binary, so nothing was \
983             changed. Upgrade it yourself with one of:",
984        );
985        print_upgrade_commands();
986        anyhow::bail!("unrecognised install channel");
987    };
988
989    output::print_info(&format!("Running: {}", argv.join(" ")));
990    let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
991        .args(&argv[1..])
992        .status()
993        .with_context(|| format!("could not start `{}`", argv[0]))?;
994    if !status.success() {
995        anyhow::bail!("`{}` exited with {status}", argv.join(" "));
996    }
997    Ok(())
998}
999
1000/// The end-of-run hook behind `auto_update`: when the setting is on and the last release
1001/// check already knows a newer version exists, replace the binary without being asked.
1002///
1003/// Warn-never-fail, like everything else that runs as a side effect of `devp run` — a
1004/// broken upgrade path must not turn a successful prune into a failed command.
1005///
1006/// Deliberately *not* `run_install`. That function falls back to running the package
1007/// manager that installed this copy, and this is the path that runs unattended: from the
1008/// scheduled pass, from a git hook, from `devp run` in the middle of someone else's
1009/// work. Spawning `winget upgrade` there can raise an elevation prompt and can pull in
1010/// upgrades nobody asked about. Download-and-replace is safe unattended; handing the
1011/// machine to a package manager is a decision, and decisions stay with the person.
1012pub fn maybe_auto_update(registry: &Registry) {
1013    if !registry.settings.auto_update
1014        || crate::setup::offline_requested()
1015        || crate::setup::no_auto_setup_requested()
1016    {
1017        return;
1018    }
1019    let Some(latest) = registry.latest_known_version.as_deref() else {
1020        return;
1021    };
1022    if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
1023        return;
1024    }
1025
1026    // Announced here rather than at the top of the function, so the line appears on
1027    // exactly the runs where the pin changed the outcome. A pass with nothing to
1028    // install stays as silent as it has always been.
1029    if registry.settings.version_lock {
1030        println!();
1031        output::print_info(&locked_notice(Some(latest)));
1032        return;
1033    }
1034
1035    let Ok(exe) = std::env::current_exe() else {
1036        return;
1037    };
1038    let managed = crate::setup::managed_exe_path().ok();
1039    let channel = Channel::detect_at(&exe, managed.as_deref());
1040
1041    // WinGet, Scoop and Homebrew swap their whole package directory on upgrade, so bytes
1042    // written there are undone by the next `winget upgrade` — which would still believe
1043    // the old version is installed. Those channels own the upgrade, and
1044    // `notify_if_outdated` has already printed the line naming the right command.
1045    if channel.replaces_its_directory() {
1046        return;
1047    }
1048
1049    // A foreign tree — Volta, mise, Nix, the system package manager — is owned the same
1050    // way, but there is no resync command to have printed. With a managed copy present
1051    // the pass below keeps that copy fresh and `install_directly` leaves the manager's
1052    // file alone; without one, the only writable target *is* the manager's file, and an
1053    // unattended pass must not desync a tree it has no way to correct.
1054    if matches!(channel, Channel::Foreign(_)) && managed.is_none() {
1055        return;
1056    }
1057
1058    println!();
1059    output::print_info(&format!(
1060        "Updating dev-prune v{} -> v{latest} …",
1061        constants::VERSION
1062    ));
1063    match install_directly(latest, &exe, managed.as_deref(), channel) {
1064        Ok(()) => {
1065            output::print_success(&format!("dev-prune v{latest} installed."));
1066            report_channel_bookkeeping(channel);
1067        }
1068        Err(e) => output::print_warning(&format!(
1069            "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
1070             `devp config set auto_update false` to stop trying."
1071        )),
1072    }
1073}
1074
1075/// Quietly keep the release check current and print a one-line notice when the installed
1076/// build is behind. Returns `true` when the registry changed and needs saving.
1077///
1078/// Called from `devp run` and `devp status`. Never returns an error: a background
1079/// convenience must not be able to fail the command the user actually asked for.
1080pub fn notify_if_outdated(registry: &mut Registry) -> bool {
1081    if !registry.settings.update_check {
1082        return false;
1083    }
1084
1085    let due = check_due(registry);
1086
1087    if due {
1088        // The result is deliberately ignored: `refresh_latest` moves the timestamp even
1089        // when the request fails, and retrying on every command while the machine is
1090        // offline would put a five-second stall in front of everyday work.
1091        let _ = refresh_latest(registry);
1092    }
1093
1094    if let Some(latest) = registry.latest_known_version.as_deref()
1095        && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
1096    {
1097        if registry.settings.version_lock {
1098            output::print_info(&locked_notice(Some(latest)));
1099        } else {
1100            output::print_info(&format!(
1101                "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
1102                 `devp config set update_check false` silences this.",
1103                constants::VERSION
1104            ));
1105        }
1106    }
1107
1108    due
1109}
1110
1111/// Whether the periodic release check should run now.
1112///
1113/// A check that failed gets one day, not the whole interval: the timestamp still
1114/// advances on failure (so an offline machine is not stalled on every command), but the
1115/// version it froze is stale, and `maybe_auto_update` trusts nothing else — one
1116/// timed-out request otherwise silences the auto-update for a week.
1117fn check_due(registry: &Registry) -> bool {
1118    let interval = if registry.last_update_check_failed {
1119        registry.settings.update_check_interval_days.min(1)
1120    } else {
1121        registry.settings.update_check_interval_days
1122    };
1123    registry
1124        .last_update_check
1125        .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval)
1126}
1127
1128/// Ask GitHub for the latest release and record the answer on the registry.
1129///
1130/// The caller is responsible for saving; that keeps this usable from both the
1131/// already-loaded-registry path and the standalone command.
1132fn refresh_latest(registry: &mut Registry) -> Result<String> {
1133    let result = latest_release(registry.settings.update_check_timeout_secs);
1134    registry.last_update_check = Some(Utc::now());
1135    registry.last_update_check_failed = result.is_err();
1136    let latest = result?;
1137    registry.latest_known_version = Some(latest.clone());
1138    Ok(latest)
1139}
1140
1141/// Say whether the installed build is behind, current, or ahead of the latest release.
1142fn report_comparison(latest: &str) {
1143    let installed = constants::VERSION;
1144    match compare_versions(installed, latest) {
1145        Some(Ordering::Less) => {
1146            output::print_warning(&format!(
1147                "Latest release:    v{latest} — an upgrade is available."
1148            ));
1149        }
1150        Some(Ordering::Equal) => {
1151            output::print_success(&format!(
1152                "Latest release:    v{latest} — you are up to date."
1153            ));
1154        }
1155        Some(Ordering::Greater) => {
1156            // Normal when running a local build between releases.
1157            output::print_info(&format!(
1158                "Latest release:    v{latest} — your build is newer than the last published one."
1159            ));
1160        }
1161        None => {
1162            output::print_info(&format!(
1163                "Latest release:    v{latest} (could not compare it to v{installed})."
1164            ));
1165        }
1166    }
1167}
1168
1169/// Fetch the tag name of the most recent published release.
1170///
1171/// Returns the version without any leading `v`, so it can be compared to
1172/// `CARGO_PKG_VERSION` directly.
1173///
1174/// The tag comes from the `Location` of the redirect GitHub answers with, not from a
1175/// page body: `max_redirects(0)` hands the `302` back as an ordinary response, so the
1176/// release page itself is never downloaded and no API quota is spent (see
1177/// [`constants::LATEST_RELEASE_URL`]).
1178fn latest_release(timeout_secs: u64) -> Result<String> {
1179    if crate::setup::offline_requested() {
1180        anyhow::bail!("{} is set", constants::ENV_OFFLINE);
1181    }
1182    let response = ureq::get(constants::LATEST_RELEASE_URL)
1183        .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
1184        .config()
1185        .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
1186        .max_redirects(0)
1187        .build()
1188        .call()
1189        .context("request failed")?;
1190
1191    let location = response
1192        .headers()
1193        .get("location")
1194        .and_then(|v| v.to_str().ok())
1195        .context("GitHub did not answer with a redirect")?;
1196
1197    version_from_release_location(location).context("GitHub did not redirect to a release tag")
1198}
1199
1200/// Read the version out of the URL GitHub redirects `releases/latest` to.
1201///
1202/// A repository with no releases answers with the releases page itself, and a renamed
1203/// repository answers with the new repository's `releases/latest`; neither carries a tag
1204/// and neither must be mistaken for one.
1205fn version_from_release_location(location: &str) -> Option<String> {
1206    let tag = location
1207        .split_once("/releases/tag/")?
1208        .1
1209        .trim_end_matches('/');
1210    if tag.is_empty() || tag.contains('/') {
1211        return None;
1212    }
1213    Some(tag.trim_start_matches('v').to_string())
1214}
1215
1216/// Compare two dotted numeric versions, ignoring any pre-release suffix.
1217///
1218/// Returns `None` when either side is not `major.minor.patch` — better to say "could not
1219/// compare" than to claim an upgrade exists because `1.0.0` sorts before `1.0.0-rc.1`
1220/// as a string.
1221pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
1222    let parse = |v: &str| -> Option<[u64; 3]> {
1223        let core = v.split(['-', '+']).next()?;
1224        let mut parts = core.split('.');
1225        let out = [
1226            parts.next()?.parse().ok()?,
1227            parts.next()?.parse().ok()?,
1228            parts.next()?.parse().ok()?,
1229        ];
1230        // A fourth component means this is not the scheme we release under.
1231        if parts.next().is_some() {
1232            return None;
1233        }
1234        Some(out)
1235    };
1236    Some(parse(a)?.cmp(&parse(b)?))
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242    use chrono::Duration as ChronoDuration;
1243
1244    #[test]
1245    fn the_tag_is_read_out_of_the_redirect_and_its_v_is_dropped() {
1246        assert_eq!(
1247            version_from_release_location(
1248                "https://github.com/Life-Experimentalist/dev-prune/releases/tag/v1.18.0"
1249            )
1250            .as_deref(),
1251            Some("1.18.0")
1252        );
1253        assert_eq!(
1254            version_from_release_location("/Life-Experimentalist/dev-prune/releases/tag/1.2.3/")
1255                .as_deref(),
1256            Some("1.2.3")
1257        );
1258    }
1259
1260    #[test]
1261    fn a_redirect_that_is_not_a_release_tag_is_not_a_version() {
1262        // No releases yet: GitHub sends the releases page itself.
1263        assert_eq!(
1264            version_from_release_location(
1265                "https://github.com/Life-Experimentalist/dev-prune/releases"
1266            ),
1267            None
1268        );
1269        // A renamed repository redirects to the new name's `releases/latest`.
1270        assert_eq!(
1271            version_from_release_location("https://github.com/other/name/releases/latest"),
1272            None
1273        );
1274        assert_eq!(
1275            version_from_release_location("https://github.com/o/r/releases/tag/"),
1276            None
1277        );
1278        assert_eq!(
1279            version_from_release_location("https://github.com/o/r/releases/tag/v1/extra"),
1280            None
1281        );
1282    }
1283
1284    #[test]
1285    fn orders_by_component_not_lexically() {
1286        // "1.10.0" < "1.9.0" as strings, which is the bug this function exists to avoid.
1287        assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
1288        assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
1289        assert_eq!(
1290            compare_versions("2.0.0", "1.99.99"),
1291            Some(Ordering::Greater)
1292        );
1293    }
1294
1295    #[test]
1296    fn pre_release_suffixes_compare_by_their_core() {
1297        assert_eq!(
1298            compare_versions("1.0.0", "1.0.0-rc.1"),
1299            Some(Ordering::Equal)
1300        );
1301        assert_eq!(
1302            compare_versions("1.0.0+build7", "1.0.1"),
1303            Some(Ordering::Less)
1304        );
1305    }
1306
1307    #[test]
1308    fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
1309        assert_eq!(compare_versions("1.0", "1.0.0"), None);
1310        assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
1311        assert_eq!(compare_versions("nightly", "1.0.0"), None);
1312    }
1313
1314    fn bin_names() -> [&'static str; 2] {
1315        if cfg!(windows) {
1316            ["dev-prune.exe", "devp.exe"]
1317        } else {
1318            ["dev-prune", "devp"]
1319        }
1320    }
1321
1322    #[test]
1323    fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
1324        // The 1.12.0 bug: `devp update --install` typed at a cargo-installed `devp`
1325        // upgraded the managed pair and the running file, and left the `dev-prune`
1326        // beside it on the previous release.
1327        let [prune, devp] = bin_names();
1328        let managed_dir = tempfile::tempdir().unwrap();
1329        let cargo_dir = tempfile::tempdir().unwrap();
1330        let primary = managed_dir.path().join(prune);
1331        let exe = cargo_dir.path().join(devp);
1332        std::fs::write(&exe, b"old").unwrap();
1333        std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
1334
1335        let also = companion_copies(&primary, true, &exe, true);
1336        assert!(also.contains(&managed_dir.path().join(devp)));
1337        assert!(also.contains(&exe));
1338        assert!(also.contains(&cargo_dir.path().join(prune)));
1339        assert!(!also.contains(&primary));
1340    }
1341
1342    #[test]
1343    fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
1344        // The managed directory owns both names; the running copy's directory belongs
1345        // to whatever installed it, so a missing twin there stays missing.
1346        let [prune, devp] = bin_names();
1347        let managed_dir = tempfile::tempdir().unwrap();
1348        let solo_dir = tempfile::tempdir().unwrap();
1349        let primary = managed_dir.path().join(prune);
1350        let exe = solo_dir.path().join(devp);
1351        std::fs::write(&exe, b"old").unwrap();
1352
1353        let also = companion_copies(&primary, true, &exe, true);
1354        assert!(also.contains(&managed_dir.path().join(devp)));
1355        assert!(also.contains(&exe));
1356        assert!(!also.contains(&solo_dir.path().join(prune)));
1357    }
1358
1359    #[test]
1360    fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
1361        // replace_exe_dir is false for WinGet/Scoop/Homebrew copies; nothing in the
1362        // running copy's directory may be rewritten, twins included.
1363        let [prune, devp] = bin_names();
1364        let managed_dir = tempfile::tempdir().unwrap();
1365        let store_dir = tempfile::tempdir().unwrap();
1366        let primary = managed_dir.path().join(prune);
1367        let exe = store_dir.path().join(devp);
1368        std::fs::write(&exe, b"old").unwrap();
1369        std::fs::write(store_dir.path().join(prune), b"old").unwrap();
1370
1371        let also = companion_copies(&primary, true, &exe, false);
1372        assert_eq!(also, vec![managed_dir.path().join(devp)]);
1373    }
1374
1375    #[test]
1376    fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
1377        // The trio because the next `winget upgrade` would undo the write anyway, and
1378        // Foreign because overwriting a shim or a store path desyncs a manager this
1379        // tool has no resync command for. Everything else is a plain file the direct
1380        // route may replace.
1381        for owned in [
1382            Channel::WinGet,
1383            Channel::Scoop,
1384            Channel::Homebrew,
1385            Channel::Foreign("Volta"),
1386            Channel::Foreign("the system package manager"),
1387        ] {
1388            assert!(manager_owns_exe_dir(owned), "{owned:?}");
1389        }
1390        for replaceable in [
1391            Channel::Installer,
1392            Channel::Cargo,
1393            Channel::Npm,
1394            Channel::Bun,
1395            Channel::Pnpm,
1396            Channel::Yarn,
1397            Channel::UvTool,
1398            Channel::Pipx,
1399            Channel::Pip,
1400            Channel::Unknown,
1401        ] {
1402            assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
1403        }
1404    }
1405
1406    #[test]
1407    fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
1408        let [prune, devp] = bin_names();
1409        let dir = tempfile::tempdir().unwrap();
1410        let primary = dir.path().join(devp);
1411        std::fs::write(&primary, b"old").unwrap();
1412
1413        // Alone, its absent `dev-prune` twin is not created…
1414        assert!(companion_copies(&primary, false, &primary, false).is_empty());
1415
1416        // …but a twin that exists is stale the moment the primary is replaced.
1417        std::fs::write(dir.path().join(prune), b"old").unwrap();
1418        assert_eq!(
1419            companion_copies(&primary, false, &primary, false),
1420            vec![dir.path().join(prune)]
1421        );
1422    }
1423
1424    fn stamped(version: &str) -> Vec<u8> {
1425        format!("{}{version}/end", constants::VERSION_STAMP_MARK).into_bytes()
1426    }
1427
1428    #[test]
1429    fn an_update_run_by_the_managed_copy_still_reaches_the_cargo_copies() {
1430        // The scheduler runs the managed copy, so exe == primary and companion_copies
1431        // stops at the managed directory. The cargo pair on PATH must not be left on
1432        // the release that installed it.
1433        let [prune, devp] = bin_names();
1434        let managed_dir = tempfile::tempdir().unwrap();
1435        let cargo_bin = tempfile::tempdir().unwrap();
1436        let managed = managed_dir.path().join(prune);
1437        std::fs::write(&managed, stamped("1.0.0")).unwrap();
1438        std::fs::write(cargo_bin.path().join(prune), stamped("1.0.0")).unwrap();
1439        std::fs::write(cargo_bin.path().join(devp), b"built before stamping").unwrap();
1440
1441        let also = companion_copies(&managed, true, &managed, false);
1442        assert!(!also.iter().any(|p| p.starts_with(cargo_bin.path())));
1443
1444        let mut skip = also.clone();
1445        skip.push(managed.clone());
1446        let cargo = cargo_channel_copies(cargo_bin.path(), "1.1.0", &skip);
1447        assert_eq!(
1448            cargo,
1449            vec![cargo_bin.path().join(prune), cargo_bin.path().join(devp)]
1450        );
1451    }
1452
1453    #[test]
1454    fn a_cargo_copy_already_current_or_newer_is_not_downgraded() {
1455        // `cargo install --path .` leaves a development build here, usually ahead of
1456        // the latest release.
1457        let [prune, devp] = bin_names();
1458        let cargo_bin = tempfile::tempdir().unwrap();
1459        std::fs::write(cargo_bin.path().join(prune), stamped("1.2.0")).unwrap();
1460        std::fs::write(cargo_bin.path().join(devp), stamped("1.1.0")).unwrap();
1461        assert!(cargo_channel_copies(cargo_bin.path(), "1.1.0", &[]).is_empty());
1462    }
1463
1464    #[test]
1465    fn a_cargo_copy_already_being_replaced_is_not_listed_twice() {
1466        // Typed from ~/.cargo/bin, the running copy's directory is the cargo one and
1467        // companion_copies has both files already.
1468        let [prune, devp] = bin_names();
1469        let cargo_bin = tempfile::tempdir().unwrap();
1470        let skip = vec![cargo_bin.path().join(prune), cargo_bin.path().join(devp)];
1471        for path in &skip {
1472            std::fs::write(path, stamped("1.0.0")).unwrap();
1473        }
1474        assert!(cargo_channel_copies(cargo_bin.path(), "1.1.0", &skip).is_empty());
1475    }
1476
1477    #[test]
1478    fn the_check_is_on_unless_the_user_turns_it_off() {
1479        assert!(Registry::default().settings.update_check);
1480    }
1481
1482    #[test]
1483    fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
1484        let mut registry = Registry::default();
1485        registry.settings.update_check = false;
1486        assert!(!notify_if_outdated(&mut registry));
1487        assert!(registry.last_update_check.is_none());
1488    }
1489
1490    #[test]
1491    fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
1492        let registry = Registry::default();
1493        assert!(registry.settings.auto_update);
1494        // No release check has run, so `latest_known_version` is unset and this must
1495        // return without touching the network or the terminal. The default being *on* is
1496        // what makes that early return load-bearing rather than incidental.
1497        assert!(registry.latest_known_version.is_none());
1498        maybe_auto_update(&registry);
1499    }
1500
1501    #[test]
1502    fn the_pin_is_off_until_somebody_asks_for_it() {
1503        // Every other path in this file is written on the assumption that the pin costs
1504        // nothing when nobody has set it, so the default is the part worth asserting.
1505        assert!(!Registry::default().settings.version_lock);
1506    }
1507
1508    #[test]
1509    fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
1510        // Both halves matter. A refusal that does not say which version it is holding
1511        // cannot be audited, and one that does not say how to release it is
1512        // indistinguishable, to the person reading it, from an update path that broke.
1513        let notice = locked_notice(None);
1514        assert!(notice.contains(constants::VERSION), "{notice}");
1515        assert!(
1516            notice.contains("devp config set version_lock false"),
1517            "{notice}"
1518        );
1519        assert!(!notice.contains("is out"), "{notice}");
1520    }
1521
1522    #[test]
1523    fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
1524        // "You are pinned" and "there is a 2.0.0 out that you are not getting" are
1525        // different facts, and the second is the one that makes somebody go and look at
1526        // the setting.
1527        let notice = locked_notice(Some("2.0.0"));
1528        assert!(notice.contains("v2.0.0 is out"), "{notice}");
1529        assert!(notice.contains(constants::VERSION), "{notice}");
1530    }
1531
1532    #[test]
1533    fn a_failed_check_is_retried_after_a_day_not_a_whole_interval() {
1534        let mut registry = Registry {
1535            last_update_check: Some(Utc::now() - ChronoDuration::days(2)),
1536            last_update_check_failed: true,
1537            ..Default::default()
1538        };
1539        // Two days since a *failed* check: due again, well inside the ordinary
1540        // interval. This is the incident: a timed-out check froze the known version at
1541        // 1.14.0, and the next scheduled pass — a day and a half later — trusted it.
1542        assert!(check_due(&registry));
1543        registry.last_update_check_failed = false;
1544        assert!(
1545            !check_due(&registry),
1546            "a successful check keeps the interval"
1547        );
1548    }
1549
1550    #[test]
1551    fn even_a_failed_check_is_not_retried_within_the_day() {
1552        // The failure backoff must not reintroduce the per-command stall it was
1553        // written around: offline for an afternoon means one timeout, not one per
1554        // command.
1555        let registry = Registry {
1556            last_update_check: Some(Utc::now() - ChronoDuration::hours(2)),
1557            last_update_check_failed: true,
1558            ..Default::default()
1559        };
1560        assert!(!check_due(&registry));
1561    }
1562
1563    #[test]
1564    fn a_recent_check_is_not_repeated() {
1565        let mut registry = Registry::default();
1566        let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
1567        registry.last_update_check = Some(stamp);
1568        // No network call, so the stamp survives untouched and nothing needs saving.
1569        assert!(!notify_if_outdated(&mut registry));
1570        assert_eq!(registry.last_update_check, Some(stamp));
1571    }
1572
1573    #[test]
1574    fn the_asset_name_matches_what_the_release_workflow_builds() {
1575        // This string is a contract with `.github/workflows/release.yml`. Getting it
1576        // wrong is not a compile error and not a test failure anywhere else — it is a
1577        // self-update that 404s for every user on the day of a release.
1578        let name = constants::release_asset_name("1.4.0");
1579        let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
1580            ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
1581            ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
1582            ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
1583            ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
1584            ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
1585            ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
1586            ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
1587            // A platform the release does not build for must decline the direct route
1588            // rather than download some other platform's binary.
1589            _ => None,
1590        };
1591        assert_eq!(name.as_deref(), expected);
1592    }
1593
1594    #[cfg(windows)]
1595    #[test]
1596    fn the_windowless_asset_name_matches_what_the_release_workflow_builds() {
1597        // Same contract, same failure mode as the console asset above: a wrong name is
1598        // a scheduler binary that 404s on the day of a release and stays stale.
1599        let name = constants::windowless_release_asset_name("1.4.0");
1600        let expected = match std::env::consts::ARCH {
1601            "x86_64" => Some("devpw-v1.4.0-windows-x64.exe"),
1602            "aarch64" => Some("devpw-v1.4.0-windows-arm64.exe"),
1603            "x86" => Some("devpw-v1.4.0-windows-x86.exe"),
1604            _ => None,
1605        };
1606        assert_eq!(name.as_deref(), expected);
1607    }
1608
1609    #[test]
1610    fn only_windows_has_a_32_bit_asset() {
1611        // The matrix builds `x86` for Windows alone. On a 32-bit Linux there is nothing
1612        // to download, and guessing `x64` would install a binary that cannot run.
1613        let name = constants::release_asset_name("9.9.9");
1614        if std::env::consts::ARCH == "x86" {
1615            assert_eq!(name.is_some(), std::env::consts::OS == "windows");
1616        }
1617    }
1618
1619    #[test]
1620    fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
1621        let digest = "a".repeat(64);
1622        assert_eq!(
1623            parse_sha256_sidecar(&format!("{digest}  dev-prune-v1.4.0-linux-x64\n")).unwrap(),
1624            digest
1625        );
1626        // The Windows step writes it with no trailing newline, and GitHub may serve
1627        // either line ending.
1628        assert_eq!(
1629            parse_sha256_sidecar(&format!("{digest}  asset.exe")).unwrap(),
1630            digest
1631        );
1632        assert_eq!(
1633            parse_sha256_sidecar(&format!("{}  asset\r\n", digest.to_uppercase())).unwrap(),
1634            digest,
1635            "an upper-case digest must compare equal to the one we compute"
1636        );
1637    }
1638
1639    #[test]
1640    fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
1641        // A 404 page, an error blob or a truncated read must fail as "not a digest"
1642        // rather than as a mismatch — the two send the user to very different places.
1643        for bad in [
1644            "",
1645            "   ",
1646            "<!DOCTYPE html>",
1647            "not-a-hash  asset",
1648            &"a".repeat(63),
1649            &"a".repeat(65),
1650            &format!("{}g  asset", "a".repeat(63)),
1651        ] {
1652            assert!(
1653                parse_sha256_sidecar(bad).is_err(),
1654                "{bad:?} must not be accepted as a digest"
1655            );
1656        }
1657    }
1658}