1use std::cmp::Ordering;
27use std::fs;
28use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32use anyhow::{Context, Result};
33use chrono::Utc;
34
35use crate::channel::Channel;
36use crate::config::Registry;
37use crate::constants;
38use crate::output;
39
40pub fn run(offline: bool, install: bool, channels: bool) -> Result<()> {
41 if install {
42 return run_install();
43 }
44 if channels {
45 return run_channels();
46 }
47 output::print_header("dev-prune version & upgrade");
48
49 output::print_info(&format!("Installed version: v{}", constants::VERSION));
50
51 let mut registry = Registry::load().ok();
52
53 if offline {
54 output::print_info("Skipping the release check because `--offline` was passed.");
55 } else if let Some(reg) = registry.as_mut() {
56 if reg.settings.update_check {
57 match refresh_latest(reg) {
60 Ok(latest) => report_comparison(&latest),
61 Err(e) => output::print_warning(&format!(
64 "Could not reach the release API ({e}). The upgrade commands below still apply."
65 )),
66 }
67 let _ = reg.save();
68 } else {
69 output::print_info(
70 "The release check is off (`devp config set update_check true` re-enables it).",
71 );
72 }
73 }
74
75 println!();
76 println!(" Latest releases: {}", constants::RELEASES_URL);
77 println!();
78
79 if registry.is_some_and(|r| r.settings.version_lock) {
83 output::print_info(&locked_notice(None));
84 } else {
85 print_upgrade_commands();
86 }
87
88 Ok(())
89}
90
91pub(crate) fn locked_notice(latest: Option<&str>) -> String {
100 let head = match latest {
101 Some(latest) => format!("dev-prune v{latest} is out. "),
102 None => String::new(),
103 };
104 format!(
105 "{head}`version_lock` is on, so this copy stays at v{}. \
106 `devp config set version_lock false` releases it.",
107 constants::VERSION
108 )
109}
110
111pub fn check_now(registry: &mut Registry) -> bool {
120 if !registry.settings.update_check {
121 return false;
122 }
123
124 match refresh_latest(registry) {
125 Ok(latest) => {
126 report_comparison(&latest);
127 if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
128 print_upgrade_commands();
129 }
130 }
131 Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
133 }
134 true
135}
136
137fn print_upgrade_commands() {
145 let channel = Channel::detect();
146 match channel.upgrade_command() {
147 Some(command) => {
148 println!(" Installed with {} — upgrade with:", channel.label());
149 println!(" {command}");
150 println!();
151 println!(" Or `devp update --install` to let dev-prune do it for you.");
152 println!();
153 println!(" `devp update --channels` lists the command for every channel.");
157 }
158 None => {
162 println!(" This copy is not in a location any install channel owns, so there");
163 println!(" is no package manager to name. Replace it in place with:");
164 println!(" devp update --install");
165 println!();
166 println!(" Or install through a channel, which keeps it upgradeable:");
167 print_every_upgrade_command();
168 }
169 }
170}
171
172fn run_channels() -> Result<()> {
179 output::print_header("dev-prune upgrade commands");
180 let current = Channel::detect();
181 println!();
182 println!(" This copy came from {}.", current.label());
183 println!();
184 print_every_upgrade_command();
185 println!();
186 output::print_info(
187 "`devp update --install` replaces this copy directly, without the manager. \
188 `devp install --channel <name>` moves it to a different one.",
189 );
190 Ok(())
191}
192
193fn print_every_upgrade_command() {
199 let channels = [
200 Channel::Installer,
201 Channel::Cargo,
202 Channel::Npm,
203 Channel::Bun,
204 Channel::Pnpm,
205 Channel::Yarn,
206 Channel::UvTool,
207 Channel::Pipx,
208 Channel::Pip,
209 Channel::WinGet,
210 Channel::Scoop,
211 Channel::Homebrew,
212 ];
213 let width = channels.iter().map(|c| c.label().len()).max().unwrap_or(0);
214 for channel in channels {
215 if let Some(command) = channel.upgrade_command() {
216 println!(" {:<width$} {command}", channel.label());
217 }
218 }
219}
220
221fn run_install() -> Result<()> {
241 output::print_header("dev-prune self-update");
242
243 let mut registry = Registry::load()?;
244
245 if registry.settings.version_lock {
248 anyhow::bail!("{}", locked_notice(None));
249 }
250
251 if crate::setup::offline_requested() {
252 anyhow::bail!(
253 "{} is set — an install needs the network by definition.",
254 constants::ENV_OFFLINE
255 );
256 }
257
258 let latest = refresh_latest(&mut registry)?;
262 let _ = registry.save();
263 if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
264 output::print_success(&format!(
265 "v{} is already the latest release — nothing to install.",
266 constants::VERSION
267 ));
268 return Ok(());
269 }
270 output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
271
272 let exe = std::env::current_exe().context("could not locate the running binary")?;
273 let managed = crate::setup::managed_exe_path().ok();
274 let channel = Channel::detect_at(&exe, managed.as_deref());
275
276 match install_directly(&latest, &exe, managed.as_deref(), channel) {
277 Ok(()) => {
278 output::print_success(&format!("dev-prune v{latest} installed."));
279 report_channel_bookkeeping(channel);
280 output::print_info(
281 "The scheduled pass was not interrupted: it runs the managed copy, which \
282 was replaced by atomic rename, so a pass already in flight keeps the \
283 image it loaded and the next one picks up the new binary.",
284 );
285 return Ok(());
286 }
287 Err(e) => output::print_warning(&format!(
288 "Direct download did not work ({e:#}).\nFalling back to the channel that \
289 installed this copy."
290 )),
291 }
292
293 #[cfg(windows)]
298 let aside = {
299 let aside = exe.with_extension("exe.old");
300 let _ = fs::remove_file(&aside);
301 fs::rename(&exe, &aside).ok().map(|_| aside)
302 };
303
304 let result = spawn_channel_upgrade(channel);
305
306 #[cfg(windows)]
307 if let Some(aside) = aside {
308 if result.is_ok() {
309 let _ = fs::remove_file(&aside);
312 } else if !exe.exists() {
313 let _ = fs::rename(&aside, &exe);
316 }
317 }
318 result?;
319
320 output::print_success(&format!("dev-prune v{latest} installed."));
321 output::print_info(
322 "The scheduled pass was not interrupted: it runs the managed copy, which \
323 refreshes itself from the new binary on its next run.",
324 );
325 Ok(())
326}
327
328fn install_directly(
341 latest: &str,
342 exe: &Path,
343 managed: Option<&Path>,
344 channel: Channel,
345) -> Result<()> {
346 let bytes = fetch_release_binary(latest)?;
347 let primary = managed.unwrap_or(exe);
348 install_bytes_at(&bytes, primary)?;
349
350 let replace_exe_dir = primary != exe && exe.is_file() && !manager_owns_exe_dir(channel);
360 for path in companion_copies(primary, managed.is_some(), exe, replace_exe_dir) {
361 if let Err(e) = install_bytes_at(&bytes, &path) {
362 output::print_warning(&format!(
363 "The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it \
364 is, that copy runs the previous version whenever it is the one invoked.",
365 path.display()
366 ));
367 }
368 }
369
370 crate::daemon::refresh_windowless_twin();
373
374 crate::receipt::refresh_after_upgrade(latest);
379 Ok(())
380}
381
382fn manager_owns_exe_dir(channel: Channel) -> bool {
387 channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
388}
389
390fn companion_copies(
405 primary: &Path,
406 primary_is_managed: bool,
407 exe: &Path,
408 replace_exe_dir: bool,
409) -> Vec<PathBuf> {
410 let names: [&str; 2] = if cfg!(windows) {
411 ["dev-prune.exe", "devp.exe"]
412 } else {
413 ["dev-prune", "devp"]
414 };
415 let mut also: Vec<PathBuf> = Vec::new();
416 if let Some(dir) = primary.parent() {
417 for name in names {
418 let twin = dir.join(name);
419 if twin != primary && (primary_is_managed || twin.is_file()) {
420 also.push(twin);
421 }
422 }
423 }
424 if replace_exe_dir {
425 if !also.contains(&exe.to_path_buf()) {
426 also.push(exe.to_path_buf());
427 }
428 if let Some(dir) = exe.parent() {
429 for name in names {
430 let twin = dir.join(name);
431 if twin != *exe && twin != primary && twin.is_file() && !also.contains(&twin) {
432 also.push(twin);
433 }
434 }
435 }
436 }
437 also
438}
439
440fn report_channel_bookkeeping(channel: Channel) {
443 if let Channel::Foreign(manager) = channel {
448 output::print_info(&format!(
449 "The managed copy is now v{}. The copy that {manager} installed was left \
450 exactly as it wrote it — replacing a file inside a manager-owned tree only \
451 makes {manager} and the disk disagree. Upgrade that copy through {manager} \
452 itself.",
453 constants::VERSION
454 ));
455 return;
456 }
457 let Some(resync) = channel
460 .owns_its_files()
461 .then(|| channel.upgrade_command())
462 .flatten()
463 else {
464 return;
465 };
466 if channel.replaces_its_directory() {
467 output::print_info(&format!(
468 "The managed copy is now v{}. The copy {} installed was left exactly as it \
469 wrote it — replacing a file inside a versioned package directory only makes \
470 the manager and the disk disagree. Run `{resync}` to move that one forward \
471 too.",
472 constants::VERSION,
473 channel.label()
474 ));
475 } else {
476 output::print_info(&format!(
477 "The binaries are up to date. `{resync}` also updates that manager's own \
478 record of the version, which still reads v{}.",
479 constants::VERSION
480 ));
481 }
482}
483
484fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
497 let asset = constants::release_asset_name(version).with_context(|| {
498 format!(
499 "no published binary for {}-{}; upgrade through the channel that installed \
500 this copy instead",
501 std::env::consts::OS,
502 std::env::consts::ARCH
503 )
504 })?;
505 let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
506
507 let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
508 output::print_info(&format!("Downloading {asset} …"));
509 let bytes = fetch_bytes(&base)?;
510
511 let actual = {
512 use sha2::{Digest, Sha256};
513 use std::fmt::Write as _;
514 let mut h = Sha256::new();
515 h.update(&bytes);
516 h.finalize().iter().fold(String::new(), |mut s, b| {
519 let _ = write!(s, "{b:02x}");
520 s
521 })
522 };
523 if actual != expected {
524 anyhow::bail!(
525 "checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
526 The download was corrupted or tampered with; nothing was installed."
527 );
528 }
529
530 Ok(bytes)
531}
532
533fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
539 let staging = target.with_extension("new");
543 if let Some(parent) = target.parent() {
544 fs::create_dir_all(parent).ok();
545 }
546 if let Err(e) = fs::write(&staging, bytes) {
547 let _ = fs::remove_file(&staging);
550 return Err(e).with_context(|| format!("could not write {}", staging.display()));
551 }
552
553 #[cfg(unix)]
554 {
555 use std::os::unix::fs::PermissionsExt;
556 let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
558 }
559
560 replace_binary(&staging, target)
561}
562
563fn fetch_expected_hash(url: &str) -> Result<String> {
565 let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
566 parse_sha256_sidecar(&body)
567}
568
569fn parse_sha256_sidecar(body: &str) -> Result<String> {
578 let hash = body
579 .split_whitespace()
580 .next()
581 .context("the checksum sidecar was empty")?
582 .to_ascii_lowercase();
583 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
584 anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
585 }
586 Ok(hash)
587}
588
589fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
590 let mut body = ureq::get(url)
591 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
592 .config()
593 .timeout_global(Some(Duration::from_secs(
594 constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
595 )))
596 .build()
597 .call()
598 .with_context(|| format!("could not download {url}"))?;
599 let mut buf = Vec::new();
600 body.body_mut()
601 .as_reader()
602 .read_to_end(&mut buf)
603 .with_context(|| format!("could not read {url}"))?;
604 Ok(buf)
605}
606
607fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
610 #[cfg(windows)]
614 let aside = {
615 let aside = target.with_extension("exe.old");
616 let _ = fs::remove_file(&aside);
617 target
618 .exists()
619 .then(|| fs::rename(target, &aside).ok().map(|_| aside))
620 .flatten()
621 };
622
623 match fs::rename(staged, target) {
624 Ok(()) => {
625 #[cfg(windows)]
626 if let Some(aside) = aside {
627 let _ = fs::remove_file(&aside);
628 }
629 Ok(())
630 }
631 Err(e) => {
632 let _ = fs::remove_file(staged);
633 #[cfg(windows)]
634 if let Some(aside) = aside
635 && !target.exists()
636 {
637 let _ = fs::rename(&aside, target);
640 }
641 Err(e).with_context(|| format!("could not install {}", target.display()))
642 }
643 }
644}
645
646fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
649 let Some(argv) = channel.upgrade_argv() else {
650 output::print_warning(
651 "Could not tell which channel installed this binary, so nothing was \
652 changed. Upgrade it yourself with one of:",
653 );
654 print_upgrade_commands();
655 anyhow::bail!("unrecognised install channel");
656 };
657
658 output::print_info(&format!("Running: {}", argv.join(" ")));
659 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
660 .args(&argv[1..])
661 .status()
662 .with_context(|| format!("could not start `{}`", argv[0]))?;
663 if !status.success() {
664 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
665 }
666 Ok(())
667}
668
669pub fn maybe_auto_update(registry: &Registry) {
682 if !registry.settings.auto_update
683 || crate::setup::offline_requested()
684 || crate::setup::no_auto_setup_requested()
685 {
686 return;
687 }
688 let Some(latest) = registry.latest_known_version.as_deref() else {
689 return;
690 };
691 if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
692 return;
693 }
694
695 if registry.settings.version_lock {
699 println!();
700 output::print_info(&locked_notice(Some(latest)));
701 return;
702 }
703
704 let Ok(exe) = std::env::current_exe() else {
705 return;
706 };
707 let managed = crate::setup::managed_exe_path().ok();
708 let channel = Channel::detect_at(&exe, managed.as_deref());
709
710 if channel.replaces_its_directory() {
715 return;
716 }
717
718 if matches!(channel, Channel::Foreign(_)) && managed.is_none() {
724 return;
725 }
726
727 println!();
728 output::print_info(&format!(
729 "Updating dev-prune v{} -> v{latest} …",
730 constants::VERSION
731 ));
732 match install_directly(latest, &exe, managed.as_deref(), channel) {
733 Ok(()) => {
734 output::print_success(&format!("dev-prune v{latest} installed."));
735 report_channel_bookkeeping(channel);
736 }
737 Err(e) => output::print_warning(&format!(
738 "Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
739 `devp config set auto_update false` to stop trying."
740 )),
741 }
742}
743
744pub fn notify_if_outdated(registry: &mut Registry) -> bool {
750 if !registry.settings.update_check {
751 return false;
752 }
753
754 let interval = registry.settings.update_check_interval_days;
755 let due = registry
756 .last_update_check
757 .is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
758
759 if due {
760 let _ = refresh_latest(registry);
764 }
765
766 if let Some(latest) = registry.latest_known_version.as_deref()
767 && compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
768 {
769 if registry.settings.version_lock {
770 output::print_info(&locked_notice(Some(latest)));
771 } else {
772 output::print_info(&format!(
773 "dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
774 `devp config set update_check false` silences this.",
775 constants::VERSION
776 ));
777 }
778 }
779
780 due
781}
782
783fn refresh_latest(registry: &mut Registry) -> Result<String> {
788 let result = latest_release(registry.settings.update_check_timeout_secs);
789 registry.last_update_check = Some(Utc::now());
790 let latest = result?;
791 registry.latest_known_version = Some(latest.clone());
792 Ok(latest)
793}
794
795fn report_comparison(latest: &str) {
797 let installed = constants::VERSION;
798 match compare_versions(installed, latest) {
799 Some(Ordering::Less) => {
800 output::print_warning(&format!(
801 "Latest release: v{latest} — an upgrade is available."
802 ));
803 }
804 Some(Ordering::Equal) => {
805 output::print_success(&format!(
806 "Latest release: v{latest} — you are up to date."
807 ));
808 }
809 Some(Ordering::Greater) => {
810 output::print_info(&format!(
812 "Latest release: v{latest} — your build is newer than the last published one."
813 ));
814 }
815 None => {
816 output::print_info(&format!(
817 "Latest release: v{latest} (could not compare it to v{installed})."
818 ));
819 }
820 }
821}
822
823fn latest_release(timeout_secs: u64) -> Result<String> {
828 if crate::setup::offline_requested() {
829 anyhow::bail!("{} is set", constants::ENV_OFFLINE);
830 }
831 let body = ureq::get(constants::LATEST_RELEASE_API_URL)
832 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
833 .header("Accept", "application/vnd.github+json")
834 .config()
835 .timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
836 .build()
837 .call()
838 .context("request failed")?
839 .body_mut()
840 .read_to_string()
841 .context("could not read the response")?;
842
843 let json: serde_json::Value =
844 serde_json::from_str(&body).context("the response was not JSON")?;
845 let tag = json
846 .get("tag_name")
847 .and_then(|v| v.as_str())
848 .context("the response carried no tag_name")?;
849
850 Ok(tag.trim_start_matches('v').to_string())
851}
852
853pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
859 let parse = |v: &str| -> Option<[u64; 3]> {
860 let core = v.split(['-', '+']).next()?;
861 let mut parts = core.split('.');
862 let out = [
863 parts.next()?.parse().ok()?,
864 parts.next()?.parse().ok()?,
865 parts.next()?.parse().ok()?,
866 ];
867 if parts.next().is_some() {
869 return None;
870 }
871 Some(out)
872 };
873 Some(parse(a)?.cmp(&parse(b)?))
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use chrono::Duration as ChronoDuration;
880
881 #[test]
882 fn orders_by_component_not_lexically() {
883 assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
885 assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
886 assert_eq!(
887 compare_versions("2.0.0", "1.99.99"),
888 Some(Ordering::Greater)
889 );
890 }
891
892 #[test]
893 fn pre_release_suffixes_compare_by_their_core() {
894 assert_eq!(
895 compare_versions("1.0.0", "1.0.0-rc.1"),
896 Some(Ordering::Equal)
897 );
898 assert_eq!(
899 compare_versions("1.0.0+build7", "1.0.1"),
900 Some(Ordering::Less)
901 );
902 }
903
904 #[test]
905 fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
906 assert_eq!(compare_versions("1.0", "1.0.0"), None);
907 assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
908 assert_eq!(compare_versions("nightly", "1.0.0"), None);
909 }
910
911 fn bin_names() -> [&'static str; 2] {
912 if cfg!(windows) {
913 ["dev-prune.exe", "devp.exe"]
914 } else {
915 ["dev-prune", "devp"]
916 }
917 }
918
919 #[test]
920 fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
921 let [prune, devp] = bin_names();
925 let managed_dir = tempfile::tempdir().unwrap();
926 let cargo_dir = tempfile::tempdir().unwrap();
927 let primary = managed_dir.path().join(prune);
928 let exe = cargo_dir.path().join(devp);
929 std::fs::write(&exe, b"old").unwrap();
930 std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
931
932 let also = companion_copies(&primary, true, &exe, true);
933 assert!(also.contains(&managed_dir.path().join(devp)));
934 assert!(also.contains(&exe));
935 assert!(also.contains(&cargo_dir.path().join(prune)));
936 assert!(!also.contains(&primary));
937 }
938
939 #[test]
940 fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
941 let [prune, devp] = bin_names();
944 let managed_dir = tempfile::tempdir().unwrap();
945 let solo_dir = tempfile::tempdir().unwrap();
946 let primary = managed_dir.path().join(prune);
947 let exe = solo_dir.path().join(devp);
948 std::fs::write(&exe, b"old").unwrap();
949
950 let also = companion_copies(&primary, true, &exe, true);
951 assert!(also.contains(&managed_dir.path().join(devp)));
952 assert!(also.contains(&exe));
953 assert!(!also.contains(&solo_dir.path().join(prune)));
954 }
955
956 #[test]
957 fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
958 let [prune, devp] = bin_names();
961 let managed_dir = tempfile::tempdir().unwrap();
962 let store_dir = tempfile::tempdir().unwrap();
963 let primary = managed_dir.path().join(prune);
964 let exe = store_dir.path().join(devp);
965 std::fs::write(&exe, b"old").unwrap();
966 std::fs::write(store_dir.path().join(prune), b"old").unwrap();
967
968 let also = companion_copies(&primary, true, &exe, false);
969 assert_eq!(also, vec![managed_dir.path().join(devp)]);
970 }
971
972 #[test]
973 fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
974 for owned in [
979 Channel::WinGet,
980 Channel::Scoop,
981 Channel::Homebrew,
982 Channel::Foreign("Volta"),
983 Channel::Foreign("the system package manager"),
984 ] {
985 assert!(manager_owns_exe_dir(owned), "{owned:?}");
986 }
987 for replaceable in [
988 Channel::Installer,
989 Channel::Cargo,
990 Channel::Npm,
991 Channel::Bun,
992 Channel::Pnpm,
993 Channel::Yarn,
994 Channel::UvTool,
995 Channel::Pipx,
996 Channel::Pip,
997 Channel::Unknown,
998 ] {
999 assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
1000 }
1001 }
1002
1003 #[test]
1004 fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
1005 let [prune, devp] = bin_names();
1006 let dir = tempfile::tempdir().unwrap();
1007 let primary = dir.path().join(devp);
1008 std::fs::write(&primary, b"old").unwrap();
1009
1010 assert!(companion_copies(&primary, false, &primary, false).is_empty());
1012
1013 std::fs::write(dir.path().join(prune), b"old").unwrap();
1015 assert_eq!(
1016 companion_copies(&primary, false, &primary, false),
1017 vec![dir.path().join(prune)]
1018 );
1019 }
1020
1021 #[test]
1022 fn the_check_is_on_unless_the_user_turns_it_off() {
1023 assert!(Registry::default().settings.update_check);
1024 }
1025
1026 #[test]
1027 fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
1028 let mut registry = Registry::default();
1029 registry.settings.update_check = false;
1030 assert!(!notify_if_outdated(&mut registry));
1031 assert!(registry.last_update_check.is_none());
1032 }
1033
1034 #[test]
1035 fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
1036 let registry = Registry::default();
1037 assert!(registry.settings.auto_update);
1038 assert!(registry.latest_known_version.is_none());
1042 maybe_auto_update(®istry);
1043 }
1044
1045 #[test]
1046 fn the_pin_is_off_until_somebody_asks_for_it() {
1047 assert!(!Registry::default().settings.version_lock);
1050 }
1051
1052 #[test]
1053 fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
1054 let notice = locked_notice(None);
1058 assert!(notice.contains(constants::VERSION), "{notice}");
1059 assert!(
1060 notice.contains("devp config set version_lock false"),
1061 "{notice}"
1062 );
1063 assert!(!notice.contains("is out"), "{notice}");
1064 }
1065
1066 #[test]
1067 fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
1068 let notice = locked_notice(Some("2.0.0"));
1072 assert!(notice.contains("v2.0.0 is out"), "{notice}");
1073 assert!(notice.contains(constants::VERSION), "{notice}");
1074 }
1075
1076 #[test]
1077 fn a_recent_check_is_not_repeated() {
1078 let mut registry = Registry::default();
1079 let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
1080 registry.last_update_check = Some(stamp);
1081 assert!(!notify_if_outdated(&mut registry));
1083 assert_eq!(registry.last_update_check, Some(stamp));
1084 }
1085
1086 #[test]
1087 fn the_asset_name_matches_what_the_release_workflow_builds() {
1088 let name = constants::release_asset_name("1.4.0");
1092 let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
1093 ("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
1094 ("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
1095 ("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
1096 ("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
1097 ("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
1098 ("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
1099 ("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
1100 _ => None,
1103 };
1104 assert_eq!(name.as_deref(), expected);
1105 }
1106
1107 #[test]
1108 fn only_windows_has_a_32_bit_asset() {
1109 let name = constants::release_asset_name("9.9.9");
1112 if std::env::consts::ARCH == "x86" {
1113 assert_eq!(name.is_some(), std::env::consts::OS == "windows");
1114 }
1115 }
1116
1117 #[test]
1118 fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
1119 let digest = "a".repeat(64);
1120 assert_eq!(
1121 parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
1122 digest
1123 );
1124 assert_eq!(
1127 parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
1128 digest
1129 );
1130 assert_eq!(
1131 parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
1132 digest,
1133 "an upper-case digest must compare equal to the one we compute"
1134 );
1135 }
1136
1137 #[test]
1138 fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
1139 for bad in [
1142 "",
1143 " ",
1144 "<!DOCTYPE html>",
1145 "not-a-hash asset",
1146 &"a".repeat(63),
1147 &"a".repeat(65),
1148 &format!("{}g asset", "a".repeat(63)),
1149 ] {
1150 assert!(
1151 parse_sha256_sidecar(bad).is_err(),
1152 "{bad:?} must not be accepted as a digest"
1153 );
1154 }
1155 }
1156}