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 return Ok(());
309 }
310 install_latest(&latest)
311}
312
313fn install_latest(latest: &str) -> Result<()> {
318 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
319
320 let exe = std::env::current_exe().context("could not locate the running binary")?;
321 let managed = crate::setup::managed_exe_path().ok();
322 let channel = Channel::detect_at(&exe, managed.as_deref());
323
324 match install_directly(latest, &exe, managed.as_deref(), channel) {
325 Ok(()) => {
326 output::print_success(&format!("dev-prune v{latest} installed."));
327 report_channel_bookkeeping(channel);
328 output::print_info(
329 "The scheduled pass was not interrupted: it runs the managed copy, which \
330 was replaced by atomic rename, so a pass already in flight keeps the \
331 image it loaded and the next one picks up the new binary.",
332 );
333 return Ok(());
334 }
335 Err(e) => output::print_warning(&format!(
336 "Direct download did not work ({e:#}).\nFalling back to the channel that \
337 installed this copy."
338 )),
339 }
340
341 #[cfg(windows)]
346 let aside = {
347 let aside = exe.with_extension("exe.old");
348 let _ = fs::remove_file(&aside);
349 fs::rename(&exe, &aside).ok().map(|_| aside)
350 };
351
352 let result = spawn_channel_upgrade(channel);
353
354 #[cfg(windows)]
355 if let Some(aside) = aside {
356 if result.is_ok() {
357 let _ = fs::remove_file(&aside);
360 } else if !exe.exists() {
361 let _ = fs::rename(&aside, &exe);
364 }
365 }
366 result?;
367
368 output::print_success(&format!("dev-prune v{latest} installed."));
369 output::print_info(
370 "The scheduled pass was not interrupted: it runs the managed copy, which \
371 refreshes itself from the new binary on its next run.",
372 );
373 Ok(())
374}
375
376fn install_directly(
389 latest: &str,
390 exe: &Path,
391 managed: Option<&Path>,
392 channel: Channel,
393) -> Result<()> {
394 let bytes = fetch_release_binary(latest)?;
395 let primary = managed.unwrap_or(exe);
396 install_bytes_at(&bytes, primary)?;
397
398 let replace_exe_dir = primary != exe && exe.is_file() && !manager_owns_exe_dir(channel);
408 for path in companion_copies(primary, managed.is_some(), exe, replace_exe_dir) {
409 if let Err(e) = install_bytes_at(&bytes, &path) {
410 output::print_warning(&format!(
411 "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
412 is, that copy runs the previous version whenever it is the one invoked.",
413 path.display()
414 ));
415 }
416 }
417
418 crate::daemon::refresh_windowless_twin();
421
422 crate::receipt::refresh_after_upgrade(latest);
427 Ok(())
428}
429
430fn manager_owns_exe_dir(channel: Channel) -> bool {
435 channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
436}
437
438fn companion_copies(
453 primary: &Path,
454 primary_is_managed: bool,
455 exe: &Path,
456 replace_exe_dir: bool,
457) -> Vec<PathBuf> {
458 let names: [&str; 2] = if cfg!(windows) {
459 ["dev-prune.exe", "devp.exe"]
460 } else {
461 ["dev-prune", "devp"]
462 };
463 let mut also: Vec<PathBuf> = Vec::new();
464 if let Some(dir) = primary.parent() {
465 for name in names {
466 let twin = dir.join(name);
467 if twin != primary && (primary_is_managed || twin.is_file()) {
468 also.push(twin);
469 }
470 }
471 }
472 if replace_exe_dir {
473 if !also.contains(&exe.to_path_buf()) {
474 also.push(exe.to_path_buf());
475 }
476 if let Some(dir) = exe.parent() {
477 for name in names {
478 let twin = dir.join(name);
479 if twin != *exe && twin != primary && twin.is_file() && !also.contains(&twin) {
480 also.push(twin);
481 }
482 }
483 }
484 }
485 also
486}
487
488fn report_channel_bookkeeping(channel: Channel) {
491 if let Channel::Foreign(manager) = channel {
496 output::print_info(&format!(
497 "The managed copy is now v{}. The copy that {manager} installed was left \
498 exactly as it wrote it — replacing a file inside a manager-owned tree only \
499 makes {manager} and the disk disagree. Upgrade that copy through {manager} \
500 itself.",
501 constants::VERSION
502 ));
503 return;
504 }
505 let Some(resync) = channel
508 .owns_its_files()
509 .then(|| channel.upgrade_command())
510 .flatten()
511 else {
512 return;
513 };
514 if channel.replaces_its_directory() {
515 output::print_info(&format!(
516 "The managed copy is now v{}. The copy {} installed was left exactly as it \
517 wrote it — replacing a file inside a versioned package directory only makes \
518 the manager and the disk disagree. Run `{resync}` to move that one forward \
519 too.",
520 constants::VERSION,
521 channel.label()
522 ));
523 } else {
524 output::print_info(&format!(
525 "The binaries are up to date. `{resync}` also updates that manager's own \
526 record of the version, which still reads v{}.",
527 constants::VERSION
528 ));
529 }
530}
531
532fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
545 let asset = constants::release_asset_name(version).with_context(|| {
546 format!(
547 "no published binary for {}-{}; upgrade through the channel that installed \
548 this copy instead",
549 std::env::consts::OS,
550 std::env::consts::ARCH
551 )
552 })?;
553 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
554
555 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
556 output::print_info(&format!("Downloading {asset} …"));
557 let bytes = fetch_bytes(&base)?;
558
559 let actual = {
560 use sha2::{Digest, Sha256};
561 use std::fmt::Write as _;
562 let mut h = Sha256::new();
563 h.update(&bytes);
564 h.finalize().iter().fold(String::new(), |mut s, b| {
567 let _ = write!(s, "{b:02x}");
568 s
569 })
570 };
571 if actual != expected {
572 anyhow::bail!(
573 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
574 The download was corrupted or tampered with; nothing was installed."
575 );
576 }
577
578 Ok(bytes)
579}
580
581fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
587 let staging = target.with_extension("new");
591 if let Some(parent) = target.parent() {
592 fs::create_dir_all(parent).ok();
593 }
594 if let Err(e) = fs::write(&staging, bytes) {
595 let _ = fs::remove_file(&staging);
598 return Err(e).with_context(|| format!("could not write {}", staging.display()));
599 }
600
601 #[cfg(unix)]
602 {
603 use std::os::unix::fs::PermissionsExt;
604 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
606 }
607
608 replace_binary(&staging, target)
609}
610
611fn fetch_expected_hash(url: &str) -> Result<String> {
613 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
614 parse_sha256_sidecar(&body)
615}
616
617fn parse_sha256_sidecar(body: &str) -> Result<String> {
626 let hash = body
627 .split_whitespace()
628 .next()
629 .context("the checksum sidecar was empty")?
630 .to_ascii_lowercase();
631 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
632 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
633 }
634 Ok(hash)
635}
636
637fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
638 let mut body = ureq::get(url)
639 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
640 .config()
641 .timeout_global(Some(Duration::from_secs(
642 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
643 )))
644 .build()
645 .call()
646 .with_context(|| format!("could not download {url}"))?;
647 let mut buf = Vec::new();
648 body.body_mut()
649 .as_reader()
650 .read_to_end(&mut buf)
651 .with_context(|| format!("could not read {url}"))?;
652 Ok(buf)
653}
654
655fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
658 #[cfg(windows)]
662 let aside = {
663 let aside = target.with_extension("exe.old");
664 let _ = fs::remove_file(&aside);
665 target
666 .exists()
667 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
668 .flatten()
669 };
670
671 match fs::rename(staged, target) {
672 Ok(()) => {
673 #[cfg(windows)]
674 if let Some(aside) = aside {
675 let _ = fs::remove_file(&aside);
676 }
677 Ok(())
678 }
679 Err(e) => {
680 let _ = fs::remove_file(staged);
681 #[cfg(windows)]
682 if let Some(aside) = aside
683 && !target.exists()
684 {
685 let _ = fs::rename(&aside, target);
688 }
689 Err(e).with_context(|| format!("could not install {}", target.display()))
690 }
691 }
692}
693
694fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
697 let Some(argv) = channel.upgrade_argv() else {
698 output::print_warning(
699 "Could not tell which channel installed this binary, so nothing was \
700 changed. Upgrade it yourself with one of:",
701 );
702 print_upgrade_commands();
703 anyhow::bail!("unrecognised install channel");
704 };
705
706 output::print_info(&format!("Running: {}", argv.join(" ")));
707 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
708 .args(&argv[1..])
709 .status()
710 .with_context(|| format!("could not start `{}`", argv[0]))?;
711 if !status.success() {
712 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
713 }
714 Ok(())
715}
716
717pub fn maybe_auto_update(registry: &Registry) {
730 if !registry.settings.auto_update
731 || crate::setup::offline_requested()
732 || crate::setup::no_auto_setup_requested()
733 {
734 return;
735 }
736 let Some(latest) = registry.latest_known_version.as_deref() else {
737 return;
738 };
739 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
740 return;
741 }
742
743 if registry.settings.version_lock {
747 println!();
748 output::print_info(&locked_notice(Some(latest)));
749 return;
750 }
751
752 let Ok(exe) = std::env::current_exe() else {
753 return;
754 };
755 let managed = crate::setup::managed_exe_path().ok();
756 let channel = Channel::detect_at(&exe, managed.as_deref());
757
758 if channel.replaces_its_directory() {
763 return;
764 }
765
766 if matches!(channel, Channel::Foreign(_)) && managed.is_none() {
772 return;
773 }
774
775 println!();
776 output::print_info(&format!(
777 "Updating dev-prune v{} -> v{latest} …",
778 constants::VERSION
779 ));
780 match install_directly(latest, &exe, managed.as_deref(), channel) {
781 Ok(()) => {
782 output::print_success(&format!("dev-prune v{latest} installed."));
783 report_channel_bookkeeping(channel);
784 }
785 Err(e) => output::print_warning(&format!(
786 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
787 `devp config set auto_update false` to stop trying."
788 )),
789 }
790}
791
792pub fn notify_if_outdated(registry: &mut Registry) -> bool {
798 if !registry.settings.update_check {
799 return false;
800 }
801
802 let due = check_due(registry);
803
804 if due {
805 let _ = refresh_latest(registry);
809 }
810
811 if let Some(latest) = registry.latest_known_version.as_deref()
812 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
813 {
814 if registry.settings.version_lock {
815 output::print_info(&locked_notice(Some(latest)));
816 } else {
817 output::print_info(&format!(
818 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
819 `devp config set update_check false` silences this.",
820 constants::VERSION
821 ));
822 }
823 }
824
825 due
826}
827
828fn check_due(registry: &Registry) -> bool {
835 let interval = if registry.last_update_check_failed {
836 registry.settings.update_check_interval_days.min(1)
837 } else {
838 registry.settings.update_check_interval_days
839 };
840 registry
841 .last_update_check
842 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval)
843}
844
845fn refresh_latest(registry: &mut Registry) -> Result<String> {
850 let result = latest_release(registry.settings.update_check_timeout_secs);
851 registry.last_update_check = Some(Utc::now());
852 registry.last_update_check_failed = result.is_err();
853 let latest = result?;
854 registry.latest_known_version = Some(latest.clone());
855 Ok(latest)
856}
857
858fn report_comparison(latest: &str) {
860 let installed = constants::VERSION;
861 match compare_versions(installed, latest) {
862 Some(Ordering::Less) => {
863 output::print_warning(&format!(
864 "Latest release: v{latest} — an upgrade is available."
865 ));
866 }
867 Some(Ordering::Equal) => {
868 output::print_success(&format!(
869 "Latest release: v{latest} — you are up to date."
870 ));
871 }
872 Some(Ordering::Greater) => {
873 output::print_info(&format!(
875 "Latest release: v{latest} — your build is newer than the last published one."
876 ));
877 }
878 None => {
879 output::print_info(&format!(
880 "Latest release: v{latest} (could not compare it to v{installed})."
881 ));
882 }
883 }
884}
885
886fn latest_release(timeout_secs: u64) -> Result<String> {
896 if crate::setup::offline_requested() {
897 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
898 }
899 let response = ureq::get(constants::LATEST_RELEASE_URL)
900 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
901 .config()
902 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
903 .max_redirects(0)
904 .build()
905 .call()
906 .context("request failed")?;
907
908 let location = response
909 .headers()
910 .get("location")
911 .and_then(|v| v.to_str().ok())
912 .context("GitHub did not answer with a redirect")?;
913
914 version_from_release_location(location).context("GitHub did not redirect to a release tag")
915}
916
917fn version_from_release_location(location: &str) -> Option<String> {
923 let tag = location
924 .split_once("/releases/tag/")?
925 .1
926 .trim_end_matches('/');
927 if tag.is_empty() || tag.contains('/') {
928 return None;
929 }
930 Some(tag.trim_start_matches('v').to_string())
931}
932
933pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
939 let parse = |v: &str| -> Option<[u64; 3]> {
940 let core = v.split(['-', '+']).next()?;
941 let mut parts = core.split('.');
942 let out = [
943 parts.next()?.parse().ok()?,
944 parts.next()?.parse().ok()?,
945 parts.next()?.parse().ok()?,
946 ];
947 if parts.next().is_some() {
949 return None;
950 }
951 Some(out)
952 };
953 Some(parse(a)?.cmp(&parse(b)?))
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use chrono::Duration as ChronoDuration;
960
961 #[test]
962 fn the_tag_is_read_out_of_the_redirect_and_its_v_is_dropped() {
963 assert_eq!(
964 version_from_release_location(
965 "https://github.com/Life-Experimentalist/dev-prune/releases/tag/v1.18.0"
966 )
967 .as_deref(),
968 Some("1.18.0")
969 );
970 assert_eq!(
971 version_from_release_location("/Life-Experimentalist/dev-prune/releases/tag/1.2.3/")
972 .as_deref(),
973 Some("1.2.3")
974 );
975 }
976
977 #[test]
978 fn a_redirect_that_is_not_a_release_tag_is_not_a_version() {
979 assert_eq!(
981 version_from_release_location(
982 "https://github.com/Life-Experimentalist/dev-prune/releases"
983 ),
984 None
985 );
986 assert_eq!(
988 version_from_release_location("https://github.com/other/name/releases/latest"),
989 None
990 );
991 assert_eq!(
992 version_from_release_location("https://github.com/o/r/releases/tag/"),
993 None
994 );
995 assert_eq!(
996 version_from_release_location("https://github.com/o/r/releases/tag/v1/extra"),
997 None
998 );
999 }
1000
1001 #[test]
1002 fn orders_by_component_not_lexically() {
1003 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
1005 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
1006 assert_eq!(
1007 compare_versions("2.0.0", "1.99.99"),
1008 Some(Ordering::Greater)
1009 );
1010 }
1011
1012 #[test]
1013 fn pre_release_suffixes_compare_by_their_core() {
1014 assert_eq!(
1015 compare_versions("1.0.0", "1.0.0-rc.1"),
1016 Some(Ordering::Equal)
1017 );
1018 assert_eq!(
1019 compare_versions("1.0.0+build7", "1.0.1"),
1020 Some(Ordering::Less)
1021 );
1022 }
1023
1024 #[test]
1025 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
1026 assert_eq!(compare_versions("1.0", "1.0.0"), None);
1027 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
1028 assert_eq!(compare_versions("nightly", "1.0.0"), None);
1029 }
1030
1031 fn bin_names() -> [&'static str; 2] {
1032 if cfg!(windows) {
1033 ["dev-prune.exe", "devp.exe"]
1034 } else {
1035 ["dev-prune", "devp"]
1036 }
1037 }
1038
1039 #[test]
1040 fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
1041 let [prune, devp] = bin_names();
1045 let managed_dir = tempfile::tempdir().unwrap();
1046 let cargo_dir = tempfile::tempdir().unwrap();
1047 let primary = managed_dir.path().join(prune);
1048 let exe = cargo_dir.path().join(devp);
1049 std::fs::write(&exe, b"old").unwrap();
1050 std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
1051
1052 let also = companion_copies(&primary, true, &exe, true);
1053 assert!(also.contains(&managed_dir.path().join(devp)));
1054 assert!(also.contains(&exe));
1055 assert!(also.contains(&cargo_dir.path().join(prune)));
1056 assert!(!also.contains(&primary));
1057 }
1058
1059 #[test]
1060 fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
1061 let [prune, devp] = bin_names();
1064 let managed_dir = tempfile::tempdir().unwrap();
1065 let solo_dir = tempfile::tempdir().unwrap();
1066 let primary = managed_dir.path().join(prune);
1067 let exe = solo_dir.path().join(devp);
1068 std::fs::write(&exe, b"old").unwrap();
1069
1070 let also = companion_copies(&primary, true, &exe, true);
1071 assert!(also.contains(&managed_dir.path().join(devp)));
1072 assert!(also.contains(&exe));
1073 assert!(!also.contains(&solo_dir.path().join(prune)));
1074 }
1075
1076 #[test]
1077 fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
1078 let [prune, devp] = bin_names();
1081 let managed_dir = tempfile::tempdir().unwrap();
1082 let store_dir = tempfile::tempdir().unwrap();
1083 let primary = managed_dir.path().join(prune);
1084 let exe = store_dir.path().join(devp);
1085 std::fs::write(&exe, b"old").unwrap();
1086 std::fs::write(store_dir.path().join(prune), b"old").unwrap();
1087
1088 let also = companion_copies(&primary, true, &exe, false);
1089 assert_eq!(also, vec![managed_dir.path().join(devp)]);
1090 }
1091
1092 #[test]
1093 fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
1094 for owned in [
1099 Channel::WinGet,
1100 Channel::Scoop,
1101 Channel::Homebrew,
1102 Channel::Foreign("Volta"),
1103 Channel::Foreign("the system package manager"),
1104 ] {
1105 assert!(manager_owns_exe_dir(owned), "{owned:?}");
1106 }
1107 for replaceable in [
1108 Channel::Installer,
1109 Channel::Cargo,
1110 Channel::Npm,
1111 Channel::Bun,
1112 Channel::Pnpm,
1113 Channel::Yarn,
1114 Channel::UvTool,
1115 Channel::Pipx,
1116 Channel::Pip,
1117 Channel::Unknown,
1118 ] {
1119 assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
1120 }
1121 }
1122
1123 #[test]
1124 fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
1125 let [prune, devp] = bin_names();
1126 let dir = tempfile::tempdir().unwrap();
1127 let primary = dir.path().join(devp);
1128 std::fs::write(&primary, b"old").unwrap();
1129
1130 assert!(companion_copies(&primary, false, &primary, false).is_empty());
1132
1133 std::fs::write(dir.path().join(prune), b"old").unwrap();
1135 assert_eq!(
1136 companion_copies(&primary, false, &primary, false),
1137 vec![dir.path().join(prune)]
1138 );
1139 }
1140
1141 #[test]
1142 fn the_check_is_on_unless_the_user_turns_it_off() {
1143 assert!(Registry::default().settings.update_check);
1144 }
1145
1146 #[test]
1147 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
1148 let mut registry = Registry::default();
1149 registry.settings.update_check = false;
1150 assert!(!notify_if_outdated(&mut registry));
1151 assert!(registry.last_update_check.is_none());
1152 }
1153
1154 #[test]
1155 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
1156 let registry = Registry::default();
1157 assert!(registry.settings.auto_update);
1158 assert!(registry.latest_known_version.is_none());
1162 maybe_auto_update(®istry);
1163 }
1164
1165 #[test]
1166 fn the_pin_is_off_until_somebody_asks_for_it() {
1167 assert!(!Registry::default().settings.version_lock);
1170 }
1171
1172 #[test]
1173 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
1174 let notice = locked_notice(None);
1178 assert!(notice.contains(constants::VERSION), "{notice}");
1179 assert!(
1180 notice.contains("devp config set version_lock false"),
1181 "{notice}"
1182 );
1183 assert!(!notice.contains("is out"), "{notice}");
1184 }
1185
1186 #[test]
1187 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
1188 let notice = locked_notice(Some("2.0.0"));
1192 assert!(notice.contains("v2.0.0 is out"), "{notice}");
1193 assert!(notice.contains(constants::VERSION), "{notice}");
1194 }
1195
1196 #[test]
1197 fn a_failed_check_is_retried_after_a_day_not_a_whole_interval() {
1198 let mut registry = Registry {
1199 last_update_check: Some(Utc::now() - ChronoDuration::days(2)),
1200 last_update_check_failed: true,
1201 ..Default::default()
1202 };
1203 assert!(check_due(®istry));
1207 registry.last_update_check_failed = false;
1208 assert!(
1209 !check_due(®istry),
1210 "a successful check keeps the interval"
1211 );
1212 }
1213
1214 #[test]
1215 fn even_a_failed_check_is_not_retried_within_the_day() {
1216 let registry = Registry {
1220 last_update_check: Some(Utc::now() - ChronoDuration::hours(2)),
1221 last_update_check_failed: true,
1222 ..Default::default()
1223 };
1224 assert!(!check_due(®istry));
1225 }
1226
1227 #[test]
1228 fn a_recent_check_is_not_repeated() {
1229 let mut registry = Registry::default();
1230 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
1231 registry.last_update_check = Some(stamp);
1232 assert!(!notify_if_outdated(&mut registry));
1234 assert_eq!(registry.last_update_check, Some(stamp));
1235 }
1236
1237 #[test]
1238 fn the_asset_name_matches_what_the_release_workflow_builds() {
1239 let name = constants::release_asset_name("1.4.0");
1243 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
1244 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
1245 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
1246 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
1247 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
1248 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
1249 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
1250 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
1251 _ => None,
1254 };
1255 assert_eq!(name.as_deref(), expected);
1256 }
1257
1258 #[test]
1259 fn only_windows_has_a_32_bit_asset() {
1260 let name = constants::release_asset_name("9.9.9");
1263 if std::env::consts::ARCH == "x86" {
1264 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
1265 }
1266 }
1267
1268 #[test]
1269 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
1270 let digest = "a".repeat(64);
1271 assert_eq!(
1272 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
1273 digest
1274 );
1275 assert_eq!(
1278 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
1279 digest
1280 );
1281 assert_eq!(
1282 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
1283 digest,
1284 "an upper-case digest must compare equal to the one we compute"
1285 );
1286 }
1287
1288 #[test]
1289 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
1290 for bad in [
1293 "",
1294 " ",
1295 "<!DOCTYPE html>",
1296 "not-a-hash asset",
1297 &"a".repeat(63),
1298 &"a".repeat(65),
1299 &format!("{}g asset", "a".repeat(63)),
1300 ] {
1301 assert!(
1302 parse_sha256_sidecar(bad).is_err(),
1303 "{bad:?} must not be accepted as a digest"
1304 );
1305 }
1306 }
1307}