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 interval = registry.settings.update_check_interval_days;
803 let due = registry
804 .last_update_check
805 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
806
807 if due {
808 let _ = refresh_latest(registry);
812 }
813
814 if let Some(latest) = registry.latest_known_version.as_deref()
815 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
816 {
817 if registry.settings.version_lock {
818 output::print_info(&locked_notice(Some(latest)));
819 } else {
820 output::print_info(&format!(
821 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
822 `devp config set update_check false` silences this.",
823 constants::VERSION
824 ));
825 }
826 }
827
828 due
829}
830
831fn refresh_latest(registry: &mut Registry) -> Result<String> {
836 let result = latest_release(registry.settings.update_check_timeout_secs);
837 registry.last_update_check = Some(Utc::now());
838 let latest = result?;
839 registry.latest_known_version = Some(latest.clone());
840 Ok(latest)
841}
842
843fn report_comparison(latest: &str) {
845 let installed = constants::VERSION;
846 match compare_versions(installed, latest) {
847 Some(Ordering::Less) => {
848 output::print_warning(&format!(
849 "Latest release: v{latest} — an upgrade is available."
850 ));
851 }
852 Some(Ordering::Equal) => {
853 output::print_success(&format!(
854 "Latest release: v{latest} — you are up to date."
855 ));
856 }
857 Some(Ordering::Greater) => {
858 output::print_info(&format!(
860 "Latest release: v{latest} — your build is newer than the last published one."
861 ));
862 }
863 None => {
864 output::print_info(&format!(
865 "Latest release: v{latest} (could not compare it to v{installed})."
866 ));
867 }
868 }
869}
870
871fn latest_release(timeout_secs: u64) -> Result<String> {
881 if crate::setup::offline_requested() {
882 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
883 }
884 let response = ureq::get(constants::LATEST_RELEASE_URL)
885 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
886 .config()
887 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
888 .max_redirects(0)
889 .build()
890 .call()
891 .context("request failed")?;
892
893 let location = response
894 .headers()
895 .get("location")
896 .and_then(|v| v.to_str().ok())
897 .context("GitHub did not answer with a redirect")?;
898
899 version_from_release_location(location).context("GitHub did not redirect to a release tag")
900}
901
902fn version_from_release_location(location: &str) -> Option<String> {
908 let tag = location
909 .split_once("/releases/tag/")?
910 .1
911 .trim_end_matches('/');
912 if tag.is_empty() || tag.contains('/') {
913 return None;
914 }
915 Some(tag.trim_start_matches('v').to_string())
916}
917
918pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
924 let parse = |v: &str| -> Option<[u64; 3]> {
925 let core = v.split(['-', '+']).next()?;
926 let mut parts = core.split('.');
927 let out = [
928 parts.next()?.parse().ok()?,
929 parts.next()?.parse().ok()?,
930 parts.next()?.parse().ok()?,
931 ];
932 if parts.next().is_some() {
934 return None;
935 }
936 Some(out)
937 };
938 Some(parse(a)?.cmp(&parse(b)?))
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use chrono::Duration as ChronoDuration;
945
946 #[test]
947 fn the_tag_is_read_out_of_the_redirect_and_its_v_is_dropped() {
948 assert_eq!(
949 version_from_release_location(
950 "https://github.com/Life-Experimentalist/dev-prune/releases/tag/v1.18.0"
951 )
952 .as_deref(),
953 Some("1.18.0")
954 );
955 assert_eq!(
956 version_from_release_location("/Life-Experimentalist/dev-prune/releases/tag/1.2.3/")
957 .as_deref(),
958 Some("1.2.3")
959 );
960 }
961
962 #[test]
963 fn a_redirect_that_is_not_a_release_tag_is_not_a_version() {
964 assert_eq!(
966 version_from_release_location(
967 "https://github.com/Life-Experimentalist/dev-prune/releases"
968 ),
969 None
970 );
971 assert_eq!(
973 version_from_release_location("https://github.com/other/name/releases/latest"),
974 None
975 );
976 assert_eq!(
977 version_from_release_location("https://github.com/o/r/releases/tag/"),
978 None
979 );
980 assert_eq!(
981 version_from_release_location("https://github.com/o/r/releases/tag/v1/extra"),
982 None
983 );
984 }
985
986 #[test]
987 fn orders_by_component_not_lexically() {
988 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
990 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
991 assert_eq!(
992 compare_versions("2.0.0", "1.99.99"),
993 Some(Ordering::Greater)
994 );
995 }
996
997 #[test]
998 fn pre_release_suffixes_compare_by_their_core() {
999 assert_eq!(
1000 compare_versions("1.0.0", "1.0.0-rc.1"),
1001 Some(Ordering::Equal)
1002 );
1003 assert_eq!(
1004 compare_versions("1.0.0+build7", "1.0.1"),
1005 Some(Ordering::Less)
1006 );
1007 }
1008
1009 #[test]
1010 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
1011 assert_eq!(compare_versions("1.0", "1.0.0"), None);
1012 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
1013 assert_eq!(compare_versions("nightly", "1.0.0"), None);
1014 }
1015
1016 fn bin_names() -> [&'static str; 2] {
1017 if cfg!(windows) {
1018 ["dev-prune.exe", "devp.exe"]
1019 } else {
1020 ["dev-prune", "devp"]
1021 }
1022 }
1023
1024 #[test]
1025 fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
1026 let [prune, devp] = bin_names();
1030 let managed_dir = tempfile::tempdir().unwrap();
1031 let cargo_dir = tempfile::tempdir().unwrap();
1032 let primary = managed_dir.path().join(prune);
1033 let exe = cargo_dir.path().join(devp);
1034 std::fs::write(&exe, b"old").unwrap();
1035 std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
1036
1037 let also = companion_copies(&primary, true, &exe, true);
1038 assert!(also.contains(&managed_dir.path().join(devp)));
1039 assert!(also.contains(&exe));
1040 assert!(also.contains(&cargo_dir.path().join(prune)));
1041 assert!(!also.contains(&primary));
1042 }
1043
1044 #[test]
1045 fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
1046 let [prune, devp] = bin_names();
1049 let managed_dir = tempfile::tempdir().unwrap();
1050 let solo_dir = tempfile::tempdir().unwrap();
1051 let primary = managed_dir.path().join(prune);
1052 let exe = solo_dir.path().join(devp);
1053 std::fs::write(&exe, b"old").unwrap();
1054
1055 let also = companion_copies(&primary, true, &exe, true);
1056 assert!(also.contains(&managed_dir.path().join(devp)));
1057 assert!(also.contains(&exe));
1058 assert!(!also.contains(&solo_dir.path().join(prune)));
1059 }
1060
1061 #[test]
1062 fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
1063 let [prune, devp] = bin_names();
1066 let managed_dir = tempfile::tempdir().unwrap();
1067 let store_dir = tempfile::tempdir().unwrap();
1068 let primary = managed_dir.path().join(prune);
1069 let exe = store_dir.path().join(devp);
1070 std::fs::write(&exe, b"old").unwrap();
1071 std::fs::write(store_dir.path().join(prune), b"old").unwrap();
1072
1073 let also = companion_copies(&primary, true, &exe, false);
1074 assert_eq!(also, vec![managed_dir.path().join(devp)]);
1075 }
1076
1077 #[test]
1078 fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
1079 for owned in [
1084 Channel::WinGet,
1085 Channel::Scoop,
1086 Channel::Homebrew,
1087 Channel::Foreign("Volta"),
1088 Channel::Foreign("the system package manager"),
1089 ] {
1090 assert!(manager_owns_exe_dir(owned), "{owned:?}");
1091 }
1092 for replaceable in [
1093 Channel::Installer,
1094 Channel::Cargo,
1095 Channel::Npm,
1096 Channel::Bun,
1097 Channel::Pnpm,
1098 Channel::Yarn,
1099 Channel::UvTool,
1100 Channel::Pipx,
1101 Channel::Pip,
1102 Channel::Unknown,
1103 ] {
1104 assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
1105 }
1106 }
1107
1108 #[test]
1109 fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
1110 let [prune, devp] = bin_names();
1111 let dir = tempfile::tempdir().unwrap();
1112 let primary = dir.path().join(devp);
1113 std::fs::write(&primary, b"old").unwrap();
1114
1115 assert!(companion_copies(&primary, false, &primary, false).is_empty());
1117
1118 std::fs::write(dir.path().join(prune), b"old").unwrap();
1120 assert_eq!(
1121 companion_copies(&primary, false, &primary, false),
1122 vec![dir.path().join(prune)]
1123 );
1124 }
1125
1126 #[test]
1127 fn the_check_is_on_unless_the_user_turns_it_off() {
1128 assert!(Registry::default().settings.update_check);
1129 }
1130
1131 #[test]
1132 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
1133 let mut registry = Registry::default();
1134 registry.settings.update_check = false;
1135 assert!(!notify_if_outdated(&mut registry));
1136 assert!(registry.last_update_check.is_none());
1137 }
1138
1139 #[test]
1140 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
1141 let registry = Registry::default();
1142 assert!(registry.settings.auto_update);
1143 assert!(registry.latest_known_version.is_none());
1147 maybe_auto_update(®istry);
1148 }
1149
1150 #[test]
1151 fn the_pin_is_off_until_somebody_asks_for_it() {
1152 assert!(!Registry::default().settings.version_lock);
1155 }
1156
1157 #[test]
1158 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
1159 let notice = locked_notice(None);
1163 assert!(notice.contains(constants::VERSION), "{notice}");
1164 assert!(
1165 notice.contains("devp config set version_lock false"),
1166 "{notice}"
1167 );
1168 assert!(!notice.contains("is out"), "{notice}");
1169 }
1170
1171 #[test]
1172 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
1173 let notice = locked_notice(Some("2.0.0"));
1177 assert!(notice.contains("v2.0.0 is out"), "{notice}");
1178 assert!(notice.contains(constants::VERSION), "{notice}");
1179 }
1180
1181 #[test]
1182 fn a_recent_check_is_not_repeated() {
1183 let mut registry = Registry::default();
1184 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
1185 registry.last_update_check = Some(stamp);
1186 assert!(!notify_if_outdated(&mut registry));
1188 assert_eq!(registry.last_update_check, Some(stamp));
1189 }
1190
1191 #[test]
1192 fn the_asset_name_matches_what_the_release_workflow_builds() {
1193 let name = constants::release_asset_name("1.4.0");
1197 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
1198 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
1199 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
1200 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
1201 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
1202 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
1203 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
1204 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
1205 _ => None,
1208 };
1209 assert_eq!(name.as_deref(), expected);
1210 }
1211
1212 #[test]
1213 fn only_windows_has_a_32_bit_asset() {
1214 let name = constants::release_asset_name("9.9.9");
1217 if std::env::consts::ARCH == "x86" {
1218 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
1219 }
1220 }
1221
1222 #[test]
1223 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
1224 let digest = "a".repeat(64);
1225 assert_eq!(
1226 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
1227 digest
1228 );
1229 assert_eq!(
1232 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
1233 digest
1234 );
1235 assert_eq!(
1236 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
1237 digest,
1238 "an upper-case digest must compare equal to the one we compute"
1239 );
1240 }
1241
1242 #[test]
1243 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
1244 for bad in [
1247 "",
1248 " ",
1249 "<!DOCTYPE html>",
1250 "not-a-hash asset",
1251 &"a".repeat(63),
1252 &"a".repeat(65),
1253 &format!("{}g asset", "a".repeat(63)),
1254 ] {
1255 assert!(
1256 parse_sha256_sidecar(bad).is_err(),
1257 "{bad:?} must not be accepted as a digest"
1258 );
1259 }
1260 }
1261}