diff --git a/AGENTS.md b/AGENTS.md index b966df38..4d7b8796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ CI runs `cargo test`, `cargo clippy -- -D warnings`, `cargo fmt --check`, and a ## Project Structure ``` +build.rs — cargo-install guard (cross-channel install detection) +src/util/install_channel.rs — install-channel detection + interactive uninstall prompt src/ main.rs — CLI entry point (clap-based) config.rs — Config load/save, root resolution diff --git a/Cargo.toml b/Cargo.toml index 33a384c3..4371b861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ exclude = [ name = "numan" path = "src/main.rs" +[[bin]] +name = "numan-install-guard" +path = "src/bin/install_guard.rs" + [dependencies] # CLI clap = { version = "4", features = ["derive"] } diff --git a/README.md b/README.md index 60665d18..33767e7c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ brew tap tonythethompson/numan brew install numan ``` -Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) tap. Formula digests update automatically after each GitHub Release (see [docs/PACKAGING.md](docs/PACKAGING.md)). +Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) tap. Formula digests update automatically after each GitHub Release (see [docs/PACKAGING.md](docs/PACKAGING.md)). If you already have numan from cargo or winget, use `scripts/install-homebrew.sh` so the installer prompts to remove the other copy first. ### winget (Windows) @@ -135,6 +135,8 @@ Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-n winget install tonythethompson.numan ``` +If you already have numan from **cargo** or **Homebrew**, use `scripts/install-winget.ps1` (winget) or `cargo install` (automatic guard) so the installer prompts to remove the other copy first. + See [packaging/winget/README.md](packaging/winget/README.md) and [docs/PACKAGING.md](docs/PACKAGING.md). ### crates.io @@ -143,7 +145,7 @@ See [packaging/winget/README.md](packaging/winget/README.md) and [docs/PACKAGING cargo install numan-cli ``` -Requires [Rust](https://rustup.rs/) (stable). The installed binary is named `numan`. +Requires [Rust](https://rustup.rs/) (stable). The installed binary is named `numan`. During `cargo install`, numan checks for winget/Homebrew/release copies and prompts to uninstall them first; decline cancels the install. **Requirements:** a [Nushell](https://www.nushell.sh/) binary on `PATH` for `numan init`, `numan activate`, and related commands. diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..16d36f84 --- /dev/null +++ b/build.rs @@ -0,0 +1,25 @@ +mod install_channel { + #![allow(dead_code)] + include!("src/util/install_channel.rs"); +} + +fn is_cargo_install_build() -> bool { + if std::env::var("CARGO_INSTALL_ROOT").is_ok() { + return true; + } + if let Ok(out_dir) = std::env::var("OUT_DIR") { + let normalized = out_dir.replace('\\', "/").to_ascii_lowercase(); + if normalized.contains("cargo-install") { + return true; + } + } + false +} + +fn main() { + if is_cargo_install_build() + && install_channel::run_cargo_install_guard() != std::process::ExitCode::SUCCESS + { + std::process::exit(1); + } +} diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index a4dc5be1..0ddeb5ad 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -37,10 +37,22 @@ python3 scripts/render_homebrew_formula.py --version X.Y.Z --sha256sums SHA256SU | GitHub Release | Download archive from [Releases](https://github.com/tonythethompson/numan/releases) | | crates.io | `cargo install numan-cli` | | From git | `cargo install --git https://github.com/tonythethompson/numan` | -| Homebrew (tap) | `brew tap tonythethompson/numan && brew install numan` | -| winget (community) | `winget install tonythethompson.numan` | +| Homebrew tap | `brew tap tonythethompson/numan && brew install numan` (use `scripts/install-homebrew.sh` when switching from cargo/winget) | +| winget (community) | `winget install tonythethompson.numan` (use `scripts/install-winget.ps1` when switching from cargo/Homebrew) | + +## Install channel guard + +Cross-channel installs (cargo vs winget vs Homebrew) prompt to uninstall the existing copy first; declining cancels the install. + +| Channel | Guard behavior | +|---------|----------------| +| `cargo install numan-cli` | Automatic via `build.rs` during install | +| winget | `powershell -File scripts/install-winget.ps1` | +| Homebrew tap | `bash scripts/install-homebrew.sh` | +| CI / automation | Set `NUMAN_SKIP_INSTALL_GUARD=1` to bypass | + +`numan doctor` warns when multiple channels are detected (`install.multiple_channels`). -## Archive layout Release archives extract to `numan--/` containing the `numan` (or `numan.exe`) binary. Homebrew and winget installers assume this layout. diff --git a/docs/numan-doctor.md b/docs/numan-doctor.md index 1d53ddcc..4ce510bb 100644 --- a/docs/numan-doctor.md +++ b/docs/numan-doctor.md @@ -129,6 +129,7 @@ Checks run in order below. Implementation should call existing validators (`NuPa |----|----------|-----------| | `nu.binary.missing_on_path` | `error` | Nu not on PATH and not under `$NUMAN_ROOT/tools/nushell/` → fix: `numan setup nu` | | `nu.binary.found_off_path` | `warn` | Nu exists in a known install root (e.g. `~/.cargo/bin`, `%LOCALAPPDATA%\Programs\nushell`) but not on PATH → fix: `numan setup nu use ` | +| `install.multiple_channels` | `warn` | More than one `numan` binary from different install channels (cargo, winget, Homebrew, release archive) → fix: uninstall the other channel(s) before reinstalling | | `nu.path.version` | `info` | PATH-only Nu version (`PATH Nu: 0.114.1`), `PATH Nu: not found`, or `PATH Nu: found at '' but version probe failed ()` when the binary exists but `--version` fails. Does not treat managed Nu as PATH. Report-only (no automatic repair). | | `nu.managed.version` | `info` | Managed binary under `$NUMAN_ROOT/tools/nushell/` with version, `Managed Nu: not installed`, or `Managed Nu: present at '' but version probe failed ()` when the binary exists but `--version` fails. Report-only (no automatic repair). | | `nu.active_version.invalid` | `error` | `nu_state/active-version.json` is present but unreadable/invalid JSON. Lookup would otherwise soft-miss the marker and fall back to PATH. **auto:** copy raw bytes to `active-version.json.corrupt` (best-effort, recoverable `binary_path`), then clear via `clear_active_version` so resolution recovers cleanly. | diff --git a/packaging/winget/README.md b/packaging/winget/README.md index 04f1e7c4..d50a0422 100644 --- a/packaging/winget/README.md +++ b/packaging/winget/README.md @@ -20,6 +20,14 @@ After manifests are accepted in [microsoft/winget-pkgs](https://github.com/micro winget install tonythethompson.numan ``` +If you already installed numan via **cargo** or **Homebrew**, use the install guard wrapper so winget prompts to remove the other copy first (plain `winget install` does not): + +```powershell +powershell -File scripts/install-winget.ps1 +``` + +`cargo install numan-cli` runs the same guard automatically during install. + ## Automated updates The [`Publish to WinGet`](../../.github/workflows/winget.yml) workflow submits one update PR after each published GitHub Release. It uses the Windows `.zip` release asset and the existing `tonythethompson/winget-pkgs` fork. diff --git a/scripts/install-cargo.ps1 b/scripts/install-cargo.ps1 new file mode 100644 index 00000000..b36910a4 --- /dev/null +++ b/scripts/install-cargo.ps1 @@ -0,0 +1,25 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install numan via cargo after checking for conflicting installs from other channels. +#> +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$CargoArgs = @("install", "--path", ".") +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$manifest = Join-Path $repoRoot "Cargo.toml" + +Write-Host "Checking for conflicting numan installs..." +& cargo run --quiet --bin numan-install-guard --manifest-path $manifest -- cargo +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Running: cargo $($CargoArgs -join ' ')" +& cargo @CargoArgs --manifest-path $manifest +exit $LASTEXITCODE diff --git a/scripts/install-homebrew.sh b/scripts/install-homebrew.sh new file mode 100644 index 00000000..3803eeee --- /dev/null +++ b/scripts/install-homebrew.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Install numan via Homebrew after checking for conflicting installs from other channels. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "Checking for conflicting numan installs..." +cargo run --quiet --bin numan-install-guard --manifest-path "${repo_root}/Cargo.toml" -- brew +echo "Running: brew install tonythethompson/numan/numan ${*}" +brew install tonythethompson/numan/numan "$@" diff --git a/scripts/install-winget.ps1 b/scripts/install-winget.ps1 new file mode 100644 index 00000000..38719394 --- /dev/null +++ b/scripts/install-winget.ps1 @@ -0,0 +1,24 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install numan via winget after checking for conflicting installs from other channels. +#> +[CmdletBinding()] +param( + [string[]]$WingetArgs = @("install", "tonythethompson.numan") +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$manifest = Join-Path $repoRoot "Cargo.toml" + +Write-Host "Checking for conflicting numan installs..." +& cargo run --quiet --bin numan-install-guard --manifest-path $manifest -- winget +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Running: winget $($WingetArgs -join ' ')" +& winget @WingetArgs +exit $LASTEXITCODE diff --git a/src/bin/install_guard.rs b/src/bin/install_guard.rs new file mode 100644 index 00000000..e60366cf --- /dev/null +++ b/src/bin/install_guard.rs @@ -0,0 +1,18 @@ +use std::env; +use std::process::ExitCode; + +use numan_cli::util::install_channel::{ + run_cargo_install_guard, run_homebrew_install_guard, run_winget_install_guard, +}; + +fn main() -> ExitCode { + match env::args().nth(1).map(|s| s.to_ascii_lowercase()) { + Some(arg) if arg == "cargo" => run_cargo_install_guard(), + Some(arg) if arg == "winget" => run_winget_install_guard(), + Some(arg) if arg == "brew" || arg == "homebrew" => run_homebrew_install_guard(), + _ => { + eprintln!("usage: numan-install-guard "); + ExitCode::from(2) + } + } +} diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 9ae34b7c..aff1641d 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -38,6 +38,9 @@ use crate::util::hints::{ ACTIVE_PLUGIN_MUTATION_GATED_FIX, CMD_ACTIVATE, CMD_DEACTIVATE, CMD_DOCTOR_FIX, CMD_INIT, CMD_INIT_REFRESH, CMD_REGISTRY_SYNC, CMD_SETUP_NU, CMD_USE, }; +use crate::util::install_channel::{ + classify_binary_path, discover_path_installations, InstallChannel, +}; use crate::util::stdio_redirect::StdoutToStderr; const SCHEMA_VERSION: u32 = 1; @@ -195,6 +198,7 @@ pub fn run_checks_with_options( let mut findings = Vec::new(); check_root_layout(root, &mut findings); + check_install_channels(&mut findings); check_active_version_marker(root, &mut findings); let nu_paths = check_nu_paths(root, options, &mut findings); check_nu_environments(root, options, &mut findings); @@ -322,6 +326,65 @@ fn nu_is_available(root: &Path) -> bool { false } +fn check_install_channels(findings: &mut Vec) { + // Doctor uses the cheap PATH-only scan; package-manager trees are checked by install guards. + let installs = discover_path_installations(); + if installs.len() <= 1 { + return; + } + + let channels: std::collections::HashSet = + installs.iter().map(|install| install.channel).collect(); + if channels.len() <= 1 { + return; + } + + let current = std::env::current_exe().ok(); + let current_channel = current + .as_ref() + .map(|path| classify_binary_path(path)) + .unwrap_or(InstallChannel::Unknown); + + let mut lines = + vec!["Multiple numan installs from different package managers were detected:".to_string()]; + for install in &installs { + lines.push(format!( + " {} ({})", + install.path.display(), + install.channel.label() + )); + } + lines.push(format!( + "This session is running the {} build.", + current_channel.label() + )); + lines.push( + "Keep one install channel to avoid PATH ambiguity. Uninstall the others before reinstalling." + .to_string(), + ); + + let fix = installs + .iter() + .filter(|install| install.channel != current_channel) + .filter_map(|install| install.channel.uninstall_hint()) + .collect::>() + .into_iter() + .collect::>() + .join("; "); + + findings.push(finding( + "install.multiple_channels", + Severity::Warn, + lines.join("\n"), + if fix.is_empty() { + None + } else { + Some(fix.as_str()) + }, + RepairTier::Manual, + )); +} + /// Detect a present-but-unreadable `nu_state/active-version.json`. /// /// `find_nu_executable_with_root` treats marker read errors as soft misses and diff --git a/src/util/install_channel.rs b/src/util/install_channel.rs new file mode 100644 index 00000000..c62bf6dd --- /dev/null +++ b/src/util/install_channel.rs @@ -0,0 +1,452 @@ +// Detect Numan binaries installed via different package managers and gate +// cross-channel installs behind an interactive uninstall prompt. + +use std::collections::HashSet; +use std::ffi::OsStr; +use std::io::{IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; + +/// How a `numan` binary on disk was likely installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InstallChannel { + Cargo, + Winget, + Homebrew, + ReleaseArchive, + Unknown, +} + +impl InstallChannel { + pub fn label(self) -> &'static str { + match self { + InstallChannel::Cargo => "cargo", + InstallChannel::Winget => "winget", + InstallChannel::Homebrew => "homebrew", + InstallChannel::ReleaseArchive => "release archive", + InstallChannel::Unknown => "unknown", + } + } + + pub fn uninstall_hint(self) -> Option<&'static str> { + match self { + InstallChannel::Cargo => Some("cargo uninstall numan-cli"), + InstallChannel::Winget => Some("winget uninstall tonythethompson.numan"), + InstallChannel::Homebrew => Some("brew uninstall numan"), + InstallChannel::ReleaseArchive | InstallChannel::Unknown => None, + } + } +} + +/// One discovered `numan` binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredInstall { + pub path: PathBuf, + pub channel: InstallChannel, +} + +/// Classify a binary path by install channel heuristics. +pub fn classify_binary_path(path: &Path) -> InstallChannel { + let normalized = path + .to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase(); + + if normalized.contains("/.cargo/bin/numan") || normalized.ends_with("/.cargo/bin/numan.exe") { + return InstallChannel::Cargo; + } + + if normalized.contains("/microsoft/winget/") + || normalized.contains("/winget/packages/") + || (normalized.contains("winget") && normalized.contains("numan")) + { + return InstallChannel::Winget; + } + + if normalized.contains("/cellar/numan/") + || normalized.contains("/homebrew/numan/") + || normalized.contains("/linuxbrew/numan/") + { + return InstallChannel::Homebrew; + } + + if normalized.contains("/numan-") + && normalized + .split('/') + .any(|part| part.starts_with("numan-") && part.len() > "numan-".len()) + { + return InstallChannel::ReleaseArchive; + } + + InstallChannel::Unknown +} + +/// Discover `numan` binaries on PATH and in known package-manager locations. +pub fn discover_path_installations() -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for dir in path_directories() { + push_if_numan(&dir, &mut seen, &mut out); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + +pub fn discover_installations() -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for dir in path_directories() { + push_if_numan(&dir, &mut seen, &mut out); + } + + for candidate in known_install_candidates() { + if candidate.is_file() { + push_install(candidate, &mut seen, &mut out); + } + } + + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + +fn push_if_numan(dir: &Path, seen: &mut HashSet, out: &mut Vec) { + let candidates = [dir.join("numan"), dir.join("numan.exe")]; + for candidate in candidates { + if candidate.is_file() { + push_install(candidate, seen, out); + } + } +} + +fn push_install(path: PathBuf, seen: &mut HashSet, out: &mut Vec) { + let canonical = path.canonicalize().unwrap_or(path); + if seen.insert(canonical.clone()) { + let channel = classify_binary_path(&canonical); + out.push(DiscoveredInstall { + path: canonical, + channel, + }); + } +} + +fn path_directories() -> Vec { + let key = if cfg!(windows) { "Path" } else { "PATH" }; + std::env::var_os(key) + .map(|value| std::env::split_paths(&value).collect()) + .unwrap_or_default() +} + +fn known_install_candidates() -> Vec { + let mut candidates = Vec::new(); + + if let Some(home) = home_dir() { + candidates.push(home.join(".cargo").join("bin").join(if cfg!(windows) { + "numan.exe" + } else { + "numan" + })); + } + + if cfg!(windows) { + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + let winget_packages = PathBuf::from(local) + .join("Microsoft") + .join("WinGet") + .join("Packages"); + candidates.extend(find_numan_under(&winget_packages, 4)); + } + } + + if cfg!(target_os = "macos") { + candidates.push(PathBuf::from("/opt/homebrew/Cellar/numan")); + candidates.push(PathBuf::from("/usr/local/Cellar/numan")); + candidates.extend(find_numan_under( + &PathBuf::from("/opt/homebrew/Cellar/numan"), + 3, + )); + candidates.extend(find_numan_under( + &PathBuf::from("/usr/local/Cellar/numan"), + 3, + )); + } + + if cfg!(target_os = "linux") { + if let Some(home) = home_dir() { + let linuxbrew = home.join(".linuxbrew/Cellar/numan"); + candidates.extend(find_numan_under(&linuxbrew, 3)); + } + candidates.extend(find_numan_under( + &PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/numan"), + 3, + )); + } + + candidates +} + +fn find_numan_under(root: &Path, max_depth: usize) -> Vec { + if !root.exists() { + return Vec::new(); + } + let mut found = Vec::new(); + walk_for_numan(root, 0, max_depth, &mut found); + found +} + +fn walk_for_numan(dir: &Path, depth: usize, max_depth: usize, found: &mut Vec) { + if depth > max_depth { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_for_numan(&path, depth + 1, max_depth, found); + } else if is_numan_binary(&path) { + found.push(path); + } + } +} + +fn is_numan_binary(path: &Path) -> bool { + matches!( + path.file_name().and_then(OsStr::to_str), + Some("numan") | Some("numan.exe") + ) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +/// Conflicting installs for a new install via `channel`, excluding `exclude_paths`. +pub fn conflicting_installs( + channel: InstallChannel, + exclude_paths: &[PathBuf], +) -> Vec { + let excluded: HashSet = exclude_paths + .iter() + .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())) + .collect(); + + discover_installations() + .into_iter() + .filter(|install| !excluded.contains(&install.path)) + .filter(|install| install.channel != channel) + .collect() +} + +/// Run the cargo-install guard. Returns process exit code. +pub fn run_cargo_install_guard() -> ExitCode { + let install_root = std::env::var("CARGO_INSTALL_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|_| default_cargo_install_root()); + let target = install_root + .join("bin") + .join(if cfg!(windows) { "numan.exe" } else { "numan" }); + + run_install_guard(InstallChannel::Cargo, &[target]) +} + +/// Run the winget-install guard. Returns process exit code. +pub fn run_winget_install_guard() -> ExitCode { + run_install_guard(InstallChannel::Winget, &[]) +} + +/// Run the homebrew-install guard. Returns process exit code. +pub fn run_homebrew_install_guard() -> ExitCode { + run_install_guard(InstallChannel::Homebrew, &[]) +} + +fn default_cargo_install_root() -> PathBuf { + if let Some(home) = home_dir() { + return home.join(".cargo"); + } + PathBuf::from(".cargo") +} + +fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> ExitCode { + if should_skip_guard() { + return ExitCode::SUCCESS; + } + + let conflicts = conflicting_installs(channel, exclude_paths); + if conflicts.is_empty() { + return ExitCode::SUCCESS; + } + + let is_tty = std::io::stdin().is_terminal(); + print_conflict_banner(channel, &conflicts); + + if !is_tty { + eprintln!( + "Refusing {} install in non-interactive session while another channel is installed.", + channel.label() + ); + eprintln!("Remove the existing install first, then retry the installation."); + return ExitCode::from(1); + } + + let channel_labels: Vec<&str> = conflicts + .iter() + .map(|c| c.channel.label()) + .collect::>() + .into_iter() + .collect(); + print!( + "Uninstall the existing {} install(s) before continuing? [y/N] ", + channel_labels.join(", ") + ); + let _ = std::io::stdout().flush(); + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + eprintln!("Failed to read confirmation; cancelling install."); + return ExitCode::from(1); + } + + if !input.trim().eq_ignore_ascii_case("y") { + eprintln!("Install cancelled."); + return ExitCode::from(1); + } + + for conflict in &conflicts { + if !uninstall_channel(conflict.channel) { + eprintln!( + "Could not uninstall {} install at {}.", + conflict.channel.label(), + conflict.path.display() + ); + if let Some(hint) = conflict.channel.uninstall_hint() { + eprintln!("Run manually: {hint}"); + } + return ExitCode::from(1); + } + } + + let remaining = conflicting_installs(channel, exclude_paths); + if !remaining.is_empty() { + eprintln!("Existing install still detected after uninstall; cancelling install."); + for install in remaining { + eprintln!(" {} ({})", install.path.display(), install.channel.label()); + } + return ExitCode::from(1); + } + + ExitCode::SUCCESS +} + +fn should_skip_guard() -> bool { + std::env::var("NUMAN_SKIP_INSTALL_GUARD") + .map(|v| v == "1") + .unwrap_or(false) + || std::env::var("CI").ok().as_deref() == Some("true") + || std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") +} + +fn print_conflict_banner(channel: InstallChannel, conflicts: &[DiscoveredInstall]) { + eprintln!(); + eprintln!( + "Another numan install was detected while installing via {}.", + channel.label() + ); + for conflict in conflicts { + eprintln!( + " {} via {}", + conflict.path.display(), + conflict.channel.label() + ); + if let Some(hint) = conflict.channel.uninstall_hint() { + eprintln!(" uninstall: {hint}"); + } + } + eprintln!(); +} + +fn uninstall_channel(channel: InstallChannel) -> bool { + match channel { + InstallChannel::Cargo => run_command("cargo", &["uninstall", "numan-cli"]), + InstallChannel::Winget => run_command( + "winget", + &[ + "uninstall", + "--id", + "tonythethompson.numan", + "--exact", + "--accept-source-agreements", + ], + ), + InstallChannel::Homebrew => run_command("brew", &["uninstall", "numan"]), + InstallChannel::ReleaseArchive | InstallChannel::Unknown => false, + } +} + +fn run_command(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_cargo_path() { + let path = Path::new("/home/user/.cargo/bin/numan"); + assert_eq!(classify_binary_path(path), InstallChannel::Cargo); + } + + #[test] + fn classify_winget_path() { + let path = Path::new("C:/Users/x/AppData/Local/Microsoft/WinGet/Packages/foo/numan.exe"); + assert_eq!(classify_binary_path(path), InstallChannel::Winget); + } + + #[test] + fn classify_homebrew_path() { + let path = Path::new("/opt/homebrew/Cellar/numan/0.2.0/bin/numan"); + assert_eq!(classify_binary_path(path), InstallChannel::Homebrew); + } + + #[test] + fn classify_release_archive_path() { + let path = Path::new("/tmp/numan-0.2.0-x86_64-pc-windows-msvc/numan.exe"); + assert_eq!(classify_binary_path(path), InstallChannel::ReleaseArchive); + } + + #[test] + fn conflicting_installs_excludes_paths() { + let excluded = PathBuf::from("/tmp/excluded/numan"); + let other = PathBuf::from("/tmp/other/numan"); + let channel = InstallChannel::Winget; + let filtered = [ + DiscoveredInstall { + path: excluded.clone(), + channel: InstallChannel::Cargo, + }, + DiscoveredInstall { + path: other.clone(), + channel: InstallChannel::Cargo, + }, + ] + .into_iter() + .filter(|install| !std::slice::from_ref(&excluded).contains(&install.path)) + .filter(|install| install.channel != channel) + .collect::>(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].path, other); + assert_eq!(filtered[0].channel, InstallChannel::Cargo); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index b7b45f00..1e40957f 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -2,6 +2,7 @@ pub mod atomic; pub mod confirm; pub mod fs_safety; pub mod hints; +pub mod install_channel; pub mod stdio_redirect; /// PATH snapshot/restore for unit and integration tests that mutate process env. pub mod test_paths;