Skip to main content

pi/core/update/
self_update.rs

1//! Self-update planning, command execution, and atomic binary replacement.
2
3use 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/// Supported installation ownership modes.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum InstallMethod {
16    /// Standalone native binary.
17    Binary,
18    /// Rust package installed by Cargo.
19    Cargo,
20    /// npm global installation.
21    Npm,
22    /// pnpm global installation.
23    Pnpm,
24    /// Yarn global installation.
25    Yarn,
26    /// Bun global installation.
27    Bun,
28    /// Installation source cannot be proven.
29    Unknown,
30}
31
32/// Paths used to determine how the current executable was installed.
33#[derive(Clone, Debug, Default, Eq, PartialEq)]
34pub struct InstallEvidence {
35    /// Current executable path.
36    pub command_path: PathBuf,
37    /// Package/source directory containing the executable, when known.
38    pub source_path: Option<PathBuf>,
39    /// Explicit marker supplied by a packaged standalone binary.
40    pub standalone_binary: bool,
41}
42
43/// Determine installation mode without invoking a package manager.
44#[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/// One subprocess in a self-update operation.
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub struct CommandStep {
73    /// Program name or absolute path.
74    pub program: OsString,
75    /// Exact argument vector.
76    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/// How an update will be applied.
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub enum UpdateAction {
94    /// Execute package-manager commands in order.
95    Commands(Vec<CommandStep>),
96    /// Replace the current executable with an already-downloaded file.
97    ReplaceBinary {
98        /// Running executable.
99        current: PathBuf,
100        /// Fully downloaded and verified replacement.
101        replacement: PathBuf,
102        /// Rollback file retained until replacement succeeds.
103        backup: PathBuf,
104    },
105}
106
107/// Complete, side-effect-free update plan.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct SelfUpdatePlan {
110    /// Installed package name.
111    pub installed_package_name: String,
112    /// Target package name.
113    pub package_name: String,
114    /// `<package>@<version>` install spec.
115    pub install_spec: String,
116    /// Target version.
117    pub version: String,
118    /// Optional release note.
119    pub note: Option<String>,
120    /// Whether force/version/package-rename rules require execution.
121    pub should_run: bool,
122    /// Installation action, absent when no update should run.
123    pub action: Option<UpdateAction>,
124}
125
126/// Flags affecting update execution.
127#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
128pub struct UpdateOptions {
129    /// Reinstall even when the version is not newer.
130    pub force: bool,
131    /// Return the plan without changing files or spawning commands.
132    pub dry_run: bool,
133    /// Prohibit endpoint access and update execution.
134    pub offline: bool,
135}
136
137/// Self-update failure.
138#[derive(Debug, Error)]
139pub enum UpdateError {
140    /// Offline mode prohibits updating.
141    #[error("self-update is unavailable while offline")]
142    Offline,
143    /// Installation ownership cannot be established.
144    #[error("this installation is not managed by a supported update method")]
145    UnsupportedInstallation,
146    /// Child process failed.
147    #[error("update command failed: {0}")]
148    Command(String),
149    /// File operation failed.
150    #[error("update file operation failed: {0}")]
151    Io(#[from] std::io::Error),
152    /// Replacement failed and rollback also failed.
153    #[error("replacement failed ({replace}); rollback failed ({rollback})")]
154    Rollback {
155        /// Replacement error.
156        replace: std::io::Error,
157        /// Rollback error.
158        rollback: std::io::Error,
159    },
160}
161
162/// Build exact package-manager argv for an installation mode.
163#[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
272/// Resolve release metadata and install mode into a pure plan.
273///
274/// # Errors
275///
276/// Returns [`UpdateError::Offline`] when `options.offline` is set and
277/// [`UpdateError::UnsupportedInstallation`] when the install method has no
278/// package-manager command.
279pub 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
324/// Build a standalone-binary replacement plan after the artifact has been
325/// downloaded and verified by the caller.
326///
327/// # Errors
328///
329/// Returns [`UpdateError::Offline`] when `options.offline` is set.
330pub 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
365/// Injected command runner.
366pub trait UpdateRunner {
367    /// Run one step and wait for completion.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`UpdateError::Command`] when the subprocess exits non-zero, and
372    /// [`UpdateError::Io`] for spawning or I/O failure.
373    fn run(&mut self, step: &CommandStep) -> Result<(), UpdateError>;
374}
375
376/// Standard inherited-stdio command runner.
377pub 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
396/// Execute a plan with the real filesystem. Dry-run and no-op plans have no effects.
397///
398/// # Errors
399///
400/// Propagates [`UpdateError::Offline`], [`UpdateError::Command`],
401/// [`UpdateError::Io`], and [`UpdateError::Rollback`] from plan execution.
402pub 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
410/// Fully injected self-update executor.
411///
412/// # Errors
413///
414/// See [`run_self_update`].
415pub 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
443/// Filesystem seam used by atomic replacement and Windows quarantine.
444pub trait UpdateFileSystem {
445    /// Whether a path exists.
446    fn exists(&self, path: &Path) -> bool;
447    /// Rename a path atomically within a filesystem.
448    ///
449    /// # Errors
450    ///
451    /// Propagates the underlying [`std::io`] error when the rename fails.
452    fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()>;
453    /// Copy a file.
454    ///
455    /// # Errors
456    ///
457    /// Propagates the underlying [`std::io`] error when the copy fails.
458    fn copy(&self, from: &Path, to: &Path) -> std::io::Result<u64>;
459    /// Remove a file if present.
460    ///
461    /// # Errors
462    ///
463    /// Propagates the underlying [`std::io`] error when the removal fails.
464    fn remove_file(&self, path: &Path) -> std::io::Result<()>;
465    /// Recursively remove a directory if present.
466    ///
467    /// # Errors
468    ///
469    /// Propagates the underlying [`std::io`] error when the removal fails.
470    fn remove_dir_all(&self, path: &Path) -> std::io::Result<()>;
471    /// Recursively create a directory.
472    ///
473    /// # Errors
474    ///
475    /// Propagates the underlying [`std::io`] error when creation fails.
476    fn create_dir_all(&self, path: &Path) -> std::io::Result<()>;
477    /// Read file permissions.
478    ///
479    /// # Errors
480    ///
481    /// Propagates the underlying [`std::io`] error when metadata read fails.
482    fn permissions(&self, path: &Path) -> std::io::Result<fs::Permissions>;
483    /// Set file permissions.
484    ///
485    /// # Errors
486    ///
487    /// Propagates the underlying [`std::io`] error when setting permissions fails.
488    fn set_permissions(&self, path: &Path, permissions: fs::Permissions) -> std::io::Result<()>;
489}
490
491/// Real filesystem implementation.
492pub 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
521/// Atomically replace an executable and restore the old file on failure.
522///
523/// Once the replacement is renamed into position the operation is considered
524/// successful; backup cleanup failure is swallowed because the update has
525/// already taken effect.
526///
527/// # Errors
528///
529/// Returns [`UpdateError::Io`] for permission or rename failure;
530/// [`UpdateError::Rollback`] when both replacement and rollback fail.
531pub 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    // Best-effort backup cleanup; the replacement already succeeded.
550    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        // Standalone binary flag always wins.
584        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        // Cargo via command_path.
592        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        // Ends-with cargo bin pi.
599        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        // pnpm via .pnpm.
606        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        // yarn.
613        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        // bun.
620        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        // npm via node_modules.
627        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        // Unknown.
634        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        // Windows backslash paths are normalized to forward slashes.
641        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        // Source path contributes to detection.
648        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        // Single install step (no rename).
735        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        // Uninstall first, then install.
767        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        // No rename: single install step.
818        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        // Rename: uninstall + install.
828        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        // Standard spec: pi@2.0.0 -> version 2.0.0.
875        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        // Renamed package: cargo uses the target package_name, not installed name.
885        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        // Rename produces two steps: uninstall old, install new.
1061        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(&current, b"old")?;
1210        std::fs::write(&replacement, b"new")?;
1211
1212        atomic_replace_binary(&StdUpdateFileSystem, &current, &replacement, &backup)?;
1213
1214        assert_eq!(std::fs::read(&current)?, 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, &current, &replacement, &backup);
1229        assert!(result.is_err());
1230        // Replacement is unchanged.
1231        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(&current, b"old")?;
1244        std::fs::write(&replacement, b"new")?;
1245        std::fs::write(&backup, b"stale")?;
1246
1247        atomic_replace_binary(&StdUpdateFileSystem, &current, &replacement, &backup)?;
1248
1249        assert_eq!(std::fs::read(&current)?, b"new");
1250        assert!(!backup.exists());
1251        Ok(())
1252    }
1253
1254    /// Filesystem that fails `remove_file` to test backup cleanup tolerance.
1255    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(&current, b"old")?;
1303        std::fs::write(&replacement, b"new")?;
1304
1305        // Use a filesystem where remove_file always fails, simulating a locked backup.
1306        let fs = RemoveFileFailingFS {
1307            inner: StdUpdateFileSystem,
1308            fail_remove: true,
1309        };
1310        // The pre-rename stale-backup removal will fail, but since there's no stale
1311        // backup, the exists() check returns false and remove_file is not called.
1312        // After the replacement succeeds, the backup cleanup is best-effort.
1313        // To actually test the post-rename failure, we need the backup to exist
1314        // after the rename. Let's set it up so the rename creates the backup,
1315        // then remove_file on it fails.
1316        let result = atomic_replace_binary(&fs, &current, &replacement, &backup);
1317
1318        // The replacement succeeded; backup cleanup failure is swallowed.
1319        assert!(result.is_ok());
1320        // Current has the new content.
1321        assert_eq!(std::fs::read(&current)?, b"new");
1322        // Backup still exists because cleanup failed (best-effort).
1323        assert!(backup.exists());
1324        assert_eq!(std::fs::read(&backup)?, b"old");
1325        Ok(())
1326    }
1327}