Skip to main content

dev_prune/commands/
update.rs

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