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
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum InstallMethod {
22 Homebrew,
24 CargoRegistry,
26 CargoPath(PathBuf),
28 Standalone,
30}
31
32impl InstallMethod {
33 pub fn detect() -> Result<Self> {
35 let exe_path = std::env::current_exe().context("failed to get current executable path")?;
36 let path_str = exe_path.to_string_lossy();
37
38 if path_str.contains("/Cellar/") || path_str.contains("/homebrew/") {
39 return Ok(Self::Homebrew);
40 }
41
42 if path_str.contains("/.cargo/bin/") {
43 if let Some(method) = Self::detect_cargo_source()? {
45 return Ok(method);
46 }
47 return Ok(Self::CargoRegistry);
49 }
50
51 Ok(Self::Standalone)
52 }
53
54 fn detect_cargo_source() -> Result<Option<Self>> {
60 let output = Command::new("cargo")
61 .args(["install", "--list"])
62 .output()
63 .context("failed to run cargo install --list")?;
64
65 if !output.status.success() {
66 return Ok(None);
67 }
68
69 let stdout = String::from_utf8_lossy(&output.stdout);
70
71 for line in stdout.lines() {
73 if !line.starts_with("mi6 v") {
74 continue;
75 }
76
77 if let Some(start) = line.find(" (")
79 && let Some(end) = line.rfind("):")
80 {
81 let path_str = &line[start + 2..end];
82 return Ok(Some(Self::CargoPath(PathBuf::from(path_str))));
83 }
84
85 return Ok(Some(Self::CargoRegistry));
87 }
88
89 Ok(None)
90 }
91
92 pub fn name(&self) -> &'static str {
94 match self {
95 Self::Homebrew => "Homebrew",
96 Self::CargoRegistry => "Cargo (crates.io)",
97 Self::CargoPath(_) => "Cargo (source)",
98 Self::Standalone => "Standalone",
99 }
100 }
101
102 pub fn to_upgrade_method(&self) -> UpgradeMethod {
104 match self {
105 Self::Homebrew => UpgradeMethod::Brew,
106 Self::CargoRegistry => UpgradeMethod::CargoCrates,
107 Self::CargoPath(_) => UpgradeMethod::CargoSource,
108 Self::Standalone => UpgradeMethod::Github,
109 }
110 }
111
112 pub fn from_upgrade_method(method: UpgradeMethod, path: Option<PathBuf>) -> Result<Self> {
114 match method {
115 UpgradeMethod::Brew => Ok(Self::Homebrew),
116 UpgradeMethod::CargoCrates => Ok(Self::CargoRegistry),
117 UpgradeMethod::Github => Ok(Self::Standalone),
118 UpgradeMethod::CargoSource => {
119 let source_path = path.ok_or_else(|| {
120 anyhow::anyhow!(
121 "--source-path is required when using --method cargo_source\n\
122 Example: mi6 upgrade --method cargo_source --source-path /path/to/mi6"
123 )
124 })?;
125 Ok(Self::CargoPath(source_path))
126 }
127 }
128 }
129
130 pub fn check_prerequisites(&self) -> Result<()> {
132 match self {
133 Self::Homebrew => {
134 let output = Command::new("brew")
135 .arg("--version")
136 .output()
137 .context("brew is not installed or not in PATH")?;
138 if !output.status.success() {
139 bail!("brew is not working properly");
140 }
141 Ok(())
142 }
143 Self::CargoRegistry | Self::CargoPath(_) => {
144 let output = Command::new("cargo")
145 .arg("--version")
146 .output()
147 .context("cargo is not installed or not in PATH")?;
148 if !output.status.success() {
149 bail!("cargo is not working properly");
150 }
151 if let Self::CargoPath(path) = self {
153 if !path.exists() {
154 bail!(
155 "Source directory does not exist: {}\n\
156 Please provide a valid path to the mi6 source.",
157 path.display()
158 );
159 }
160 let cargo_toml = path.join("Cargo.toml");
161 if !cargo_toml.exists() {
162 bail!(
163 "Not a valid Rust project (no Cargo.toml): {}\n\
164 Please provide a path to the mi6 source directory.",
165 path.display()
166 );
167 }
168 }
169 Ok(())
170 }
171 Self::Standalone => {
172 Ok(())
174 }
175 }
176 }
177}
178
179pub struct UpgradeOptions {
181 pub version: Option<String>,
183 pub yes: bool,
185 pub dry_run: bool,
187 pub method: Option<UpgradeMethod>,
189 pub source_path: Option<PathBuf>,
191}
192
193pub fn run_upgrade(options: UpgradeOptions) -> Result<()> {
195 let current_version = env!("CARGO_PKG_VERSION");
196 let current_method = InstallMethod::detect()?;
197
198 println!("mi6 v{}", current_version);
199 println!();
200 println!(
201 "{} {} install",
202 bold_green("Detected:"),
203 current_method.name()
204 );
205 println!();
206
207 let target_method = if let Some(method) = options.method {
209 let path = options.source_path.clone().or_else(|| {
211 if let InstallMethod::CargoPath(p) = ¤t_method {
212 Some(p.clone())
213 } else {
214 None
215 }
216 });
217 let path = path.map(|p| p.canonicalize().unwrap_or(p));
219 InstallMethod::from_upgrade_method(method, path)?
220 } else {
221 current_method.clone()
222 };
223
224 let is_switching = current_method.to_upgrade_method() != target_method.to_upgrade_method();
225
226 if options.version.is_some() && !matches!(target_method, InstallMethod::CargoRegistry) {
228 println!(
229 "Warning: --version is only supported for Cargo registry installs (ignored for {})",
230 target_method.name()
231 );
232 println!();
233 }
234
235 if options.dry_run {
237 if is_switching {
238 println!(
239 "Would switch from {} to {}",
240 current_method.name(),
241 target_method.name()
242 );
243 println!();
244
245 print!("Checking prerequisites for {}... ", target_method.name());
247 match target_method.check_prerequisites() {
248 Ok(()) => println!("OK"),
249 Err(e) => {
250 println!("FAILED");
251 println!();
252 println!("Warning: Switch would fail: {}", e);
253 println!();
254 }
255 }
256 }
257 return check_for_updates(current_version, &target_method);
258 }
259
260 if is_switching {
262 return switch_method(¤t_method, &target_method, &options);
263 }
264
265 let completed = match &target_method {
267 InstallMethod::Homebrew => upgrade_homebrew(options.yes)?,
268 InstallMethod::CargoRegistry => {
269 upgrade_cargo_registry(options.version.as_deref(), options.yes)?
270 }
271 InstallMethod::CargoPath(path) => upgrade_cargo_path(path, options.yes)?,
272 InstallMethod::Standalone => upgrade_standalone(current_version, options.yes)?,
273 };
274
275 if completed {
276 println!();
277 println!("Done! Run `mi6 --version` to verify.");
278 }
279
280 Ok(())
281}
282
283fn switch_method(from: &InstallMethod, to: &InstallMethod, options: &UpgradeOptions) -> Result<()> {
292 println!(
293 "Switching installation method: {} -> {}",
294 from.name(),
295 to.name()
296 );
297 println!();
298
299 println!("Checking prerequisites for {}...", to.name());
301 to.check_prerequisites()
302 .with_context(|| format!("Cannot switch to {}: prerequisites not met", to.name()))?;
303 println!("Prerequisites OK.");
304 println!();
305
306 let is_cargo_to_cargo = matches!(
308 (from, to),
309 (
310 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_),
311 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_)
312 )
313 );
314
315 if !options.yes {
317 println!("This will:");
318 if is_cargo_to_cargo {
319 println!(" Install mi6 via {} (overwrites existing)", to.name());
320 } else {
321 println!(" 1. Install mi6 via {}", to.name());
322 println!(" 2. Uninstall mi6 via {} (binary only)", from.name());
323 }
324 println!();
325 println!("Note: Your mi6 data (database, config, themes) will be preserved.");
326 println!();
327 if !confirm_stdout("Proceed with method switch?")? {
328 println!("Aborted.");
329 return Ok(());
330 }
331 println!();
332 }
333
334 if is_cargo_to_cargo {
335 println!("Installing via {} (atomic overwrite)...", to.name());
337 install_binary_forced(to, options.version.as_deref())
338 .with_context(|| format!("Failed to install via {}", to.name()))?;
339 } else {
340 println!("Installing via {}...", to.name());
345 if let Err(install_err) = install_binary(to, options.version.as_deref()) {
346 bail!(
348 "Failed to install via {}: {}\n\n\
349 Your current installation ({}) is unchanged.",
350 to.name(),
351 install_err,
352 from.name()
353 );
354 }
355 println!("Install complete.");
356 println!();
357
358 println!("Removing old installation ({})...", from.name());
360 if let Err(uninstall_err) = uninstall_binary(from) {
361 eprintln!();
364 eprintln!(
365 "WARNING: Could not remove old {} installation: {}",
366 from.name(),
367 uninstall_err
368 );
369 eprintln!();
370 eprintln!("You may have two mi6 binaries installed.");
371 eprintln!("Run `which mi6` to verify you're using the correct binary.");
372 eprintln!();
373 } else {
374 println!("Old installation removed.");
375 println!();
376 }
377 }
378
379 println!(
380 "Done! Successfully switched from {} to {}.",
381 from.name(),
382 to.name()
383 );
384 println!("Run `mi6 --version` to verify.");
385
386 Ok(())
387}
388
389fn uninstall_binary(method: &InstallMethod) -> Result<()> {
391 match method {
392 InstallMethod::Homebrew => {
393 let status = Command::new("brew")
394 .args(["uninstall", "mi6"])
395 .status()
396 .context("failed to run brew uninstall")?;
397 if !status.success() {
398 bail!("brew uninstall failed with exit code: {:?}", status.code());
399 }
400 }
401 InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
402 let status = Command::new("cargo")
403 .args(["uninstall", "mi6"])
404 .status()
405 .context("failed to run cargo uninstall")?;
406 if !status.success() {
407 bail!("cargo uninstall failed with exit code: {:?}", status.code());
408 }
409 }
410 InstallMethod::Standalone => {
411 let exe_path =
412 std::env::current_exe().context("failed to get current executable path")?;
413 std::fs::remove_file(&exe_path)
414 .with_context(|| format!("failed to delete {}", exe_path.display()))?;
415 }
416 }
417 Ok(())
418}
419
420fn install_binary(method: &InstallMethod, version: Option<&str>) -> Result<()> {
422 install_binary_impl(method, version, false)
423}
424
425fn install_binary_forced(method: &InstallMethod, version: Option<&str>) -> Result<()> {
427 install_binary_impl(method, version, true)
428}
429
430fn install_binary_impl(method: &InstallMethod, version: Option<&str>, force: bool) -> Result<()> {
432 match method {
433 InstallMethod::Homebrew => {
434 let status = Command::new("brew")
435 .args(["install", "mi6"])
436 .status()
437 .context("failed to run brew install")?;
438 if !status.success() {
439 bail!("brew install failed with exit code: {:?}", status.code());
440 }
441 }
442 InstallMethod::CargoRegistry => {
443 let mut args = vec!["install", "mi6"];
444 if force {
445 args.push("--force");
446 }
447 if let Some(v) = version {
448 args.extend(["--version", v]);
449 }
450 let status = Command::new("cargo")
451 .args(&args)
452 .status()
453 .context("failed to run cargo install")?;
454 if !status.success() {
455 bail!("cargo install failed with exit code: {:?}", status.code());
456 }
457 }
458 InstallMethod::CargoPath(path) => {
459 let path_str = path.to_string_lossy();
460 let mut args = vec!["install", "--path", path_str.as_ref()];
461 if force {
462 args.push("--force");
463 }
464 let status = Command::new("cargo")
465 .args(&args)
466 .status()
467 .context("failed to run cargo install --path")?;
468 if !status.success() {
469 bail!(
470 "cargo install --path failed with exit code: {:?}",
471 status.code()
472 );
473 }
474 }
475 InstallMethod::Standalone => {
476 let install_dir = PathBuf::from("/usr/local/bin");
482
483 if !install_dir.exists() {
485 std::fs::create_dir_all(&install_dir).with_context(|| {
486 format!(
487 "Cannot create install directory: {}\n\
488 You may need to run with sudo or choose a different installation method.",
489 install_dir.display()
490 )
491 })?;
492 }
493
494 let status = self_update::backends::github::Update::configure()
497 .repo_owner("paradigmxyz")
498 .repo_name("mi6")
499 .bin_name("mi6")
500 .bin_install_path(&install_dir)
501 .current_version("0.0.0") .show_download_progress(true)
503 .build()
504 .context("failed to configure self-updater")?
505 .update()
506 .context("failed to download from GitHub releases")?;
507
508 if !status.updated() {
510 bail!(
511 "GitHub download did not produce a binary. \
512 This may indicate a problem with the release assets."
513 );
514 }
515
516 let installed_path = install_dir.join("mi6");
518 println!();
519 println!("Installed to: {}", installed_path.display());
520 println!("Ensure /usr/local/bin is in your PATH.");
521 }
522 }
523 Ok(())
524}
525
526fn check_for_updates(current_version: &str, method: &InstallMethod) -> Result<()> {
528 match method {
529 InstallMethod::Standalone => {
530 println!("Checking GitHub for updates...");
531 println!();
532
533 let update = self_update::backends::github::Update::configure()
534 .repo_owner("paradigmxyz")
535 .repo_name("mi6")
536 .bin_name("mi6")
537 .current_version(current_version)
538 .build()
539 .context("failed to configure self-updater")?;
540
541 let latest = update
542 .get_latest_release()
543 .context("failed to check for updates")?;
544
545 let latest_version = latest.version.trim_start_matches('v');
546
547 if latest_version == current_version {
548 println!("Already at the latest version (v{}).", current_version);
549 } else {
550 println!(
551 "Update available: v{} -> v{}",
552 current_version, latest_version
553 );
554 println!();
555 println!("Run `mi6 upgrade` to install.");
556 }
557 }
558 InstallMethod::Homebrew => {
559 println!("To check for Homebrew updates, run:");
560 println!(" brew outdated mi6");
561 }
562 InstallMethod::CargoRegistry => {
563 println!("To check for Cargo updates, run:");
564 println!(" cargo search mi6");
565 }
566 InstallMethod::CargoPath(path) => {
567 println!("Installed from source at: {}", path.display());
568 println!();
569 println!("To check for updates, pull latest changes and reinstall:");
570 println!(" mi6 upgrade");
571 }
572 }
573
574 Ok(())
575}
576
577fn upgrade_homebrew(skip_confirm: bool) -> Result<bool> {
578 if !skip_confirm {
579 println!("This will run: brew upgrade mi6");
580 if !confirm_stdout("Proceed?")? {
581 println!("Aborted.");
582 return Ok(false);
583 }
584 println!();
585 }
586
587 println!("Running: brew upgrade mi6");
588 println!();
589
590 let status = Command::new("brew")
591 .args(["upgrade", "mi6"])
592 .status()
593 .context("failed to run brew upgrade")?;
594
595 if !status.success() {
596 println!();
598 println!("Note: brew upgrade exited with non-zero status.");
599 println!("This usually means mi6 is already at the latest version.");
600 println!("If not, try: brew reinstall mi6");
601 }
602
603 Ok(true)
604}
605
606fn upgrade_cargo_registry(version: Option<&str>, skip_confirm: bool) -> Result<bool> {
607 let mut args = vec!["install", "mi6"];
608 if let Some(v) = version {
609 args.extend(["--version", v]);
610 }
611
612 if !skip_confirm {
613 println!("This will run: cargo {}", args.join(" "));
614 if !confirm_stdout("Proceed?")? {
615 println!("Aborted.");
616 return Ok(false);
617 }
618 println!();
619 }
620
621 println!("Running: cargo {}", args.join(" "));
622 println!();
623
624 let status = Command::new("cargo")
625 .args(&args)
626 .status()
627 .context("failed to run cargo install")?;
628
629 if !status.success() {
630 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
631 }
632
633 Ok(true)
634}
635
636fn upgrade_cargo_path(path: &std::path::Path, skip_confirm: bool) -> Result<bool> {
637 if !path.exists() {
639 anyhow::bail!(
640 "Source directory no longer exists: {}\n\
641 You may need to reinstall mi6 or update the source location.",
642 path.display()
643 );
644 }
645
646 let workspace_root = find_workspace_root(path)?;
648
649 let pull_target = workspace_root.as_ref().and_then(|root| {
651 let git_dir = root.join(".git");
652 if git_dir.exists() {
653 Some(find_main_worktree(root).unwrap_or_else(|| root.clone()))
654 } else {
655 None
656 }
657 });
658
659 let path_str = path.to_string_lossy();
660 let args = vec!["install", "--path", path_str.as_ref()];
661
662 if !skip_confirm {
663 println!("{}", bold_green("Upgrade process:"));
664 if let Some(ref target) = pull_target {
665 println!(
666 "{} Pull latest changes ({})",
667 bold_green("1."),
668 bold_white(&format!("git pull -C {}", target.display()))
669 );
670 println!(
671 "{} Run cargo install ({})",
672 bold_green("2."),
673 bold_white(&format!("cargo {}", args.join(" ")))
674 );
675 } else {
676 println!(
677 "Run cargo install ({})",
678 bold_white(&format!("cargo {}", args.join(" ")))
679 );
680 }
681 println!();
682 if !confirm_stdout("Proceed?")? {
683 println!("Aborted.");
684 return Ok(false);
685 }
686 println!();
687 }
688
689 if let Some(ref target) = pull_target {
691 println!("Pulling latest changes...");
692 let target_str = target.to_string_lossy();
693
694 let status = Command::new("git")
695 .args(["-C", target_str.as_ref(), "pull"])
696 .status()
697 .context("failed to run git pull")?;
698
699 if !status.success() {
700 anyhow::bail!("git pull failed. Please resolve any conflicts or issues and try again.");
701 }
702 println!();
703 }
704
705 println!("Running: cargo {}", args.join(" "));
706 println!();
707
708 let status = Command::new("cargo")
709 .args(&args)
710 .status()
711 .context("failed to run cargo install --path")?;
712
713 if !status.success() {
714 anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
715 }
716
717 Ok(true)
718}
719
720fn find_workspace_root(crate_path: &std::path::Path) -> Result<Option<PathBuf>> {
722 let mut current = crate_path.to_path_buf();
723
724 for _ in 0..10 {
726 let cargo_toml = current.join("Cargo.toml");
727 if cargo_toml.exists() {
728 let content = std::fs::read_to_string(&cargo_toml)?;
729 if content.contains("[workspace]") {
730 return Ok(Some(current));
731 }
732 }
733
734 if !current.pop() {
735 break;
736 }
737 }
738
739 Ok(None)
740}
741
742fn find_main_worktree(git_path: &std::path::Path) -> Option<PathBuf> {
747 let path_str = git_path.to_string_lossy();
748
749 let output = Command::new("git")
751 .args([
752 "-C",
753 &path_str,
754 "rev-parse",
755 "--path-format=absolute",
756 "--git-common-dir",
757 ])
758 .output()
759 .ok()?;
760
761 if !output.status.success() {
762 return None;
763 }
764
765 let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
766 let common_path = PathBuf::from(&common_dir);
767
768 let main_worktree = common_path.parent()?.to_path_buf();
771
772 let git_dir_output = Command::new("git")
775 .args([
776 "-C",
777 &path_str,
778 "rev-parse",
779 "--path-format=absolute",
780 "--git-dir",
781 ])
782 .output()
783 .ok()?;
784
785 if !git_dir_output.status.success() {
786 return None;
787 }
788
789 let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
790 .trim()
791 .to_string();
792
793 if git_dir == common_dir {
795 return None;
796 }
797
798 Some(main_worktree)
799}
800
801fn upgrade_standalone(current_version: &str, skip_confirm: bool) -> Result<bool> {
802 println!("Checking GitHub for updates...");
803 println!();
804
805 let update = self_update::backends::github::Update::configure()
806 .repo_owner("paradigmxyz")
807 .repo_name("mi6")
808 .bin_name("mi6")
809 .current_version(current_version)
810 .build()
811 .context("failed to configure self-updater")?;
812
813 let latest = update
815 .get_latest_release()
816 .context("failed to check for updates")?;
817
818 let latest_version = latest.version.trim_start_matches('v');
819
820 if latest_version == current_version {
821 println!("Already at the latest version (v{}).", current_version);
822 return Ok(true);
823 }
824
825 println!(
826 "New version available: v{} -> v{}",
827 current_version, latest_version
828 );
829
830 if !skip_confirm && !confirm_stdout("Proceed with update?")? {
831 println!("Aborted.");
832 return Ok(false);
833 }
834 println!();
835
836 println!("Downloading and installing v{}...", latest_version);
837
838 let status = self_update::backends::github::Update::configure()
840 .repo_owner("paradigmxyz")
841 .repo_name("mi6")
842 .bin_name("mi6")
843 .current_version(current_version)
844 .show_download_progress(true)
845 .build()
846 .context("failed to configure self-updater")?
847 .update()
848 .context("failed to perform update")?;
849
850 println!();
851 println!("Updated to v{}!", status.version());
852
853 Ok(true)
854}