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)",
100 Self::Standalone => "Standalone",
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 "{} {} install",
204 bold_green("Detected:"),
205 current_method.name()
206 );
207 println!();
208
209 let target_method = if let Some(method) = options.method {
211 let path = options.source_path.clone().or_else(|| {
213 if let InstallMethod::CargoPath(p) = ¤t_method {
214 Some(p.clone())
215 } else {
216 None
217 }
218 });
219 let path = path.map(|p| p.canonicalize().unwrap_or(p));
221 InstallMethod::from_upgrade_method(method, path)?
222 } else {
223 current_method.clone()
224 };
225
226 let is_switching = current_method.to_upgrade_method() != target_method.to_upgrade_method();
227
228 if options.version.is_some() && !matches!(target_method, InstallMethod::CargoRegistry) {
230 println!(
231 "Warning: --version is only supported for Cargo registry installs (ignored for {})",
232 target_method.name()
233 );
234 println!();
235 }
236
237 if options.dry_run {
239 if is_switching {
240 println!(
241 "Would switch from {} to {}",
242 current_method.name(),
243 target_method.name()
244 );
245 println!();
246
247 print!("Checking prerequisites for {}... ", target_method.name());
249 match target_method.check_prerequisites() {
250 Ok(()) => println!("OK"),
251 Err(e) => {
252 println!("FAILED");
253 println!();
254 println!("Warning: Switch would fail: {}", e);
255 println!();
256 }
257 }
258 }
259 return check_for_updates(current_version, &target_method);
260 }
261
262 if is_switching {
264 return switch_method(¤t_method, &target_method, &options);
265 }
266
267 if options.version.is_none() {
269 print!("Checking for updates... ");
270 match check_for_update_with_method(current_version, &target_method) {
271 Ok(None) => {
272 println!("already on latest version (v{}).", current_version);
273 return Ok(());
274 }
275 Ok(Some(update_info)) => {
276 println!(
277 "v{} available (current: v{}).",
278 update_info.latest_version, current_version
279 );
280 println!();
281 }
282 Err(e) => {
283 println!("check failed: {}", e);
285 println!();
286 println!("Continuing with upgrade...");
287 println!();
288 }
289 }
290 }
291
292 let completed = match &target_method {
294 InstallMethod::Homebrew => upgrade_homebrew(options.yes)?,
295 InstallMethod::CargoRegistry => {
296 upgrade_cargo_registry(options.version.as_deref(), options.yes)?
297 }
298 InstallMethod::CargoPath(path) => upgrade_cargo_path(path, options.yes)?,
299 InstallMethod::Standalone => upgrade_standalone(current_version, options.yes)?,
300 };
301
302 if completed {
303 println!();
304 println!("Done! Run `mi6 --version` to verify.");
305 }
306
307 Ok(())
308}
309
310fn switch_method(from: &InstallMethod, to: &InstallMethod, options: &UpgradeOptions) -> Result<()> {
319 println!(
320 "Switching installation method: {} -> {}",
321 from.name(),
322 to.name()
323 );
324 println!();
325
326 println!("Checking prerequisites for {}...", to.name());
328 to.check_prerequisites()
329 .with_context(|| format!("Cannot switch to {}: prerequisites not met", to.name()))?;
330 println!("Prerequisites OK.");
331 println!();
332
333 let is_cargo_to_cargo = matches!(
335 (from, to),
336 (
337 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_),
338 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_)
339 )
340 );
341
342 if !options.yes {
344 println!("This will:");
345 if is_cargo_to_cargo {
346 println!(" Install mi6 via {} (overwrites existing)", to.name());
347 } else {
348 println!(" 1. Install mi6 via {}", to.name());
349 println!(" 2. Uninstall mi6 via {} (binary only)", from.name());
350 }
351 println!();
352 println!("Note: Your mi6 data (database, config, themes) will be preserved.");
353 println!();
354 if !confirm_stdout("Proceed with method switch?")? {
355 println!("Aborted.");
356 return Ok(());
357 }
358 println!();
359 }
360
361 if is_cargo_to_cargo {
362 println!("Installing via {} (atomic overwrite)...", to.name());
364 install_binary_forced(to, options.version.as_deref())
365 .with_context(|| format!("Failed to install via {}", to.name()))?;
366 } else {
367 println!("Installing via {}...", to.name());
372 if let Err(install_err) = install_binary(to, options.version.as_deref()) {
373 bail!(
375 "Failed to install via {}: {}\n\n\
376 Your current installation ({}) is unchanged.",
377 to.name(),
378 install_err,
379 from.name()
380 );
381 }
382 println!("Install complete.");
383 println!();
384
385 println!("Removing old installation ({})...", from.name());
387 if let Err(uninstall_err) = uninstall_binary(from) {
388 eprintln!();
391 eprintln!(
392 "WARNING: Could not remove old {} installation: {}",
393 from.name(),
394 uninstall_err
395 );
396 eprintln!();
397 eprintln!("You may have two mi6 binaries installed.");
398 eprintln!("Run `which mi6` to verify you're using the correct binary.");
399 eprintln!();
400 } else {
401 println!("Old installation removed.");
402 println!();
403 }
404 }
405
406 println!(
407 "Done! Successfully switched from {} to {}.",
408 from.name(),
409 to.name()
410 );
411 println!("Run `mi6 --version` to verify.");
412
413 Ok(())
414}
415
416fn uninstall_binary(method: &InstallMethod) -> Result<()> {
418 match method {
419 InstallMethod::Homebrew => {
420 let status = Command::new("brew")
421 .args(["uninstall", "mi6"])
422 .status()
423 .context("failed to run brew uninstall")?;
424 if !status.success() {
425 bail!("brew uninstall failed with exit code: {:?}", status.code());
426 }
427 }
428 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
429 let status = Command::new("cargo")
430 .args(["uninstall", "mi6"])
431 .status()
432 .context("failed to run cargo uninstall")?;
433 if !status.success() {
434 bail!("cargo uninstall failed with exit code: {:?}", status.code());
435 }
436 }
437 InstallMethod::Standalone => {
438 let exe_path =
439 std::env::current_exe().context("failed to get current executable path")?;
440 std::fs::remove_file(&exe_path)
441 .with_context(|| format!("failed to delete {}", exe_path.display()))?;
442 }
443 }
444 Ok(())
445}
446
447fn install_binary(method: &InstallMethod, version: Option<&str>) -> Result<()> {
449 install_binary_impl(method, version, false)
450}
451
452fn install_binary_forced(method: &InstallMethod, version: Option<&str>) -> Result<()> {
454 install_binary_impl(method, version, true)
455}
456
457fn install_binary_impl(method: &InstallMethod, version: Option<&str>, force: bool) -> Result<()> {
459 match method {
460 InstallMethod::Homebrew => {
461 let status = Command::new("brew")
462 .args(["install", "mi6"])
463 .status()
464 .context("failed to run brew install")?;
465 if !status.success() {
466 bail!("brew install failed with exit code: {:?}", status.code());
467 }
468 }
469 InstallMethod::CargoRegistry => {
470 let mut args = vec!["install", "mi6"];
471 if force {
472 args.push("--force");
473 }
474 if let Some(v) = version {
475 args.extend(["--version", v]);
476 }
477 let status = Command::new("cargo")
478 .args(&args)
479 .status()
480 .context("failed to run cargo install")?;
481 if !status.success() {
482 bail!("cargo install failed with exit code: {:?}", status.code());
483 }
484 }
485 InstallMethod::CargoPath(path) => {
486 let path_str = path.to_string_lossy();
487 let mut args = vec!["install", "--path", path_str.as_ref()];
488 if force {
489 args.push("--force");
490 }
491 let status = Command::new("cargo")
492 .args(&args)
493 .status()
494 .context("failed to run cargo install --path")?;
495 if !status.success() {
496 bail!(
497 "cargo install --path failed with exit code: {:?}",
498 status.code()
499 );
500 }
501 }
502 InstallMethod::Standalone => {
503 let install_dir = PathBuf::from("/usr/local/bin");
509
510 if !install_dir.exists() {
512 std::fs::create_dir_all(&install_dir).with_context(|| {
513 format!(
514 "Cannot create install directory: {}\n\
515 You may need to run with sudo or choose a different installation method.",
516 install_dir.display()
517 )
518 })?;
519 }
520
521 let status = self_update::backends::github::Update::configure()
524 .repo_owner("paradigmxyz")
525 .repo_name("mi6")
526 .bin_name("mi6")
527 .bin_install_path(&install_dir)
528 .current_version("0.0.0") .show_download_progress(true)
530 .build()
531 .context("failed to configure self-updater")?
532 .update()
533 .context("failed to download from GitHub releases")?;
534
535 if !status.updated() {
537 bail!(
538 "GitHub download did not produce a binary. \
539 This may indicate a problem with the release assets."
540 );
541 }
542
543 let installed_path = install_dir.join("mi6");
545 println!();
546 println!("Installed to: {}", installed_path.display());
547 println!("Ensure /usr/local/bin is in your PATH.");
548 }
549 }
550 Ok(())
551}
552
553fn check_for_updates(current_version: &str, method: &InstallMethod) -> Result<()> {
555 match method {
556 InstallMethod::Standalone => {
557 println!("Checking GitHub for updates...");
558 println!();
559
560 let update = self_update::backends::github::Update::configure()
561 .repo_owner("paradigmxyz")
562 .repo_name("mi6")
563 .bin_name("mi6")
564 .current_version(current_version)
565 .build()
566 .context("failed to configure self-updater")?;
567
568 let latest = update
569 .get_latest_release()
570 .context("failed to check for updates")?;
571
572 let latest_version = latest.version.trim_start_matches('v');
573
574 if latest_version == current_version {
575 println!("Already at the latest version (v{}).", current_version);
576 } else {
577 println!(
578 "Update available: v{} -> v{}",
579 current_version, latest_version
580 );
581 println!();
582 println!("Run `mi6 upgrade` to install.");
583 }
584 }
585 InstallMethod::Homebrew => {
586 println!("To check for Homebrew updates, run:");
587 println!(" brew outdated mi6");
588 }
589 InstallMethod::CargoRegistry => {
590 println!("To check for Cargo updates, run:");
591 println!(" cargo search mi6");
592 }
593 InstallMethod::CargoPath(path) => {
594 println!("Installed from source at: {}", path.display());
595 println!();
596 println!("To check for updates, pull latest changes and reinstall:");
597 println!(" mi6 upgrade");
598 }
599 }
600
601 Ok(())
602}
603
604fn upgrade_homebrew(skip_confirm: bool) -> Result<bool> {
605 if !skip_confirm {
606 println!("This will run: brew upgrade mi6");
607 if !confirm_stdout("Proceed?")? {
608 println!("Aborted.");
609 return Ok(false);
610 }
611 println!();
612 }
613
614 println!("Running: brew upgrade mi6");
615 println!();
616
617 let status = Command::new("brew")
618 .args(["upgrade", "mi6"])
619 .status()
620 .context("failed to run brew upgrade")?;
621
622 if !status.success() {
623 println!();
625 println!("Note: brew upgrade exited with non-zero status.");
626 println!("This usually means mi6 is already at the latest version.");
627 println!("If not, try: brew reinstall mi6");
628 }
629
630 Ok(true)
631}
632
633fn upgrade_cargo_registry(version: Option<&str>, skip_confirm: bool) -> Result<bool> {
634 let mut args = vec!["install", "mi6"];
635 if let Some(v) = version {
636 args.extend(["--version", v]);
637 }
638
639 if !skip_confirm {
640 println!("This will run: cargo {}", args.join(" "));
641 if !confirm_stdout("Proceed?")? {
642 println!("Aborted.");
643 return Ok(false);
644 }
645 println!();
646 }
647
648 println!("Running: cargo {}", args.join(" "));
649 println!();
650
651 let status = Command::new("cargo")
652 .args(&args)
653 .status()
654 .context("failed to run cargo install")?;
655
656 if !status.success() {
657 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
658 }
659
660 Ok(true)
661}
662
663fn upgrade_cargo_path(path: &std::path::Path, skip_confirm: bool) -> Result<bool> {
664 if !path.exists() {
666 anyhow::bail!(
667 "Source directory no longer exists: {}\n\
668 You may need to reinstall mi6 or update the source location.",
669 path.display()
670 );
671 }
672
673 let workspace_root = find_workspace_root(path)?;
675
676 let pull_target = workspace_root.as_ref().and_then(|root| {
678 let git_dir = root.join(".git");
679 if git_dir.exists() {
680 Some(find_main_worktree(root).unwrap_or_else(|| root.clone()))
681 } else {
682 None
683 }
684 });
685
686 let path_str = path.to_string_lossy();
687 let args = vec!["install", "--path", path_str.as_ref()];
688
689 if !skip_confirm {
690 println!("{}", bold_green("Upgrade process:"));
691 if let Some(ref target) = pull_target {
692 println!(
693 "{} Pull latest changes ({})",
694 bold_green("1."),
695 bold_white(&format!("git pull -C {}", target.display()))
696 );
697 println!(
698 "{} Run cargo install ({})",
699 bold_green("2."),
700 bold_white(&format!("cargo {}", args.join(" ")))
701 );
702 } else {
703 println!(
704 "Run cargo install ({})",
705 bold_white(&format!("cargo {}", args.join(" ")))
706 );
707 }
708 println!();
709 if !confirm_stdout("Proceed?")? {
710 println!("Aborted.");
711 return Ok(false);
712 }
713 println!();
714 }
715
716 if let Some(ref target) = pull_target {
718 println!("Pulling latest changes...");
719 let target_str = target.to_string_lossy();
720
721 let status = Command::new("git")
722 .args(["-C", target_str.as_ref(), "pull"])
723 .status()
724 .context("failed to run git pull")?;
725
726 if !status.success() {
727 anyhow::bail!("git pull failed. Please resolve any conflicts or issues and try again.");
728 }
729 println!();
730 }
731
732 println!("Running: cargo {}", args.join(" "));
733 println!();
734
735 let status = Command::new("cargo")
736 .args(&args)
737 .status()
738 .context("failed to run cargo install --path")?;
739
740 if !status.success() {
741 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
742 }
743
744 Ok(true)
745}
746
747fn find_workspace_root(crate_path: &std::path::Path) -> Result<Option<PathBuf>> {
749 let mut current = crate_path.to_path_buf();
750
751 for _ in 0..10 {
753 let cargo_toml = current.join("Cargo.toml");
754 if cargo_toml.exists() {
755 let content = std::fs::read_to_string(&cargo_toml)?;
756 if content.contains("[workspace]") {
757 return Ok(Some(current));
758 }
759 }
760
761 if !current.pop() {
762 break;
763 }
764 }
765
766 Ok(None)
767}
768
769fn find_main_worktree(git_path: &std::path::Path) -> Option<PathBuf> {
774 let path_str = git_path.to_string_lossy();
775
776 let output = Command::new("git")
778 .args([
779 "-C",
780 &path_str,
781 "rev-parse",
782 "--path-format=absolute",
783 "--git-common-dir",
784 ])
785 .output()
786 .ok()?;
787
788 if !output.status.success() {
789 return None;
790 }
791
792 let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
793 let common_path = PathBuf::from(&common_dir);
794
795 let main_worktree = common_path.parent()?.to_path_buf();
798
799 let git_dir_output = Command::new("git")
802 .args([
803 "-C",
804 &path_str,
805 "rev-parse",
806 "--path-format=absolute",
807 "--git-dir",
808 ])
809 .output()
810 .ok()?;
811
812 if !git_dir_output.status.success() {
813 return None;
814 }
815
816 let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
817 .trim()
818 .to_string();
819
820 if git_dir == common_dir {
822 return None;
823 }
824
825 Some(main_worktree)
826}
827
828fn upgrade_standalone(current_version: &str, skip_confirm: bool) -> Result<bool> {
829 println!("Checking GitHub for updates...");
830 println!();
831
832 let update = self_update::backends::github::Update::configure()
833 .repo_owner("paradigmxyz")
834 .repo_name("mi6")
835 .bin_name("mi6")
836 .current_version(current_version)
837 .build()
838 .context("failed to configure self-updater")?;
839
840 let latest = update
842 .get_latest_release()
843 .context("failed to check for updates")?;
844
845 let latest_version = latest.version.trim_start_matches('v');
846
847 if latest_version == current_version {
848 println!("Already at the latest version (v{}).", current_version);
849 return Ok(true);
850 }
851
852 println!(
853 "New version available: v{} -> v{}",
854 current_version, latest_version
855 );
856
857 if !skip_confirm && !confirm_stdout("Proceed with update?")? {
858 println!("Aborted.");
859 return Ok(false);
860 }
861 println!();
862
863 println!("Downloading and installing v{}...", latest_version);
864
865 let status = self_update::backends::github::Update::configure()
867 .repo_owner("paradigmxyz")
868 .repo_name("mi6")
869 .bin_name("mi6")
870 .current_version(current_version)
871 .show_download_progress(true)
872 .build()
873 .context("failed to configure self-updater")?
874 .update()
875 .context("failed to perform update")?;
876
877 println!();
878 println!("Updated to v{}!", status.version());
879
880 Ok(true)
881}