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 for path in companion_copies(primary, managed.is_some(), exe, replace_exe_dir) {
419 if let Err(e) = install_bytes_at(&bytes, &path) {
420 output::print_warning(&format!(
421 "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
422 is, that copy runs the previous version whenever it is the one invoked.",
423 path.display()
424 ));
425 }
426 }
427
428 #[cfg(windows)]
433 {
434 let mut dirs: Vec<&Path> = Vec::new();
435 if let Some(dir) = primary.parent() {
436 dirs.push(dir);
437 }
438 if replace_exe_dir && let Some(dir) = exe.parent() {
439 dirs.push(dir);
440 }
441 reconcile_windowless_twins(latest, &dirs);
442 }
443
444 crate::daemon::refresh_windowless_twin();
449
450 crate::receipt::refresh_after_upgrade(latest);
455 Ok(())
456}
457
458fn manager_owns_exe_dir(channel: Channel) -> bool {
463 channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
464}
465
466fn companion_copies(
481 primary: &Path,
482 primary_is_managed: bool,
483 exe: &Path,
484 replace_exe_dir: bool,
485) -> Vec<PathBuf> {
486 let names: [&str; 2] = if cfg!(windows) {
487 ["dev-prune.exe", "devp.exe"]
488 } else {
489 ["dev-prune", "devp"]
490 };
491 let mut also: Vec<PathBuf> = Vec::new();
492 if let Some(dir) = primary.parent() {
493 for name in names {
494 let twin = dir.join(name);
495 if twin != primary && (primary_is_managed || twin.is_file()) {
496 also.push(twin);
497 }
498 }
499 }
500 if replace_exe_dir {
501 if !also.contains(&exe.to_path_buf()) {
502 also.push(exe.to_path_buf());
503 }
504 if let Some(dir) = exe.parent() {
505 for name in names {
506 let twin = dir.join(name);
507 if twin != *exe && twin != primary && twin.is_file() && !also.contains(&twin) {
508 also.push(twin);
509 }
510 }
511 }
512 }
513 also
514}
515
516fn report_channel_bookkeeping(channel: Channel) {
519 if let Channel::Foreign(manager) = channel {
524 output::print_info(&format!(
525 "The managed copy is now v{}. The copy that {manager} installed was left \
526 exactly as it wrote it — replacing a file inside a manager-owned tree only \
527 makes {manager} and the disk disagree. Upgrade that copy through {manager} \
528 itself.",
529 constants::VERSION
530 ));
531 return;
532 }
533 let Some(resync) = channel
536 .owns_its_files()
537 .then(|| channel.upgrade_command())
538 .flatten()
539 else {
540 return;
541 };
542 if channel.replaces_its_directory() {
543 output::print_info(&format!(
544 "The managed copy is now v{}. The copy {} installed was left exactly as it \
545 wrote it — replacing a file inside a versioned package directory only makes \
546 the manager and the disk disagree. Run `{resync}` to move that one forward \
547 too.",
548 constants::VERSION,
549 channel.label()
550 ));
551 } else {
552 output::print_info(&format!(
553 "The binaries are up to date. `{resync}` also updates that manager's own \
554 record of the version, which still reads v{}.",
555 constants::VERSION
556 ));
557 }
558}
559
560fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
573 let asset = constants::release_asset_name(version).with_context(|| {
574 format!(
575 "no published binary for {}-{}; upgrade through the channel that installed \
576 this copy instead",
577 std::env::consts::OS,
578 std::env::consts::ARCH
579 )
580 })?;
581 fetch_verified_asset(version, &asset)
582}
583
584#[cfg(windows)]
591fn fetch_windowless_binary(version: &str) -> Result<Vec<u8>> {
592 let asset = constants::windowless_release_asset_name(version).with_context(|| {
593 format!(
594 "no published windowless binary for windows-{}",
595 std::env::consts::ARCH
596 )
597 })?;
598 fetch_verified_asset(version, &asset)
599}
600
601fn fetch_verified_asset(version: &str, asset: &str) -> Result<Vec<u8>> {
604 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
605
606 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
607 output::print_info(&format!("Downloading {asset} …"));
608 let bytes = fetch_bytes(&base)?;
609
610 let actual = {
611 use sha2::{Digest, Sha256};
612 use std::fmt::Write as _;
613 let mut h = Sha256::new();
614 h.update(&bytes);
615 h.finalize().iter().fold(String::new(), |mut s, b| {
618 let _ = write!(s, "{b:02x}");
619 s
620 })
621 };
622 if actual != expected {
623 anyhow::bail!(
624 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
625 The download was corrupted or tampered with; nothing was installed."
626 );
627 }
628
629 Ok(bytes)
630}
631
632#[cfg(windows)]
634enum TwinReconcile {
635 UpToDate,
637 Updated,
639 Failed(String),
641}
642
643#[cfg(windows)]
656fn reconcile_windowless_twins(version: &str, dirs: &[&Path]) -> TwinReconcile {
657 let mut stale: Vec<PathBuf> = Vec::new();
658 for dir in dirs {
659 let twin = dir.join(constants::WINDOWS_WINDOWLESS_BIN);
660 if stale.contains(&twin) || !twin.is_file() {
661 continue;
662 }
663 let stamped = fs::read(&twin)
664 .ok()
665 .and_then(|b| crate::commands::trust::version_from_stamp(&b));
666 let up_to_date = stamped
669 .as_deref()
670 .and_then(|v| compare_versions(v, version))
671 .is_some_and(|o| o != Ordering::Less);
672 if !up_to_date {
673 stale.push(twin);
674 }
675 }
676 if stale.is_empty() {
677 return TwinReconcile::UpToDate;
678 }
679
680 let bytes = match fetch_windowless_binary(version) {
681 Ok(b) => b,
682 Err(e) => {
683 let why = format!(
684 "The scheduler binary {} could not be downloaded ({e:#}). The scheduled \
685 pass keeps running the version it has until `devp update --install` or \
686 `devp doctor --fix` succeeds.",
687 constants::WINDOWS_WINDOWLESS_BIN
688 );
689 output::print_warning(&why);
690 return TwinReconcile::Failed(why);
691 }
692 };
693
694 let mut failed = None;
695 for twin in &stale {
696 if let Err(e) = install_bytes_at(&bytes, twin) {
697 let why = format!(
698 "{} could not be replaced ({e:#}); the scheduled pass keeps running the \
699 previous version from that copy.",
700 twin.display()
701 );
702 output::print_warning(&why);
703 failed = Some(why);
704 }
705 }
706 match failed {
707 Some(why) => TwinReconcile::Failed(why),
708 None => {
709 output::print_success(&format!(
710 "Scheduler binary {} updated to v{version}.",
711 constants::WINDOWS_WINDOWLESS_BIN
712 ));
713 TwinReconcile::Updated
714 }
715 }
716}
717
718#[cfg(windows)]
721fn default_twin_dirs() -> Vec<PathBuf> {
722 let mut dirs = Vec::new();
723 if let Ok(managed) = crate::setup::managed_exe_path()
724 && let Some(dir) = managed.parent()
725 {
726 dirs.push(dir.to_path_buf());
727 }
728 if let Ok(exe) = std::env::current_exe()
729 && let Some(dir) = exe.parent()
730 {
731 dirs.push(dir.to_path_buf());
732 }
733 dirs
734}
735
736pub(crate) fn repair_windowless_twins() -> crate::setup::Outcome {
740 #[cfg(windows)]
741 {
742 use crate::setup::Outcome;
743 match Registry::load() {
744 Ok(r) if r.settings.version_lock => {
745 return Outcome::Skipped(
746 "version_lock is set, and nothing dev-prune does replaces a binary \
747 while it is"
748 .to_string(),
749 );
750 }
751 _ => {}
752 }
753 if crate::setup::offline_requested() {
754 return Outcome::Skipped(format!(
755 "{} is set and this repair needs a download",
756 constants::ENV_OFFLINE
757 ));
758 }
759 if std::env::current_exe().is_ok_and(|exe| crate::commands::uninstall::is_dev_build(&exe)) {
764 return Outcome::Skipped(
765 "this is a development build, and its version may not be released yet".to_string(),
766 );
767 }
768 let dirs = default_twin_dirs();
769 let refs: Vec<&Path> = dirs.iter().map(|p| p.as_path()).collect();
770 match reconcile_windowless_twins(constants::VERSION, &refs) {
771 TwinReconcile::UpToDate => Outcome::AlreadyPresent,
772 TwinReconcile::Updated => Outcome::Installed,
773 TwinReconcile::Failed(why) => Outcome::Failed(why),
774 }
775 }
776 #[cfg(not(windows))]
777 {
778 crate::setup::Outcome::AlreadyPresent
781 }
782}
783
784fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
790 let staging = target.with_extension("new");
794 if let Some(parent) = target.parent() {
795 fs::create_dir_all(parent).ok();
796 }
797 if let Err(e) = fs::write(&staging, bytes) {
798 let _ = fs::remove_file(&staging);
801 return Err(e).with_context(|| format!("could not write {}", staging.display()));
802 }
803
804 #[cfg(unix)]
805 {
806 use std::os::unix::fs::PermissionsExt;
807 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
809 }
810
811 replace_binary(&staging, target)
812}
813
814fn fetch_expected_hash(url: &str) -> Result<String> {
816 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
817 parse_sha256_sidecar(&body)
818}
819
820fn parse_sha256_sidecar(body: &str) -> Result<String> {
829 let hash = body
830 .split_whitespace()
831 .next()
832 .context("the checksum sidecar was empty")?
833 .to_ascii_lowercase();
834 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
835 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
836 }
837 Ok(hash)
838}
839
840fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
841 let mut body = ureq::get(url)
842 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
843 .config()
844 .timeout_global(Some(Duration::from_secs(
845 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
846 )))
847 .build()
848 .call()
849 .with_context(|| format!("could not download {url}"))?;
850 let mut buf = Vec::new();
851 body.body_mut()
852 .as_reader()
853 .read_to_end(&mut buf)
854 .with_context(|| format!("could not read {url}"))?;
855 Ok(buf)
856}
857
858fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
861 #[cfg(windows)]
865 let aside = {
866 let aside = target.with_extension("exe.old");
867 let _ = fs::remove_file(&aside);
868 target
869 .exists()
870 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
871 .flatten()
872 };
873
874 match fs::rename(staged, target) {
875 Ok(()) => {
876 #[cfg(windows)]
877 if let Some(aside) = aside {
878 let _ = fs::remove_file(&aside);
879 }
880 Ok(())
881 }
882 Err(e) => {
883 let _ = fs::remove_file(staged);
884 #[cfg(windows)]
885 if let Some(aside) = aside
886 && !target.exists()
887 {
888 let _ = fs::rename(&aside, target);
891 }
892 Err(e).with_context(|| format!("could not install {}", target.display()))
893 }
894 }
895}
896
897fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
900 let Some(argv) = channel.upgrade_argv() else {
901 output::print_warning(
902 "Could not tell which channel installed this binary, so nothing was \
903 changed. Upgrade it yourself with one of:",
904 );
905 print_upgrade_commands();
906 anyhow::bail!("unrecognised install channel");
907 };
908
909 output::print_info(&format!("Running: {}", argv.join(" ")));
910 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
911 .args(&argv[1..])
912 .status()
913 .with_context(|| format!("could not start `{}`", argv[0]))?;
914 if !status.success() {
915 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
916 }
917 Ok(())
918}
919
920pub fn maybe_auto_update(registry: &Registry) {
933 if !registry.settings.auto_update
934 || crate::setup::offline_requested()
935 || crate::setup::no_auto_setup_requested()
936 {
937 return;
938 }
939 let Some(latest) = registry.latest_known_version.as_deref() else {
940 return;
941 };
942 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
943 return;
944 }
945
946 if registry.settings.version_lock {
950 println!();
951 output::print_info(&locked_notice(Some(latest)));
952 return;
953 }
954
955 let Ok(exe) = std::env::current_exe() else {
956 return;
957 };
958 let managed = crate::setup::managed_exe_path().ok();
959 let channel = Channel::detect_at(&exe, managed.as_deref());
960
961 if channel.replaces_its_directory() {
966 return;
967 }
968
969 if matches!(channel, Channel::Foreign(_)) && managed.is_none() {
975 return;
976 }
977
978 println!();
979 output::print_info(&format!(
980 "Updating dev-prune v{} -> v{latest} …",
981 constants::VERSION
982 ));
983 match install_directly(latest, &exe, managed.as_deref(), channel) {
984 Ok(()) => {
985 output::print_success(&format!("dev-prune v{latest} installed."));
986 report_channel_bookkeeping(channel);
987 }
988 Err(e) => output::print_warning(&format!(
989 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
990 `devp config set auto_update false` to stop trying."
991 )),
992 }
993}
994
995pub fn notify_if_outdated(registry: &mut Registry) -> bool {
1001 if !registry.settings.update_check {
1002 return false;
1003 }
1004
1005 let due = check_due(registry);
1006
1007 if due {
1008 let _ = refresh_latest(registry);
1012 }
1013
1014 if let Some(latest) = registry.latest_known_version.as_deref()
1015 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
1016 {
1017 if registry.settings.version_lock {
1018 output::print_info(&locked_notice(Some(latest)));
1019 } else {
1020 output::print_info(&format!(
1021 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
1022 `devp config set update_check false` silences this.",
1023 constants::VERSION
1024 ));
1025 }
1026 }
1027
1028 due
1029}
1030
1031fn check_due(registry: &Registry) -> bool {
1038 let interval = if registry.last_update_check_failed {
1039 registry.settings.update_check_interval_days.min(1)
1040 } else {
1041 registry.settings.update_check_interval_days
1042 };
1043 registry
1044 .last_update_check
1045 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval)
1046}
1047
1048fn refresh_latest(registry: &mut Registry) -> Result<String> {
1053 let result = latest_release(registry.settings.update_check_timeout_secs);
1054 registry.last_update_check = Some(Utc::now());
1055 registry.last_update_check_failed = result.is_err();
1056 let latest = result?;
1057 registry.latest_known_version = Some(latest.clone());
1058 Ok(latest)
1059}
1060
1061fn report_comparison(latest: &str) {
1063 let installed = constants::VERSION;
1064 match compare_versions(installed, latest) {
1065 Some(Ordering::Less) => {
1066 output::print_warning(&format!(
1067 "Latest release: v{latest} — an upgrade is available."
1068 ));
1069 }
1070 Some(Ordering::Equal) => {
1071 output::print_success(&format!(
1072 "Latest release: v{latest} — you are up to date."
1073 ));
1074 }
1075 Some(Ordering::Greater) => {
1076 output::print_info(&format!(
1078 "Latest release: v{latest} — your build is newer than the last published one."
1079 ));
1080 }
1081 None => {
1082 output::print_info(&format!(
1083 "Latest release: v{latest} (could not compare it to v{installed})."
1084 ));
1085 }
1086 }
1087}
1088
1089fn latest_release(timeout_secs: u64) -> Result<String> {
1099 if crate::setup::offline_requested() {
1100 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
1101 }
1102 let response = ureq::get(constants::LATEST_RELEASE_URL)
1103 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
1104 .config()
1105 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
1106 .max_redirects(0)
1107 .build()
1108 .call()
1109 .context("request failed")?;
1110
1111 let location = response
1112 .headers()
1113 .get("location")
1114 .and_then(|v| v.to_str().ok())
1115 .context("GitHub did not answer with a redirect")?;
1116
1117 version_from_release_location(location).context("GitHub did not redirect to a release tag")
1118}
1119
1120fn version_from_release_location(location: &str) -> Option<String> {
1126 let tag = location
1127 .split_once("/releases/tag/")?
1128 .1
1129 .trim_end_matches('/');
1130 if tag.is_empty() || tag.contains('/') {
1131 return None;
1132 }
1133 Some(tag.trim_start_matches('v').to_string())
1134}
1135
1136pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
1142 let parse = |v: &str| -> Option<[u64; 3]> {
1143 let core = v.split(['-', '+']).next()?;
1144 let mut parts = core.split('.');
1145 let out = [
1146 parts.next()?.parse().ok()?,
1147 parts.next()?.parse().ok()?,
1148 parts.next()?.parse().ok()?,
1149 ];
1150 if parts.next().is_some() {
1152 return None;
1153 }
1154 Some(out)
1155 };
1156 Some(parse(a)?.cmp(&parse(b)?))
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162 use chrono::Duration as ChronoDuration;
1163
1164 #[test]
1165 fn the_tag_is_read_out_of_the_redirect_and_its_v_is_dropped() {
1166 assert_eq!(
1167 version_from_release_location(
1168 "https://github.com/Life-Experimentalist/dev-prune/releases/tag/v1.18.0"
1169 )
1170 .as_deref(),
1171 Some("1.18.0")
1172 );
1173 assert_eq!(
1174 version_from_release_location("/Life-Experimentalist/dev-prune/releases/tag/1.2.3/")
1175 .as_deref(),
1176 Some("1.2.3")
1177 );
1178 }
1179
1180 #[test]
1181 fn a_redirect_that_is_not_a_release_tag_is_not_a_version() {
1182 assert_eq!(
1184 version_from_release_location(
1185 "https://github.com/Life-Experimentalist/dev-prune/releases"
1186 ),
1187 None
1188 );
1189 assert_eq!(
1191 version_from_release_location("https://github.com/other/name/releases/latest"),
1192 None
1193 );
1194 assert_eq!(
1195 version_from_release_location("https://github.com/o/r/releases/tag/"),
1196 None
1197 );
1198 assert_eq!(
1199 version_from_release_location("https://github.com/o/r/releases/tag/v1/extra"),
1200 None
1201 );
1202 }
1203
1204 #[test]
1205 fn orders_by_component_not_lexically() {
1206 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
1208 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
1209 assert_eq!(
1210 compare_versions("2.0.0", "1.99.99"),
1211 Some(Ordering::Greater)
1212 );
1213 }
1214
1215 #[test]
1216 fn pre_release_suffixes_compare_by_their_core() {
1217 assert_eq!(
1218 compare_versions("1.0.0", "1.0.0-rc.1"),
1219 Some(Ordering::Equal)
1220 );
1221 assert_eq!(
1222 compare_versions("1.0.0+build7", "1.0.1"),
1223 Some(Ordering::Less)
1224 );
1225 }
1226
1227 #[test]
1228 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
1229 assert_eq!(compare_versions("1.0", "1.0.0"), None);
1230 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
1231 assert_eq!(compare_versions("nightly", "1.0.0"), None);
1232 }
1233
1234 fn bin_names() -> [&'static str; 2] {
1235 if cfg!(windows) {
1236 ["dev-prune.exe", "devp.exe"]
1237 } else {
1238 ["dev-prune", "devp"]
1239 }
1240 }
1241
1242 #[test]
1243 fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
1244 let [prune, devp] = bin_names();
1248 let managed_dir = tempfile::tempdir().unwrap();
1249 let cargo_dir = tempfile::tempdir().unwrap();
1250 let primary = managed_dir.path().join(prune);
1251 let exe = cargo_dir.path().join(devp);
1252 std::fs::write(&exe, b"old").unwrap();
1253 std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
1254
1255 let also = companion_copies(&primary, true, &exe, true);
1256 assert!(also.contains(&managed_dir.path().join(devp)));
1257 assert!(also.contains(&exe));
1258 assert!(also.contains(&cargo_dir.path().join(prune)));
1259 assert!(!also.contains(&primary));
1260 }
1261
1262 #[test]
1263 fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
1264 let [prune, devp] = bin_names();
1267 let managed_dir = tempfile::tempdir().unwrap();
1268 let solo_dir = tempfile::tempdir().unwrap();
1269 let primary = managed_dir.path().join(prune);
1270 let exe = solo_dir.path().join(devp);
1271 std::fs::write(&exe, b"old").unwrap();
1272
1273 let also = companion_copies(&primary, true, &exe, true);
1274 assert!(also.contains(&managed_dir.path().join(devp)));
1275 assert!(also.contains(&exe));
1276 assert!(!also.contains(&solo_dir.path().join(prune)));
1277 }
1278
1279 #[test]
1280 fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
1281 let [prune, devp] = bin_names();
1284 let managed_dir = tempfile::tempdir().unwrap();
1285 let store_dir = tempfile::tempdir().unwrap();
1286 let primary = managed_dir.path().join(prune);
1287 let exe = store_dir.path().join(devp);
1288 std::fs::write(&exe, b"old").unwrap();
1289 std::fs::write(store_dir.path().join(prune), b"old").unwrap();
1290
1291 let also = companion_copies(&primary, true, &exe, false);
1292 assert_eq!(also, vec![managed_dir.path().join(devp)]);
1293 }
1294
1295 #[test]
1296 fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
1297 for owned in [
1302 Channel::WinGet,
1303 Channel::Scoop,
1304 Channel::Homebrew,
1305 Channel::Foreign("Volta"),
1306 Channel::Foreign("the system package manager"),
1307 ] {
1308 assert!(manager_owns_exe_dir(owned), "{owned:?}");
1309 }
1310 for replaceable in [
1311 Channel::Installer,
1312 Channel::Cargo,
1313 Channel::Npm,
1314 Channel::Bun,
1315 Channel::Pnpm,
1316 Channel::Yarn,
1317 Channel::UvTool,
1318 Channel::Pipx,
1319 Channel::Pip,
1320 Channel::Unknown,
1321 ] {
1322 assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
1323 }
1324 }
1325
1326 #[test]
1327 fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
1328 let [prune, devp] = bin_names();
1329 let dir = tempfile::tempdir().unwrap();
1330 let primary = dir.path().join(devp);
1331 std::fs::write(&primary, b"old").unwrap();
1332
1333 assert!(companion_copies(&primary, false, &primary, false).is_empty());
1335
1336 std::fs::write(dir.path().join(prune), b"old").unwrap();
1338 assert_eq!(
1339 companion_copies(&primary, false, &primary, false),
1340 vec![dir.path().join(prune)]
1341 );
1342 }
1343
1344 #[test]
1345 fn the_check_is_on_unless_the_user_turns_it_off() {
1346 assert!(Registry::default().settings.update_check);
1347 }
1348
1349 #[test]
1350 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
1351 let mut registry = Registry::default();
1352 registry.settings.update_check = false;
1353 assert!(!notify_if_outdated(&mut registry));
1354 assert!(registry.last_update_check.is_none());
1355 }
1356
1357 #[test]
1358 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
1359 let registry = Registry::default();
1360 assert!(registry.settings.auto_update);
1361 assert!(registry.latest_known_version.is_none());
1365 maybe_auto_update(®istry);
1366 }
1367
1368 #[test]
1369 fn the_pin_is_off_until_somebody_asks_for_it() {
1370 assert!(!Registry::default().settings.version_lock);
1373 }
1374
1375 #[test]
1376 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
1377 let notice = locked_notice(None);
1381 assert!(notice.contains(constants::VERSION), "{notice}");
1382 assert!(
1383 notice.contains("devp config set version_lock false"),
1384 "{notice}"
1385 );
1386 assert!(!notice.contains("is out"), "{notice}");
1387 }
1388
1389 #[test]
1390 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
1391 let notice = locked_notice(Some("2.0.0"));
1395 assert!(notice.contains("v2.0.0 is out"), "{notice}");
1396 assert!(notice.contains(constants::VERSION), "{notice}");
1397 }
1398
1399 #[test]
1400 fn a_failed_check_is_retried_after_a_day_not_a_whole_interval() {
1401 let mut registry = Registry {
1402 last_update_check: Some(Utc::now() - ChronoDuration::days(2)),
1403 last_update_check_failed: true,
1404 ..Default::default()
1405 };
1406 assert!(check_due(®istry));
1410 registry.last_update_check_failed = false;
1411 assert!(
1412 !check_due(®istry),
1413 "a successful check keeps the interval"
1414 );
1415 }
1416
1417 #[test]
1418 fn even_a_failed_check_is_not_retried_within_the_day() {
1419 let registry = Registry {
1423 last_update_check: Some(Utc::now() - ChronoDuration::hours(2)),
1424 last_update_check_failed: true,
1425 ..Default::default()
1426 };
1427 assert!(!check_due(®istry));
1428 }
1429
1430 #[test]
1431 fn a_recent_check_is_not_repeated() {
1432 let mut registry = Registry::default();
1433 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
1434 registry.last_update_check = Some(stamp);
1435 assert!(!notify_if_outdated(&mut registry));
1437 assert_eq!(registry.last_update_check, Some(stamp));
1438 }
1439
1440 #[test]
1441 fn the_asset_name_matches_what_the_release_workflow_builds() {
1442 let name = constants::release_asset_name("1.4.0");
1446 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
1447 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
1448 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
1449 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
1450 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
1451 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
1452 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
1453 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
1454 _ => None,
1457 };
1458 assert_eq!(name.as_deref(), expected);
1459 }
1460
1461 #[cfg(windows)]
1462 #[test]
1463 fn the_windowless_asset_name_matches_what_the_release_workflow_builds() {
1464 let name = constants::windowless_release_asset_name("1.4.0");
1467 let expected = match std::env::consts::ARCH {
1468 "x86_64" => Some("devpw-v1.4.0-windows-x64.exe"),
1469 "aarch64" => Some("devpw-v1.4.0-windows-arm64.exe"),
1470 "x86" => Some("devpw-v1.4.0-windows-x86.exe"),
1471 _ => None,
1472 };
1473 assert_eq!(name.as_deref(), expected);
1474 }
1475
1476 #[test]
1477 fn only_windows_has_a_32_bit_asset() {
1478 let name = constants::release_asset_name("9.9.9");
1481 if std::env::consts::ARCH == "x86" {
1482 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
1483 }
1484 }
1485
1486 #[test]
1487 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
1488 let digest = "a".repeat(64);
1489 assert_eq!(
1490 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
1491 digest
1492 );
1493 assert_eq!(
1496 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
1497 digest
1498 );
1499 assert_eq!(
1500 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
1501 digest,
1502 "an upper-case digest must compare equal to the one we compute"
1503 );
1504 }
1505
1506 #[test]
1507 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
1508 for bad in [
1511 "",
1512 " ",
1513 "<!DOCTYPE html>",
1514 "not-a-hash asset",
1515 &"a".repeat(63),
1516 &"a".repeat(65),
1517 &format!("{}g asset", "a".repeat(63)),
1518 ] {
1519 assert!(
1520 parse_sha256_sidecar(bad).is_err(),
1521 "{bad:?} must not be accepted as a digest"
1522 );
1523 }
1524 }
1525}