1use 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 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 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 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 if let Some(latest) = newer
98 && confirm_install(&latest, yes)
99 {
100 println!();
101 return install_latest(&latest);
102 }
103
104 Ok(())
105}
106
107fn 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
131pub(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
151pub 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 Err(e) => output::print_info(&format!("Could not check for a newer release ({e:#}).")),
173 }
174 true
175}
176
177fn 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 println!(" `devp update --channels` lists the command for every channel.");
197 }
198 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
212fn 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
233fn 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
261fn run_install() -> Result<()> {
281 output::print_header("dev-prune self-update");
282
283 let mut registry = Registry::load()?;
284
285 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 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 #[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
323fn 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 #[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 let _ = fs::remove_file(&aside);
370 } else if !exe.exists() {
371 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
386fn 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 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 #[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 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 crate::daemon::refresh_windowless_twin();
475
476 crate::receipt::refresh_after_upgrade(latest);
481 Ok(())
482}
483
484fn manager_owns_exe_dir(channel: Channel) -> bool {
489 channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
490}
491
492fn 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
542fn 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
560fn 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
596fn report_channel_bookkeeping(channel: Channel) {
599 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 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
640fn 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#[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
681fn 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 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#[cfg(windows)]
714enum TwinReconcile {
715 UpToDate,
717 Updated,
719 Failed(String),
721}
722
723#[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 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#[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
816pub(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 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 crate::setup::Outcome::AlreadyPresent
861 }
862}
863
864fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
870 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 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 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
889 }
890
891 replace_binary(&staging, target)
892}
893
894fn 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
900fn 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
938fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
941 #[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 let _ = fs::rename(&aside, target);
971 }
972 Err(e).with_context(|| format!("could not install {}", target.display()))
973 }
974 }
975}
976
977fn 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
1000pub 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 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 if channel.replaces_its_directory() {
1046 return;
1047 }
1048
1049 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
1075pub 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 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
1111fn 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
1128fn 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
1141fn 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 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
1169fn 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
1200fn 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
1216pub(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 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 assert_eq!(
1264 version_from_release_location(
1265 "https://github.com/Life-Experimentalist/dev-prune/releases"
1266 ),
1267 None
1268 );
1269 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 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 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 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 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 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 assert!(companion_copies(&primary, false, &primary, false).is_empty());
1415
1416 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 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 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 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 assert!(registry.latest_known_version.is_none());
1498 maybe_auto_update(®istry);
1499 }
1500
1501 #[test]
1502 fn the_pin_is_off_until_somebody_asks_for_it() {
1503 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 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 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 assert!(check_due(®istry));
1543 registry.last_update_check_failed = false;
1544 assert!(
1545 !check_due(®istry),
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 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(®istry));
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 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 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 _ => 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 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 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 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 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}