1use std::{
4 ffi::OsString,
5 fs,
6 path::{Path, PathBuf},
7};
8
9use thiserror::Error;
10
11use super::version_check::{LatestPiRelease, is_newer_package_version};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum InstallMethod {
16 Binary,
18 Cargo,
20 Npm,
22 Pnpm,
24 Yarn,
26 Bun,
28 Unknown,
30}
31
32#[derive(Clone, Debug, Default, Eq, PartialEq)]
34pub struct InstallEvidence {
35 pub command_path: PathBuf,
37 pub source_path: Option<PathBuf>,
39 pub standalone_binary: bool,
41}
42
43#[must_use]
45pub fn detect_install_method(evidence: &InstallEvidence) -> InstallMethod {
46 if evidence.standalone_binary {
47 return InstallMethod::Binary;
48 }
49 let mut joined = evidence.command_path.to_string_lossy().to_lowercase();
50 if let Some(source) = &evidence.source_path {
51 joined.push('\0');
52 joined.push_str(&source.to_string_lossy().to_lowercase());
53 }
54 let normalized = joined.replace('\\', "/");
55 if normalized.contains("/.cargo/bin/") || normalized.ends_with("/.cargo/bin/pi") {
56 InstallMethod::Cargo
57 } else if normalized.contains("/.pnpm/") || normalized.contains("/pnpm/") {
58 InstallMethod::Pnpm
59 } else if normalized.contains("/.yarn/") || normalized.contains("/yarn/") {
60 InstallMethod::Yarn
61 } else if normalized.contains("/.bun/") || normalized.contains("/bun/") {
62 InstallMethod::Bun
63 } else if normalized.contains("/node_modules/") || normalized.contains("/npm/") {
64 InstallMethod::Npm
65 } else {
66 InstallMethod::Unknown
67 }
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct CommandStep {
73 pub program: OsString,
75 pub args: Vec<OsString>,
77}
78
79impl CommandStep {
80 fn new(
81 program: impl Into<OsString>,
82 args: impl IntoIterator<Item = impl Into<OsString>>,
83 ) -> Self {
84 Self {
85 program: program.into(),
86 args: args.into_iter().map(Into::into).collect(),
87 }
88 }
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub enum UpdateAction {
94 Commands(Vec<CommandStep>),
96 ReplaceBinary {
98 current: PathBuf,
100 replacement: PathBuf,
102 backup: PathBuf,
104 },
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct SelfUpdatePlan {
110 pub installed_package_name: String,
112 pub package_name: String,
114 pub install_spec: String,
116 pub version: String,
118 pub note: Option<String>,
120 pub should_run: bool,
122 pub action: Option<UpdateAction>,
124}
125
126#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
128pub struct UpdateOptions {
129 pub force: bool,
131 pub dry_run: bool,
133 pub offline: bool,
135}
136
137#[derive(Debug, Error)]
139pub enum UpdateError {
140 #[error("self-update is unavailable while offline")]
142 Offline,
143 #[error("this installation is not managed by a supported update method")]
145 UnsupportedInstallation,
146 #[error("update command failed: {0}")]
148 Command(String),
149 #[error("update file operation failed: {0}")]
151 Io(#[from] std::io::Error),
152 #[error("replacement failed ({replace}); rollback failed ({rollback})")]
154 Rollback {
155 replace: std::io::Error,
157 rollback: std::io::Error,
159 },
160}
161
162#[must_use]
164pub fn get_self_update_command(
165 method: InstallMethod,
166 installed_package_name: &str,
167 package_name: &str,
168 install_spec: &str,
169 npm_command: Option<&[OsString]>,
170 pnpm_global_bin_dir: Option<&Path>,
171) -> Option<Vec<CommandStep>> {
172 let renamed = installed_package_name != package_name;
173 let mut steps = Vec::with_capacity(2);
174 match method {
175 InstallMethod::Npm => {
176 let (program, prefix) = npm_command
177 .and_then(|command| command.split_first())
178 .map_or_else(
179 || (OsString::from("npm"), Vec::new()),
180 |(program, args)| (program.clone(), args.to_vec()),
181 );
182 if renamed {
183 let mut args = prefix.clone();
184 args.extend([OsString::from("uninstall"), OsString::from("-g")]);
185 args.push(OsString::from(installed_package_name));
186 steps.push(CommandStep {
187 program: program.clone(),
188 args,
189 });
190 }
191 let mut args = prefix;
192 args.extend([
193 OsString::from("install"),
194 OsString::from("-g"),
195 OsString::from("--ignore-scripts"),
196 OsString::from("--min-release-age=0"),
197 OsString::from(install_spec),
198 ]);
199 steps.push(CommandStep { program, args });
200 }
201 InstallMethod::Pnpm => {
202 let bin_arg = pnpm_global_bin_dir
203 .map(|path| OsString::from(format!("--config.global-bin-dir={}", path.display())));
204 if renamed {
205 let mut args = vec![OsString::from("remove"), OsString::from("-g")];
206 if let Some(arg) = &bin_arg {
207 args.push(arg.clone());
208 }
209 args.push(OsString::from(installed_package_name));
210 steps.push(CommandStep::new("pnpm", args));
211 }
212 let mut args = vec![
213 OsString::from("install"),
214 OsString::from("-g"),
215 OsString::from("--ignore-scripts"),
216 OsString::from("--config.minimumReleaseAge=0"),
217 ];
218 if let Some(arg) = bin_arg {
219 args.push(arg);
220 }
221 args.push(OsString::from(install_spec));
222 steps.push(CommandStep::new("pnpm", args));
223 }
224 InstallMethod::Yarn => {
225 if renamed {
226 steps.push(CommandStep::new(
227 "yarn",
228 ["global", "remove", installed_package_name],
229 ));
230 }
231 steps.push(CommandStep::new(
232 "yarn",
233 ["global", "add", "--ignore-scripts", install_spec],
234 ));
235 }
236 InstallMethod::Bun => {
237 if renamed {
238 steps.push(CommandStep::new(
239 "bun",
240 ["uninstall", "-g", installed_package_name],
241 ));
242 }
243 steps.push(CommandStep::new(
244 "bun",
245 [
246 "install",
247 "-g",
248 "--ignore-scripts",
249 "--minimum-release-age=0",
250 install_spec,
251 ],
252 ));
253 }
254 InstallMethod::Cargo => steps.push(CommandStep::new(
255 "cargo",
256 [
257 "install",
258 package_name,
259 "--version",
260 install_spec
261 .rsplit_once('@')
262 .map_or(install_spec, |(_, version)| version),
263 "--locked",
264 "--force",
265 ],
266 )),
267 InstallMethod::Binary | InstallMethod::Unknown => return None,
268 }
269 Some(steps)
270}
271
272pub fn build_self_update_plan(
280 current_version: &str,
281 installed_package_name: &str,
282 release: LatestPiRelease,
283 method: InstallMethod,
284 options: UpdateOptions,
285 npm_command: Option<&[OsString]>,
286 pnpm_global_bin_dir: Option<&Path>,
287) -> Result<SelfUpdatePlan, UpdateError> {
288 if options.offline {
289 return Err(UpdateError::Offline);
290 }
291 let package_name = release
292 .package_name
293 .clone()
294 .unwrap_or_else(|| installed_package_name.to_owned());
295 let install_spec = format!("{package_name}@{}", release.version);
296 let should_run = options.force
297 || package_name != installed_package_name
298 || is_newer_package_version(&release.version, current_version);
299 let action = if should_run {
300 let commands = get_self_update_command(
301 method,
302 installed_package_name,
303 &package_name,
304 &install_spec,
305 npm_command,
306 pnpm_global_bin_dir,
307 )
308 .ok_or(UpdateError::UnsupportedInstallation)?;
309 Some(UpdateAction::Commands(commands))
310 } else {
311 None
312 };
313 Ok(SelfUpdatePlan {
314 installed_package_name: installed_package_name.to_owned(),
315 package_name,
316 install_spec,
317 version: release.version,
318 note: release.note,
319 should_run,
320 action,
321 })
322}
323
324pub fn build_binary_self_update_plan(
331 current_version: &str,
332 installed_package_name: &str,
333 release: LatestPiRelease,
334 options: UpdateOptions,
335 current: PathBuf,
336 replacement: PathBuf,
337 backup: PathBuf,
338) -> Result<SelfUpdatePlan, UpdateError> {
339 if options.offline {
340 return Err(UpdateError::Offline);
341 }
342 let package_name = release
343 .package_name
344 .clone()
345 .unwrap_or_else(|| installed_package_name.to_owned());
346 let install_spec = format!("{package_name}@{}", release.version);
347 let should_run = options.force
348 || package_name != installed_package_name
349 || is_newer_package_version(&release.version, current_version);
350 Ok(SelfUpdatePlan {
351 installed_package_name: installed_package_name.to_owned(),
352 package_name,
353 install_spec,
354 version: release.version,
355 note: release.note,
356 should_run,
357 action: should_run.then_some(UpdateAction::ReplaceBinary {
358 current,
359 replacement,
360 backup,
361 }),
362 })
363}
364
365pub trait UpdateRunner {
367 fn run(&mut self, step: &CommandStep) -> Result<(), UpdateError>;
374}
375
376pub struct ProcessUpdateRunner;
378
379impl UpdateRunner for ProcessUpdateRunner {
380 fn run(&mut self, step: &CommandStep) -> Result<(), UpdateError> {
381 let status = std::process::Command::new(&step.program)
382 .args(&step.args)
383 .status()?;
384 if status.success() {
385 Ok(())
386 } else {
387 Err(UpdateError::Command(format!(
388 "{} exited with {}",
389 Path::new(&step.program).display(),
390 status
391 )))
392 }
393 }
394}
395
396pub fn run_self_update(
403 plan: &SelfUpdatePlan,
404 options: UpdateOptions,
405 runner: &mut dyn UpdateRunner,
406) -> Result<(), UpdateError> {
407 run_self_update_with_filesystem(plan, options, runner, &StdUpdateFileSystem)
408}
409
410pub fn run_self_update_with_filesystem(
416 plan: &SelfUpdatePlan,
417 options: UpdateOptions,
418 runner: &mut dyn UpdateRunner,
419 filesystem: &dyn UpdateFileSystem,
420) -> Result<(), UpdateError> {
421 if options.offline {
422 return Err(UpdateError::Offline);
423 }
424 if options.dry_run || !plan.should_run {
425 return Ok(());
426 }
427 match &plan.action {
428 Some(UpdateAction::Commands(steps)) => {
429 for step in steps {
430 runner.run(step)?;
431 }
432 Ok(())
433 }
434 Some(UpdateAction::ReplaceBinary {
435 current,
436 replacement,
437 backup,
438 }) => atomic_replace_binary(filesystem, current, replacement, backup),
439 None => Ok(()),
440 }
441}
442
443pub trait UpdateFileSystem {
445 fn exists(&self, path: &Path) -> bool;
447 fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()>;
453 fn copy(&self, from: &Path, to: &Path) -> std::io::Result<u64>;
459 fn remove_file(&self, path: &Path) -> std::io::Result<()>;
465 fn remove_dir_all(&self, path: &Path) -> std::io::Result<()>;
471 fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
477 fn permissions(&self, path: &Path) -> std::io::Result<fs::Permissions>;
483 fn set_permissions(&self, path: &Path, permissions: fs::Permissions) -> std::io::Result<()>;
489}
490
491pub struct StdUpdateFileSystem;
493
494impl UpdateFileSystem for StdUpdateFileSystem {
495 fn exists(&self, path: &Path) -> bool {
496 path.exists()
497 }
498 fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
499 fs::rename(from, to)
500 }
501 fn copy(&self, from: &Path, to: &Path) -> std::io::Result<u64> {
502 fs::copy(from, to)
503 }
504 fn remove_file(&self, path: &Path) -> std::io::Result<()> {
505 fs::remove_file(path)
506 }
507 fn remove_dir_all(&self, path: &Path) -> std::io::Result<()> {
508 fs::remove_dir_all(path)
509 }
510 fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
511 fs::create_dir_all(path)
512 }
513 fn permissions(&self, path: &Path) -> std::io::Result<fs::Permissions> {
514 fs::metadata(path).map(|m| m.permissions())
515 }
516 fn set_permissions(&self, path: &Path, permissions: fs::Permissions) -> std::io::Result<()> {
517 fs::set_permissions(path, permissions)
518 }
519}
520
521pub fn atomic_replace_binary(
532 filesystem: &dyn UpdateFileSystem,
533 current: &Path,
534 replacement: &Path,
535 backup: &Path,
536) -> Result<(), UpdateError> {
537 let permissions = filesystem.permissions(current)?;
538 filesystem.set_permissions(replacement, permissions)?;
539 if filesystem.exists(backup) {
540 filesystem.remove_file(backup)?;
541 }
542 filesystem.rename(current, backup)?;
543 if let Err(replace) = filesystem.rename(replacement, current) {
544 return match filesystem.rename(backup, current) {
545 Ok(()) => Err(UpdateError::Io(replace)),
546 Err(rollback) => Err(UpdateError::Rollback { replace, rollback }),
547 };
548 }
549 let _ = filesystem.remove_file(backup);
551 Ok(())
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 fn strings(step: &CommandStep) -> Vec<String> {
559 step.args
560 .iter()
561 .map(|value| value.to_string_lossy().into_owned())
562 .collect()
563 }
564
565 #[test]
566 fn detection_uses_command_and_source_paths() {
567 let evidence = InstallEvidence {
568 command_path: PathBuf::from("/home/me/.cargo/bin/pi"),
569 source_path: None,
570 standalone_binary: false,
571 };
572 assert_eq!(detect_install_method(&evidence), InstallMethod::Cargo);
573 let evidence = InstallEvidence {
574 command_path: PathBuf::from("/usr/bin/node"),
575 source_path: Some(PathBuf::from("/opt/pnpm/global/5/node_modules/pkg")),
576 standalone_binary: false,
577 };
578 assert_eq!(detect_install_method(&evidence), InstallMethod::Pnpm);
579 }
580
581 #[test]
582 fn detect_install_method_covers_all_paths_and_windows_backslashes() {
583 let evidence = InstallEvidence {
585 command_path: PathBuf::from("/home/me/pi"),
586 source_path: None,
587 standalone_binary: true,
588 };
589 assert_eq!(detect_install_method(&evidence), InstallMethod::Binary);
590
591 let evidence = InstallEvidence {
593 command_path: PathBuf::from("/home/me/.cargo/bin/pi"),
594 ..InstallEvidence::default()
595 };
596 assert_eq!(detect_install_method(&evidence), InstallMethod::Cargo);
597
598 let evidence = InstallEvidence {
600 command_path: PathBuf::from("/usr/local/.cargo/bin/pi"),
601 ..InstallEvidence::default()
602 };
603 assert_eq!(detect_install_method(&evidence), InstallMethod::Cargo);
604
605 let evidence = InstallEvidence {
607 command_path: PathBuf::from("/opt/.pnpm/global/5/node_modules/pi"),
608 ..InstallEvidence::default()
609 };
610 assert_eq!(detect_install_method(&evidence), InstallMethod::Pnpm);
611
612 let evidence = InstallEvidence {
614 command_path: PathBuf::from("/home/.yarn/global/pi"),
615 ..InstallEvidence::default()
616 };
617 assert_eq!(detect_install_method(&evidence), InstallMethod::Yarn);
618
619 let evidence = InstallEvidence {
621 command_path: PathBuf::from("/home/.bun/install/global/pi"),
622 ..InstallEvidence::default()
623 };
624 assert_eq!(detect_install_method(&evidence), InstallMethod::Bun);
625
626 let evidence = InstallEvidence {
628 command_path: PathBuf::from("/usr/lib/node_modules/pi/bin/pi"),
629 ..InstallEvidence::default()
630 };
631 assert_eq!(detect_install_method(&evidence), InstallMethod::Npm);
632
633 let evidence = InstallEvidence {
635 command_path: PathBuf::from("/usr/local/bin/pi"),
636 ..InstallEvidence::default()
637 };
638 assert_eq!(detect_install_method(&evidence), InstallMethod::Unknown);
639
640 let evidence = InstallEvidence {
642 command_path: PathBuf::from(r"C:\Users\me\.cargo\bin\pi.exe"),
643 ..InstallEvidence::default()
644 };
645 assert_eq!(detect_install_method(&evidence), InstallMethod::Cargo);
646
647 let evidence = InstallEvidence {
649 command_path: PathBuf::from("/usr/bin/node"),
650 source_path: Some(PathBuf::from("/home/.yarn/global/pi")),
651 standalone_binary: false,
652 };
653 assert_eq!(detect_install_method(&evidence), InstallMethod::Yarn);
654 }
655
656 #[test]
657 fn package_manager_argv_are_exact() {
658 let npm =
659 get_self_update_command(InstallMethod::Npm, "old", "new", "new@2.0.0", None, None);
660 assert!(npm.is_some());
661 let npm = npm.unwrap_or_default();
662 assert_eq!(strings(&npm[0]), ["uninstall", "-g", "old"]);
663 assert_eq!(
664 strings(&npm[1]),
665 [
666 "install",
667 "-g",
668 "--ignore-scripts",
669 "--min-release-age=0",
670 "new@2.0.0"
671 ]
672 );
673
674 let pnpm = get_self_update_command(
675 InstallMethod::Pnpm,
676 "pi",
677 "pi",
678 "pi@2.0.0",
679 None,
680 Some(Path::new("/global/bin")),
681 )
682 .unwrap_or_default();
683 assert_eq!(
684 strings(&pnpm[0]),
685 [
686 "install",
687 "-g",
688 "--ignore-scripts",
689 "--config.minimumReleaseAge=0",
690 "--config.global-bin-dir=/global/bin",
691 "pi@2.0.0"
692 ]
693 );
694 let bun = get_self_update_command(InstallMethod::Bun, "pi", "pi", "pi@2.0.0", None, None)
695 .unwrap_or_default();
696 assert_eq!(
697 strings(&bun[0]),
698 [
699 "install",
700 "-g",
701 "--ignore-scripts",
702 "--minimum-release-age=0",
703 "pi@2.0.0"
704 ]
705 );
706 let cargo =
707 get_self_update_command(InstallMethod::Cargo, "pi", "pi", "pi@2.0.0", None, None)
708 .unwrap_or_default();
709 assert_eq!(
710 strings(&cargo[0]),
711 ["install", "pi", "--version", "2.0.0", "--locked", "--force"]
712 );
713 }
714
715 #[test]
716 fn npm_argv_uses_custom_npm_command_prefix() {
717 let prefix = [
718 OsString::from("mise"),
719 OsString::from("exec"),
720 OsString::from("node@20"),
721 OsString::from("--"),
722 OsString::from("npm"),
723 ];
724 let npm = get_self_update_command(
725 InstallMethod::Npm,
726 "pi",
727 "pi",
728 "pi@2.0.0",
729 Some(&prefix),
730 None,
731 );
732 assert!(npm.is_some());
733 let npm = npm.unwrap_or_default();
734 assert_eq!(npm.len(), 1);
736 assert_eq!(npm[0].program, OsString::from("mise"));
737 assert_eq!(
738 strings(&npm[0]),
739 [
740 "exec",
741 "node@20",
742 "--",
743 "npm",
744 "install",
745 "-g",
746 "--ignore-scripts",
747 "--min-release-age=0",
748 "pi@2.0.0"
749 ]
750 );
751 }
752
753 #[test]
754 fn npm_argv_uninstall_precedes_install_on_rename() {
755 let npm = get_self_update_command(
756 InstallMethod::Npm,
757 "old-pkg",
758 "new-pkg",
759 "new-pkg@3.0.0",
760 None,
761 None,
762 );
763 assert!(npm.is_some());
764 let npm = npm.unwrap_or_default();
765 assert_eq!(npm.len(), 2);
766 assert_eq!(strings(&npm[0]), ["uninstall", "-g", "old-pkg"]);
768 assert_eq!(
769 strings(&npm[1]),
770 [
771 "install",
772 "-g",
773 "--ignore-scripts",
774 "--min-release-age=0",
775 "new-pkg@3.0.0"
776 ]
777 );
778 }
779
780 #[test]
781 fn pnpm_argv_uninstall_includes_bin_dir_on_rename() {
782 let pnpm = get_self_update_command(
783 InstallMethod::Pnpm,
784 "old",
785 "new",
786 "new@2.0.0",
787 None,
788 Some(Path::new("/pnpm/global/bin")),
789 );
790 assert!(pnpm.is_some());
791 let pnpm = pnpm.unwrap_or_default();
792 assert_eq!(pnpm.len(), 2);
793 assert_eq!(
794 strings(&pnpm[0]),
795 [
796 "remove",
797 "-g",
798 "--config.global-bin-dir=/pnpm/global/bin",
799 "old"
800 ]
801 );
802 assert_eq!(
803 strings(&pnpm[1]),
804 [
805 "install",
806 "-g",
807 "--ignore-scripts",
808 "--config.minimumReleaseAge=0",
809 "--config.global-bin-dir=/pnpm/global/bin",
810 "new@2.0.0"
811 ]
812 );
813 }
814
815 #[test]
816 fn yarn_argv_matches_typescript_shape() {
817 let yarn = get_self_update_command(InstallMethod::Yarn, "pi", "pi", "pi@2.0.0", None, None);
819 assert!(yarn.is_some());
820 let yarn = yarn.unwrap_or_default();
821 assert_eq!(yarn.len(), 1);
822 assert_eq!(
823 strings(&yarn[0]),
824 ["global", "add", "--ignore-scripts", "pi@2.0.0"]
825 );
826
827 let yarn =
829 get_self_update_command(InstallMethod::Yarn, "old", "new", "new@2.0.0", None, None);
830 assert!(yarn.is_some());
831 let yarn = yarn.unwrap_or_default();
832 assert_eq!(yarn.len(), 2);
833 assert_eq!(strings(&yarn[0]), ["global", "remove", "old"]);
834 assert_eq!(
835 strings(&yarn[1]),
836 ["global", "add", "--ignore-scripts", "new@2.0.0"]
837 );
838 }
839
840 #[test]
841 fn bun_argv_uninstall_precedes_install_on_rename() {
842 let bun =
843 get_self_update_command(InstallMethod::Bun, "old", "new", "new@2.0.0", None, None);
844 assert!(bun.is_some());
845 let bun = bun.unwrap_or_default();
846 assert_eq!(bun.len(), 2);
847 assert_eq!(strings(&bun[0]), ["uninstall", "-g", "old"]);
848 assert_eq!(
849 strings(&bun[1]),
850 [
851 "install",
852 "-g",
853 "--ignore-scripts",
854 "--minimum-release-age=0",
855 "new@2.0.0"
856 ]
857 );
858 }
859
860 #[test]
861 fn binary_and_unknown_methods_return_none() {
862 assert!(
863 get_self_update_command(InstallMethod::Binary, "pi", "pi", "pi@2.0.0", None, None)
864 .is_none()
865 );
866 assert!(
867 get_self_update_command(InstallMethod::Unknown, "pi", "pi", "pi@2.0.0", None, None)
868 .is_none()
869 );
870 }
871
872 #[test]
873 fn cargo_argv_strips_version_from_install_spec() {
874 let cargo =
876 get_self_update_command(InstallMethod::Cargo, "pi", "pi", "pi@2.0.0", None, None);
877 assert!(cargo.is_some());
878 let cargo = cargo.unwrap_or_default();
879 assert_eq!(
880 strings(&cargo[0]),
881 ["install", "pi", "--version", "2.0.0", "--locked", "--force"]
882 );
883
884 let cargo = get_self_update_command(
886 InstallMethod::Cargo,
887 "old-name",
888 "new-name",
889 "new-name@3.0.0",
890 None,
891 None,
892 );
893 assert!(cargo.is_some());
894 let cargo = cargo.unwrap_or_default();
895 assert_eq!(
896 strings(&cargo[0]),
897 [
898 "install",
899 "new-name",
900 "--version",
901 "3.0.0",
902 "--locked",
903 "--force"
904 ]
905 );
906 }
907
908 #[derive(Default)]
909 struct RecordingRunner {
910 calls: Vec<CommandStep>,
911 }
912 impl UpdateRunner for RecordingRunner {
913 fn run(&mut self, step: &CommandStep) -> Result<(), UpdateError> {
914 self.calls.push(step.clone());
915 Ok(())
916 }
917 }
918
919 #[derive(Default)]
920 struct FailingRunner {
921 error: Option<UpdateError>,
922 }
923 impl UpdateRunner for FailingRunner {
924 fn run(&mut self, _step: &CommandStep) -> Result<(), UpdateError> {
925 Err(self.error.take().map_or(
926 UpdateError::Command("no error configured".to_owned()),
927 |err| err,
928 ))
929 }
930 }
931
932 #[test]
933 fn force_dry_run_and_idempotency_are_explicit() -> Result<(), UpdateError> {
934 let release = LatestPiRelease {
935 version: "1.0.0".to_owned(),
936 package_name: None,
937 note: None,
938 };
939 let plan = build_self_update_plan(
940 "1.0.0",
941 "pi",
942 release.clone(),
943 InstallMethod::Npm,
944 UpdateOptions::default(),
945 None,
946 None,
947 )?;
948 assert!(!plan.should_run);
949 let forced = build_self_update_plan(
950 "1.0.0",
951 "pi",
952 release,
953 InstallMethod::Npm,
954 UpdateOptions {
955 force: true,
956 ..UpdateOptions::default()
957 },
958 None,
959 None,
960 )?;
961 let mut runner = RecordingRunner::default();
962 run_self_update(
963 &forced,
964 UpdateOptions {
965 dry_run: true,
966 ..UpdateOptions::default()
967 },
968 &mut runner,
969 )?;
970 assert!(runner.calls.is_empty());
971 run_self_update(&forced, UpdateOptions::default(), &mut runner)?;
972 assert_eq!(runner.calls.len(), 1);
973 Ok(())
974 }
975
976 #[test]
977 fn build_plan_offline_returns_error() {
978 let release = LatestPiRelease {
979 version: "2.0.0".to_owned(),
980 package_name: None,
981 note: None,
982 };
983 let result = build_self_update_plan(
984 "1.0.0",
985 "pi",
986 release,
987 InstallMethod::Npm,
988 UpdateOptions {
989 offline: true,
990 ..UpdateOptions::default()
991 },
992 None,
993 None,
994 );
995 assert!(matches!(result, Err(UpdateError::Offline)));
996 }
997
998 #[test]
999 fn build_plan_unsupported_install_returns_error_when_update_needed() {
1000 let release = LatestPiRelease {
1001 version: "2.0.0".to_owned(),
1002 package_name: None,
1003 note: None,
1004 };
1005 let result = build_self_update_plan(
1006 "1.0.0",
1007 "pi",
1008 release,
1009 InstallMethod::Binary,
1010 UpdateOptions::default(),
1011 None,
1012 None,
1013 );
1014 assert!(matches!(result, Err(UpdateError::UnsupportedInstallation)));
1015 }
1016
1017 #[test]
1018 fn build_plan_newer_version_triggers_run() -> Result<(), UpdateError> {
1019 let release = LatestPiRelease {
1020 version: "2.0.0".to_owned(),
1021 package_name: None,
1022 note: Some("major".to_owned()),
1023 };
1024 let plan = build_self_update_plan(
1025 "1.0.0",
1026 "pi",
1027 release,
1028 InstallMethod::Npm,
1029 UpdateOptions::default(),
1030 None,
1031 None,
1032 )?;
1033 assert!(plan.should_run);
1034 assert_eq!(plan.version, "2.0.0");
1035 assert_eq!(plan.note, Some("major".to_owned()));
1036 assert_eq!(plan.install_spec, "pi@2.0.0");
1037 assert!(plan.action.is_some());
1038 Ok(())
1039 }
1040
1041 #[test]
1042 fn build_plan_renamed_package_triggers_run_even_on_same_version() -> Result<(), UpdateError> {
1043 let release = LatestPiRelease {
1044 version: "1.0.0".to_owned(),
1045 package_name: Some("pi-new".to_owned()),
1046 note: None,
1047 };
1048 let plan = build_self_update_plan(
1049 "1.0.0",
1050 "pi-old",
1051 release,
1052 InstallMethod::Npm,
1053 UpdateOptions::default(),
1054 None,
1055 None,
1056 )?;
1057 assert!(plan.should_run);
1058 assert_eq!(plan.package_name, "pi-new");
1059 assert_eq!(plan.install_spec, "pi-new@1.0.0");
1060 assert!(
1062 matches!(&plan.action, Some(UpdateAction::Commands(steps)) if steps.len() == 2),
1063 "expected Commands action with 2 steps for renamed package"
1064 );
1065 Ok(())
1066 }
1067
1068 #[test]
1069 fn build_binary_plan_offline_returns_error() {
1070 let release = LatestPiRelease {
1071 version: "2.0.0".to_owned(),
1072 package_name: None,
1073 note: None,
1074 };
1075 let result = build_binary_self_update_plan(
1076 "1.0.0",
1077 "pi",
1078 release,
1079 UpdateOptions {
1080 offline: true,
1081 ..UpdateOptions::default()
1082 },
1083 PathBuf::from("/cur"),
1084 PathBuf::from("/new"),
1085 PathBuf::from("/bak"),
1086 );
1087 assert!(matches!(result, Err(UpdateError::Offline)));
1088 }
1089
1090 #[test]
1091 fn build_binary_plan_replace_action_when_newer() -> Result<(), UpdateError> {
1092 let release = LatestPiRelease {
1093 version: "2.0.0".to_owned(),
1094 package_name: None,
1095 note: None,
1096 };
1097 let plan = build_binary_self_update_plan(
1098 "1.0.0",
1099 "pi",
1100 release,
1101 UpdateOptions::default(),
1102 PathBuf::from("/cur/pi"),
1103 PathBuf::from("/tmp/new-pi"),
1104 PathBuf::from("/cur/pi.bak"),
1105 )?;
1106 assert!(plan.should_run);
1107 assert!(
1108 matches!(
1109 plan.action.as_ref(),
1110 Some(UpdateAction::ReplaceBinary { .. })
1111 ),
1112 "expected ReplaceBinary action"
1113 );
1114 if let Some(UpdateAction::ReplaceBinary {
1115 current,
1116 replacement,
1117 backup,
1118 }) = plan.action
1119 {
1120 assert_eq!(current, PathBuf::from("/cur/pi"));
1121 assert_eq!(replacement, PathBuf::from("/tmp/new-pi"));
1122 assert_eq!(backup, PathBuf::from("/cur/pi.bak"));
1123 }
1124 Ok(())
1125 }
1126
1127 #[test]
1128 fn build_binary_plan_no_action_when_same_version() -> Result<(), UpdateError> {
1129 let release = LatestPiRelease {
1130 version: "1.0.0".to_owned(),
1131 package_name: None,
1132 note: None,
1133 };
1134 let plan = build_binary_self_update_plan(
1135 "1.0.0",
1136 "pi",
1137 release,
1138 UpdateOptions::default(),
1139 PathBuf::from("/cur/pi"),
1140 PathBuf::from("/tmp/new-pi"),
1141 PathBuf::from("/cur/pi.bak"),
1142 )?;
1143 assert!(!plan.should_run);
1144 assert!(plan.action.is_none());
1145 Ok(())
1146 }
1147
1148 #[test]
1149 fn run_self_update_propagates_command_failure() -> Result<(), UpdateError> {
1150 let release = LatestPiRelease {
1151 version: "2.0.0".to_owned(),
1152 package_name: None,
1153 note: None,
1154 };
1155 let plan = build_self_update_plan(
1156 "1.0.0",
1157 "pi",
1158 release,
1159 InstallMethod::Npm,
1160 UpdateOptions::default(),
1161 None,
1162 None,
1163 )?;
1164 let mut runner = FailingRunner {
1165 error: Some(UpdateError::Command("npm exited with 1".to_owned())),
1166 };
1167 let result = run_self_update(&plan, UpdateOptions::default(), &mut runner);
1168 assert!(matches!(result, Err(UpdateError::Command(_))));
1169 Ok(())
1170 }
1171
1172 #[test]
1173 fn run_self_update_offline_returns_error_even_with_plan() -> Result<(), UpdateError> {
1174 let release = LatestPiRelease {
1175 version: "2.0.0".to_owned(),
1176 package_name: None,
1177 note: None,
1178 };
1179 let plan = build_self_update_plan(
1180 "1.0.0",
1181 "pi",
1182 release,
1183 InstallMethod::Npm,
1184 UpdateOptions::default(),
1185 None,
1186 None,
1187 )?;
1188 let mut runner = RecordingRunner::default();
1189 let result = run_self_update(
1190 &plan,
1191 UpdateOptions {
1192 offline: true,
1193 ..UpdateOptions::default()
1194 },
1195 &mut runner,
1196 );
1197 assert!(matches!(result, Err(UpdateError::Offline)));
1198 assert!(runner.calls.is_empty());
1199 Ok(())
1200 }
1201
1202 #[test]
1203 fn atomic_replace_succeeds_with_real_filesystem() -> Result<(), Box<dyn std::error::Error>> {
1204 use tempfile::tempdir;
1205 let temp = tempdir()?;
1206 let current = temp.path().join("pi");
1207 let replacement = temp.path().join("pi.new");
1208 let backup = temp.path().join("pi.bak");
1209 std::fs::write(¤t, b"old")?;
1210 std::fs::write(&replacement, b"new")?;
1211
1212 atomic_replace_binary(&StdUpdateFileSystem, ¤t, &replacement, &backup)?;
1213
1214 assert_eq!(std::fs::read(¤t)?, b"new");
1215 assert!(!backup.exists());
1216 assert!(!replacement.exists());
1217 Ok(())
1218 }
1219
1220 #[test]
1221 fn atomic_replace_fails_when_current_missing() -> Result<(), Box<dyn std::error::Error>> {
1222 let temp = tempfile::tempdir()?;
1223 let current = temp.path().join("nonexistent");
1224 let replacement = temp.path().join("pi.new");
1225 let backup = temp.path().join("pi.bak");
1226 std::fs::write(&replacement, b"new")?;
1227
1228 let result = atomic_replace_binary(&StdUpdateFileSystem, ¤t, &replacement, &backup);
1229 assert!(result.is_err());
1230 assert_eq!(std::fs::read(&replacement)?, b"new");
1232 Ok(())
1233 }
1234
1235 #[test]
1236 fn atomic_replace_removes_stale_backup_before_rename() -> Result<(), Box<dyn std::error::Error>>
1237 {
1238 use tempfile::tempdir;
1239 let temp = tempdir()?;
1240 let current = temp.path().join("pi");
1241 let replacement = temp.path().join("pi.new");
1242 let backup = temp.path().join("pi.bak");
1243 std::fs::write(¤t, b"old")?;
1244 std::fs::write(&replacement, b"new")?;
1245 std::fs::write(&backup, b"stale")?;
1246
1247 atomic_replace_binary(&StdUpdateFileSystem, ¤t, &replacement, &backup)?;
1248
1249 assert_eq!(std::fs::read(¤t)?, b"new");
1250 assert!(!backup.exists());
1251 Ok(())
1252 }
1253
1254 struct RemoveFileFailingFS {
1256 inner: StdUpdateFileSystem,
1257 fail_remove: bool,
1258 }
1259
1260 impl UpdateFileSystem for RemoveFileFailingFS {
1261 fn exists(&self, path: &Path) -> bool {
1262 self.inner.exists(path)
1263 }
1264 fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
1265 self.inner.rename(from, to)
1266 }
1267 fn copy(&self, from: &Path, to: &Path) -> std::io::Result<u64> {
1268 self.inner.copy(from, to)
1269 }
1270 fn remove_file(&self, path: &Path) -> std::io::Result<()> {
1271 if self.fail_remove {
1272 Err(std::io::Error::new(
1273 std::io::ErrorKind::PermissionDenied,
1274 "locked",
1275 ))
1276 } else {
1277 self.inner.remove_file(path)
1278 }
1279 }
1280 fn remove_dir_all(&self, path: &Path) -> std::io::Result<()> {
1281 self.inner.remove_dir_all(path)
1282 }
1283 fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
1284 self.inner.create_dir_all(path)
1285 }
1286 fn permissions(&self, path: &Path) -> std::io::Result<fs::Permissions> {
1287 self.inner.permissions(path)
1288 }
1289 fn set_permissions(&self, path: &Path, perm: fs::Permissions) -> std::io::Result<()> {
1290 self.inner.set_permissions(path, perm)
1291 }
1292 }
1293
1294 #[test]
1295 fn atomic_replace_succeeds_even_when_backup_cleanup_fails()
1296 -> Result<(), Box<dyn std::error::Error>> {
1297 use tempfile::tempdir;
1298 let temp = tempdir()?;
1299 let current = temp.path().join("pi");
1300 let replacement = temp.path().join("pi.new");
1301 let backup = temp.path().join("pi.bak");
1302 std::fs::write(¤t, b"old")?;
1303 std::fs::write(&replacement, b"new")?;
1304
1305 let fs = RemoveFileFailingFS {
1307 inner: StdUpdateFileSystem,
1308 fail_remove: true,
1309 };
1310 let result = atomic_replace_binary(&fs, ¤t, &replacement, &backup);
1317
1318 assert!(result.is_ok());
1320 assert_eq!(std::fs::read(¤t)?, b"new");
1322 assert!(backup.exists());
1324 assert_eq!(std::fs::read(&backup)?, b"old");
1325 Ok(())
1326 }
1327}