1use std::path::PathBuf;
12use std::process::Command;
13
14use anyhow::{Context, Result, bail};
15
16use crate::args::UpgradeMethod;
17use crate::display::{bold_green, bold_white, confirm_stdout};
18
19use super::update_check::check_for_update_with_method;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum InstallMethod {
24 Homebrew,
26 CargoRegistry,
28 CargoPath(PathBuf),
30 Standalone,
32}
33
34impl InstallMethod {
35 pub fn detect() -> Result<Self> {
37 let exe_path = std::env::current_exe().context("failed to get current executable path")?;
38 let path_str = exe_path.to_string_lossy();
39
40 if path_str.contains("/Cellar/") || path_str.contains("/homebrew/") {
41 return Ok(Self::Homebrew);
42 }
43
44 if path_str.contains("/.cargo/bin/") {
45 if let Some(method) = Self::detect_cargo_source()? {
47 return Ok(method);
48 }
49 return Ok(Self::CargoRegistry);
51 }
52
53 Ok(Self::Standalone)
54 }
55
56 fn detect_cargo_source() -> Result<Option<Self>> {
62 let output = Command::new("cargo")
63 .args(["install", "--list"])
64 .output()
65 .context("failed to run cargo install --list")?;
66
67 if !output.status.success() {
68 return Ok(None);
69 }
70
71 let stdout = String::from_utf8_lossy(&output.stdout);
72
73 for line in stdout.lines() {
75 if !line.starts_with("mi6 v") {
76 continue;
77 }
78
79 if let Some(start) = line.find(" (")
81 && let Some(end) = line.rfind("):")
82 {
83 let path_str = &line[start + 2..end];
84 return Ok(Some(Self::CargoPath(PathBuf::from(path_str))));
85 }
86
87 return Ok(Some(Self::CargoRegistry));
89 }
90
91 Ok(None)
92 }
93
94 pub fn name(&self) -> &'static str {
96 match self {
97 Self::Homebrew => "Homebrew",
98 Self::CargoRegistry => "Cargo (crates.io)",
99 Self::CargoPath(_) => "Cargo source code",
100 Self::Standalone => "Standalone binary",
101 }
102 }
103
104 pub fn to_upgrade_method(&self) -> UpgradeMethod {
106 match self {
107 Self::Homebrew => UpgradeMethod::Brew,
108 Self::CargoRegistry => UpgradeMethod::CargoCrates,
109 Self::CargoPath(_) => UpgradeMethod::CargoSource,
110 Self::Standalone => UpgradeMethod::Github,
111 }
112 }
113
114 pub fn from_upgrade_method(method: UpgradeMethod, path: Option<PathBuf>) -> Result<Self> {
116 match method {
117 UpgradeMethod::Brew => Ok(Self::Homebrew),
118 UpgradeMethod::CargoCrates => Ok(Self::CargoRegistry),
119 UpgradeMethod::Github => Ok(Self::Standalone),
120 UpgradeMethod::CargoSource => {
121 let source_path = path.ok_or_else(|| {
122 anyhow::anyhow!(
123 "--source-path is required when using --method cargo_source\n\
124 Example: mi6 upgrade --method cargo_source --source-path /path/to/mi6"
125 )
126 })?;
127 Ok(Self::CargoPath(source_path))
128 }
129 }
130 }
131
132 pub fn check_prerequisites(&self) -> Result<()> {
134 match self {
135 Self::Homebrew => {
136 let output = Command::new("brew")
137 .arg("--version")
138 .output()
139 .context("brew is not installed or not in PATH")?;
140 if !output.status.success() {
141 bail!("brew is not working properly");
142 }
143 Ok(())
144 }
145 Self::CargoRegistry | Self::CargoPath(_) => {
146 let output = Command::new("cargo")
147 .arg("--version")
148 .output()
149 .context("cargo is not installed or not in PATH")?;
150 if !output.status.success() {
151 bail!("cargo is not working properly");
152 }
153 if let Self::CargoPath(path) = self {
155 if !path.exists() {
156 bail!(
157 "Source directory does not exist: {}\n\
158 Please provide a valid path to the mi6 source.",
159 path.display()
160 );
161 }
162 let cargo_toml = path.join("Cargo.toml");
163 if !cargo_toml.exists() {
164 bail!(
165 "Not a valid Rust project (no Cargo.toml): {}\n\
166 Please provide a path to the mi6 source directory.",
167 path.display()
168 );
169 }
170 }
171 Ok(())
172 }
173 Self::Standalone => {
174 Ok(())
176 }
177 }
178 }
179}
180
181pub struct UpgradeOptions {
183 pub version: Option<String>,
185 pub yes: bool,
187 pub dry_run: bool,
189 pub method: Option<UpgradeMethod>,
191 pub source_path: Option<PathBuf>,
193}
194
195pub fn run_upgrade(options: UpgradeOptions) -> Result<()> {
197 let current_version = env!("CARGO_PKG_VERSION");
198 let current_method = InstallMethod::detect()?;
199
200 println!("mi6 v{}", current_version);
201 println!();
202 println!(
203 "{} {}",
204 bold_green("Installation method:"),
205 current_method.name()
206 );
207 if let InstallMethod::CargoPath(path) = ¤t_method {
208 println!("{} {}", bold_green(" Source code path:"), path.display());
209 }
210 println!();
211
212 let target_method = if let Some(method) = options.method {
214 let path = options.source_path.clone().or_else(|| {
216 if let InstallMethod::CargoPath(p) = ¤t_method {
217 Some(p.clone())
218 } else {
219 None
220 }
221 });
222 let path = path.map(|p| p.canonicalize().unwrap_or(p));
224 InstallMethod::from_upgrade_method(method, path)?
225 } else {
226 current_method.clone()
227 };
228
229 let is_switching = current_method.to_upgrade_method() != target_method.to_upgrade_method();
230
231 if options.version.is_some() && !matches!(target_method, InstallMethod::CargoRegistry) {
233 println!(
234 "Warning: --version is only supported for Cargo registry installs (ignored for {})",
235 target_method.name()
236 );
237 println!();
238 }
239
240 if options.dry_run {
242 if is_switching {
243 println!(
244 "Would switch from {} to {}",
245 current_method.name(),
246 target_method.name()
247 );
248 println!();
249
250 print!("Checking prerequisites for {}... ", target_method.name());
252 match target_method.check_prerequisites() {
253 Ok(()) => println!("OK"),
254 Err(e) => {
255 println!("FAILED");
256 println!();
257 println!("Warning: Switch would fail: {}", e);
258 println!();
259 }
260 }
261 }
262 return check_for_updates(current_version, &target_method);
263 }
264
265 if is_switching {
267 return switch_method(¤t_method, &target_method, &options);
268 }
269
270 if options.version.is_none() && !matches!(target_method, InstallMethod::Standalone) {
274 println!("Checking for updates...");
275 match check_for_update_with_method(current_version, &target_method) {
276 Ok(None) => {
277 if matches!(target_method, InstallMethod::CargoPath(_)) {
279 if let Some(current_hash) =
280 option_env!("MI6_GIT_COMMIT").filter(|s| !s.is_empty())
281 {
282 println!(
283 "{} {} ({})",
284 bold_green(" Installed version:"),
285 bold_white(current_hash),
286 bold_white(&format!("v{}", current_version))
287 );
288 println!(
289 "{} {} ({})",
290 bold_green(" Latest version:"),
291 bold_white(current_hash),
292 bold_white(&format!("v{}", current_version))
293 );
294 println!();
295 println!(
296 "Already on latest version {} ({})",
297 bold_white(current_hash),
298 bold_white(&format!("v{}", current_version))
299 );
300 } else {
301 println!(
302 "Already on latest version ({}).",
303 bold_white(&format!("v{}", current_version))
304 );
305 }
306 } else {
307 println!(
308 "Already on latest version ({}).",
309 bold_white(&format!("v{}", current_version))
310 );
311 }
312 return Ok(());
313 }
314 Ok(Some(update_info)) => {
315 if let Some(ref current_hash) = update_info.current_git_hash {
317 let latest_hash = update_info
318 .latest_version
319 .strip_prefix("git:")
320 .unwrap_or(&update_info.latest_version);
321 println!(
322 "{} {} ({})",
323 bold_green(" Installed version:"),
324 bold_white(current_hash),
325 bold_white(&format!("v{}", current_version))
326 );
327 println!(
328 "{} {} ({})",
329 bold_green(" Latest version:"),
330 bold_white(latest_hash),
331 bold_white(&format!("v{}", current_version))
332 );
333 } else {
334 println!(
335 "{} {}",
336 bold_green(" Installed version:"),
337 bold_white(&format!("v{}", current_version))
338 );
339 println!(
340 "{} {}",
341 bold_green(" Latest version:"),
342 bold_white(&format!("v{}", update_info.latest_version))
343 );
344 }
345 println!();
346 }
347 Err(e) => {
348 println!("Check failed: {}", e);
350 println!();
351 println!("Continuing with upgrade...");
352 println!();
353 }
354 }
355 }
356
357 let completed = match &target_method {
359 InstallMethod::Homebrew => upgrade_homebrew(options.yes)?,
360 InstallMethod::CargoRegistry => {
361 upgrade_cargo_registry(options.version.as_deref(), options.yes)?
362 }
363 InstallMethod::CargoPath(path) => upgrade_cargo_path(path, options.yes)?,
364 InstallMethod::Standalone => upgrade_standalone(current_version, options.yes)?,
365 };
366
367 if completed {
368 println!();
369 println!("Done! Run `mi6 --version` to verify.");
370 }
371
372 Ok(())
373}
374
375fn switch_method(from: &InstallMethod, to: &InstallMethod, options: &UpgradeOptions) -> Result<()> {
384 println!(
385 "Switching installation method: {} -> {}",
386 from.name(),
387 to.name()
388 );
389 println!();
390
391 println!("Checking prerequisites for {}...", to.name());
393 to.check_prerequisites()
394 .with_context(|| format!("Cannot switch to {}: prerequisites not met", to.name()))?;
395 println!("Prerequisites OK.");
396 println!();
397
398 let is_cargo_to_cargo = matches!(
400 (from, to),
401 (
402 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_),
403 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_)
404 )
405 );
406
407 if !options.yes {
409 println!("This will:");
410 if is_cargo_to_cargo {
411 println!(" Install mi6 via {} (overwrites existing)", to.name());
412 } else {
413 println!(" 1. Install mi6 via {}", to.name());
414 println!(" 2. Uninstall mi6 via {} (binary only)", from.name());
415 }
416 println!();
417 println!("Note: Your mi6 data (database, config, themes) will be preserved.");
418 println!();
419 if !confirm_stdout("Proceed with method switch?")? {
420 println!("Aborted.");
421 return Ok(());
422 }
423 println!();
424 }
425
426 if is_cargo_to_cargo {
427 println!("Installing via {} (atomic overwrite)...", to.name());
429 install_binary_forced(to, options.version.as_deref())
430 .with_context(|| format!("Failed to install via {}", to.name()))?;
431 } else {
432 println!("Installing via {}...", to.name());
437 if let Err(install_err) = install_binary(to, options.version.as_deref()) {
438 bail!(
440 "Failed to install via {}: {}\n\n\
441 Your current installation ({}) is unchanged.",
442 to.name(),
443 install_err,
444 from.name()
445 );
446 }
447 println!("Install complete.");
448 println!();
449
450 println!("Removing old installation ({})...", from.name());
452 if let Err(uninstall_err) = uninstall_binary(from) {
453 eprintln!();
456 eprintln!(
457 "WARNING: Could not remove old {} installation: {}",
458 from.name(),
459 uninstall_err
460 );
461 eprintln!();
462 eprintln!("You may have two mi6 binaries installed.");
463 eprintln!("Run `which mi6` to verify you're using the correct binary.");
464 eprintln!();
465 } else {
466 println!("Old installation removed.");
467 println!();
468 }
469 }
470
471 println!(
472 "Done! Successfully switched from {} to {}.",
473 from.name(),
474 to.name()
475 );
476 println!("Run `mi6 --version` to verify.");
477
478 Ok(())
479}
480
481fn uninstall_binary(method: &InstallMethod) -> Result<()> {
483 match method {
484 InstallMethod::Homebrew => {
485 let status = Command::new("brew")
486 .args(["uninstall", "mi6"])
487 .status()
488 .context("failed to run brew uninstall")?;
489 if !status.success() {
490 bail!("brew uninstall failed with exit code: {:?}", status.code());
491 }
492 }
493 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
494 let status = Command::new("cargo")
495 .args(["uninstall", "mi6"])
496 .status()
497 .context("failed to run cargo uninstall")?;
498 if !status.success() {
499 bail!("cargo uninstall failed with exit code: {:?}", status.code());
500 }
501 }
502 InstallMethod::Standalone => {
503 let exe_path =
504 std::env::current_exe().context("failed to get current executable path")?;
505 std::fs::remove_file(&exe_path)
506 .with_context(|| format!("failed to delete {}", exe_path.display()))?;
507 }
508 }
509 Ok(())
510}
511
512fn install_binary(method: &InstallMethod, version: Option<&str>) -> Result<()> {
514 install_binary_impl(method, version, false)
515}
516
517fn install_binary_forced(method: &InstallMethod, version: Option<&str>) -> Result<()> {
519 install_binary_impl(method, version, true)
520}
521
522fn install_binary_impl(method: &InstallMethod, version: Option<&str>, force: bool) -> Result<()> {
524 match method {
525 InstallMethod::Homebrew => {
526 let status = Command::new("brew")
527 .args(["install", "mi6"])
528 .status()
529 .context("failed to run brew install")?;
530 if !status.success() {
531 bail!("brew install failed with exit code: {:?}", status.code());
532 }
533 }
534 InstallMethod::CargoRegistry => {
535 let mut args = vec!["install", "mi6"];
536 if force {
537 args.push("--force");
538 }
539 if let Some(v) = version {
540 args.extend(["--version", v]);
541 }
542 let status = Command::new("cargo")
543 .args(&args)
544 .status()
545 .context("failed to run cargo install")?;
546 if !status.success() {
547 bail!("cargo install failed with exit code: {:?}", status.code());
548 }
549 }
550 InstallMethod::CargoPath(path) => {
551 let path_str = path.to_string_lossy();
552 let mut args = vec!["install", "--path", path_str.as_ref()];
553 if force {
554 args.push("--force");
555 }
556 let status = Command::new("cargo")
557 .args(&args)
558 .status()
559 .context("failed to run cargo install --path")?;
560 if !status.success() {
561 bail!(
562 "cargo install --path failed with exit code: {:?}",
563 status.code()
564 );
565 }
566 }
567 InstallMethod::Standalone => {
568 let install_dir = PathBuf::from("/usr/local/bin");
574
575 if !install_dir.exists() {
577 std::fs::create_dir_all(&install_dir).with_context(|| {
578 format!(
579 "Cannot create install directory: {}\n\
580 You may need to run with sudo or choose a different installation method.",
581 install_dir.display()
582 )
583 })?;
584 }
585
586 let status = self_update::backends::github::Update::configure()
589 .repo_owner("paradigmxyz")
590 .repo_name("mi6")
591 .bin_name("mi6")
592 .bin_install_path(&install_dir)
593 .current_version("0.0.0") .show_download_progress(true)
595 .build()
596 .context("failed to configure self-updater")?
597 .update()
598 .context("failed to download from GitHub releases")?;
599
600 if !status.updated() {
602 bail!(
603 "GitHub download did not produce a binary. \
604 This may indicate a problem with the release assets."
605 );
606 }
607
608 let installed_path = install_dir.join("mi6");
610 println!();
611 println!("Installed to: {}", installed_path.display());
612 println!("Ensure /usr/local/bin is in your PATH.");
613 }
614 }
615 Ok(())
616}
617
618fn check_for_updates(current_version: &str, method: &InstallMethod) -> Result<()> {
620 match method {
621 InstallMethod::Standalone => {
622 println!("Checking GitHub for updates...");
623 println!();
624
625 let update = self_update::backends::github::Update::configure()
626 .repo_owner("paradigmxyz")
627 .repo_name("mi6")
628 .bin_name("mi6")
629 .current_version(current_version)
630 .build()
631 .context("failed to configure self-updater")?;
632
633 let latest = update
634 .get_latest_release()
635 .context("failed to check for updates")?;
636
637 let latest_version = latest.version.trim_start_matches('v');
638
639 if latest_version == current_version {
640 println!("Already at the latest version (v{}).", current_version);
641 } else {
642 println!(
643 "Update available: v{} -> v{}",
644 current_version, latest_version
645 );
646 println!();
647 println!("Run `mi6 upgrade` to install.");
648 }
649 }
650 InstallMethod::Homebrew => {
651 println!("To check for Homebrew updates, run:");
652 println!(" brew outdated mi6");
653 }
654 InstallMethod::CargoRegistry => {
655 println!("To check for Cargo updates, run:");
656 println!(" cargo search mi6");
657 }
658 InstallMethod::CargoPath(path) => {
659 println!("Installed from source at: {}", path.display());
660 println!();
661 println!("To check for updates, pull latest changes and reinstall:");
662 println!(" mi6 upgrade");
663 }
664 }
665
666 Ok(())
667}
668
669fn upgrade_homebrew(skip_confirm: bool) -> Result<bool> {
670 if !skip_confirm {
671 println!("This will run: brew upgrade mi6");
672 if !confirm_stdout("Proceed?")? {
673 println!("Aborted.");
674 return Ok(false);
675 }
676 println!();
677 }
678
679 println!("Running: brew upgrade mi6");
680 println!();
681
682 let status = Command::new("brew")
683 .args(["upgrade", "mi6"])
684 .status()
685 .context("failed to run brew upgrade")?;
686
687 if !status.success() {
688 println!();
690 println!("Note: brew upgrade exited with non-zero status.");
691 println!("This usually means mi6 is already at the latest version.");
692 println!("If not, try: brew reinstall mi6");
693 }
694
695 Ok(true)
696}
697
698fn upgrade_cargo_registry(version: Option<&str>, skip_confirm: bool) -> Result<bool> {
699 let mut args = vec!["install", "mi6"];
700 if let Some(v) = version {
701 args.extend(["--version", v]);
702 }
703
704 if !skip_confirm {
705 println!("This will run: cargo {}", args.join(" "));
706 if !confirm_stdout("Proceed?")? {
707 println!("Aborted.");
708 return Ok(false);
709 }
710 println!();
711 }
712
713 println!("Running: cargo {}", args.join(" "));
714 println!();
715
716 let status = Command::new("cargo")
717 .args(&args)
718 .status()
719 .context("failed to run cargo install")?;
720
721 if !status.success() {
722 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
723 }
724
725 Ok(true)
726}
727
728fn upgrade_cargo_path(path: &std::path::Path, skip_confirm: bool) -> Result<bool> {
729 if !path.exists() {
731 anyhow::bail!(
732 "Source directory no longer exists: {}\n\
733 You may need to reinstall mi6 or update the source location.",
734 path.display()
735 );
736 }
737
738 let workspace_root = find_workspace_root(path)?;
740
741 let pull_target = workspace_root.as_ref().and_then(|root| {
743 let git_dir = root.join(".git");
744 if git_dir.exists() {
745 Some(find_main_worktree(root).unwrap_or_else(|| root.clone()))
746 } else {
747 None
748 }
749 });
750
751 let path_str = path.to_string_lossy();
752 let args = vec!["install", "--path", path_str.as_ref()];
753
754 if !skip_confirm {
755 println!("{}", bold_green("Upgrade process:"));
756 if let Some(ref target) = pull_target {
757 println!(
758 "{} Pull latest changes ({})",
759 bold_green("1."),
760 bold_white(&format!("git pull -C {}", target.display()))
761 );
762 println!(
763 "{} Run cargo install ({})",
764 bold_green("2."),
765 bold_white(&format!("cargo {}", args.join(" ")))
766 );
767 } else {
768 println!(
769 "Run cargo install ({})",
770 bold_white(&format!("cargo {}", args.join(" ")))
771 );
772 }
773 println!();
774 if !confirm_stdout("Proceed?")? {
775 println!("Aborted.");
776 return Ok(false);
777 }
778 println!();
779 }
780
781 if let Some(ref target) = pull_target {
783 println!("Pulling latest changes...");
784 let target_str = target.to_string_lossy();
785
786 let status = Command::new("git")
787 .args(["-C", target_str.as_ref(), "pull"])
788 .status()
789 .context("failed to run git pull")?;
790
791 if !status.success() {
792 anyhow::bail!("git pull failed. Please resolve any conflicts or issues and try again.");
793 }
794 println!();
795 }
796
797 println!("Running: cargo {}", args.join(" "));
798 println!();
799
800 let status = Command::new("cargo")
801 .args(&args)
802 .status()
803 .context("failed to run cargo install --path")?;
804
805 if !status.success() {
806 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
807 }
808
809 Ok(true)
810}
811
812fn find_workspace_root(crate_path: &std::path::Path) -> Result<Option<PathBuf>> {
814 let mut current = crate_path.to_path_buf();
815
816 for _ in 0..10 {
818 let cargo_toml = current.join("Cargo.toml");
819 if cargo_toml.exists() {
820 let content = std::fs::read_to_string(&cargo_toml)?;
821 if content.contains("[workspace]") {
822 return Ok(Some(current));
823 }
824 }
825
826 if !current.pop() {
827 break;
828 }
829 }
830
831 Ok(None)
832}
833
834fn find_main_worktree(git_path: &std::path::Path) -> Option<PathBuf> {
839 let path_str = git_path.to_string_lossy();
840
841 let output = Command::new("git")
843 .args([
844 "-C",
845 &path_str,
846 "rev-parse",
847 "--path-format=absolute",
848 "--git-common-dir",
849 ])
850 .output()
851 .ok()?;
852
853 if !output.status.success() {
854 return None;
855 }
856
857 let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
858 let common_path = PathBuf::from(&common_dir);
859
860 let main_worktree = common_path.parent()?.to_path_buf();
863
864 let git_dir_output = Command::new("git")
867 .args([
868 "-C",
869 &path_str,
870 "rev-parse",
871 "--path-format=absolute",
872 "--git-dir",
873 ])
874 .output()
875 .ok()?;
876
877 if !git_dir_output.status.success() {
878 return None;
879 }
880
881 let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
882 .trim()
883 .to_string();
884
885 if git_dir == common_dir {
887 return None;
888 }
889
890 Some(main_worktree)
891}
892
893fn upgrade_standalone(current_version: &str, skip_confirm: bool) -> Result<bool> {
894 println!("Checking GitHub for updates...");
895 println!();
896
897 let update = self_update::backends::github::Update::configure()
898 .repo_owner("paradigmxyz")
899 .repo_name("mi6")
900 .bin_name("mi6")
901 .current_version(current_version)
902 .build()
903 .context("failed to configure self-updater")?;
904
905 let latest = update
907 .get_latest_release()
908 .context("failed to check for updates")?;
909
910 let latest_version = latest.version.trim_start_matches('v');
911
912 if latest_version == current_version {
913 println!("Already at the latest version (v{}).", current_version);
914 return Ok(true);
915 }
916
917 println!(
918 "New version available: v{} -> v{}",
919 current_version, latest_version
920 );
921
922 if !skip_confirm && !confirm_stdout("Proceed with update?")? {
923 println!("Aborted.");
924 return Ok(false);
925 }
926 println!();
927
928 println!("Downloading and installing v{}...", latest_version);
929
930 let status = self_update::backends::github::Update::configure()
932 .repo_owner("paradigmxyz")
933 .repo_name("mi6")
934 .bin_name("mi6")
935 .current_version(current_version)
936 .show_download_progress(true)
937 .build()
938 .context("failed to configure self-updater")?
939 .update()
940 .context("failed to perform update")?;
941
942 println!();
943 println!("Updated to v{}!", status.version());
944
945 Ok(true)
946}