use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Result;
use super::upgrade::Version;
#[cfg(windows)]
pub const BINARY_NAME: &str = "nab.exe";
#[cfg(not(windows))]
pub const BINARY_NAME: &str = "nab";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathBinary {
pub path: PathBuf,
pub version: Option<Version>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShadowReport {
pub binaries: Vec<PathBinary>,
pub shadowed: bool,
pub winner_is_stale: bool,
}
impl ShadowReport {
#[must_use]
pub fn is_problem(&self) -> bool {
self.shadowed && self.winner_is_stale
}
}
#[must_use]
pub fn analyze_shadow(
entries: &[PathBinary],
current_path: &Path,
current_version: Version,
) -> ShadowReport {
let winner = entries.first();
let shadowed = winner.is_some_and(|w| w.path != current_path);
let winner_is_stale = shadowed
&& winner
.and_then(|w| w.version)
.is_some_and(|wv| wv < current_version);
ShadowReport {
binaries: entries.to_vec(),
shadowed,
winner_is_stale,
}
}
fn enumerate_path_binaries(
path_var: &std::ffi::OsStr,
probe: impl Fn(&Path) -> Option<Version>,
) -> Vec<PathBinary> {
let mut seen: Vec<PathBuf> = Vec::new();
let mut out: Vec<PathBinary> = Vec::new();
for dir in std::env::split_paths(path_var) {
let candidate = dir.join(BINARY_NAME);
let Ok(canonical) = candidate.canonicalize() else {
continue;
};
if seen.contains(&canonical) {
continue;
}
seen.push(canonical.clone());
let version = probe(&canonical);
out.push(PathBinary {
path: canonical,
version,
});
}
out
}
fn probe_version(path: &Path) -> Option<Version> {
let output = Command::new(path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let token = stdout.split_whitespace().next_back()?;
Version::parse(token).ok()
}
#[must_use]
pub fn uninstall_hint(path: &Path) -> Option<&'static str> {
let s = path.to_string_lossy();
if s.contains("/.cargo/bin/") || s.contains("\\.cargo\\bin\\") {
Some("cargo uninstall nab")
} else if s.contains("/Cellar/") || s.contains("/opt/homebrew/") || s.contains("linuxbrew") {
Some("brew uninstall nab")
} else {
None
}
}
pub fn cmd_doctor() -> Result<()> {
let current_version = Version::parse(env!("CARGO_PKG_VERSION"))?;
let current_path = std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok());
println!("nab doctor");
println!(" running binary: nab {current_version}");
if let Some(p) = ¤t_path {
println!(" running path: {}", p.display());
}
let path_var = std::env::var_os("PATH").unwrap_or_default();
let entries = enumerate_path_binaries(&path_var, probe_version);
report(&entries, current_path.as_deref(), current_version);
Ok(())
}
fn report(entries: &[PathBinary], current_path: Option<&Path>, current_version: Version) {
println!("\nnab binaries on PATH ({} found):", entries.len());
for (i, bin) in entries.iter().enumerate() {
let marker = if i == 0 { "→" } else { " " };
let ver = bin
.version
.map_or_else(|| "version unknown".to_string(), |v| format!("nab {v}"));
println!(" {marker} {} ({ver})", bin.path.display());
}
let Some(current_path) = current_path else {
println!("\nCould not resolve the running binary's path; skipping shadow check.");
return;
};
let report = analyze_shadow(entries, current_path, current_version);
if report.is_problem() {
let winner = &report.binaries[0];
let winner_ver = winner
.version
.map_or_else(|| "unknown".to_string(), |v| v.to_string());
eprintln!(
"\nwarning: an older nab ({winner_ver}) at {} shadows this binary ({current_version}).",
winner.path.display()
);
eprintln!(
" The shadowing install wins PATH precedence, so `nab --version` may report a stale version."
);
eprintln!(" To resolve, keep a single install channel:");
if let Some(cmd) = uninstall_hint(&winner.path) {
eprintln!(" - Remove the install that wins PATH: {cmd}");
} else {
eprintln!(
" - Remove or update the install that wins PATH ({}).",
winner.path.display()
);
}
eprintln!(" - Or reorder PATH so your preferred install's directory comes first.");
} else if report.shadowed {
println!(
"\nnote: another nab install wins PATH precedence but is not older; no action needed."
);
} else {
println!("\nNo shadowing detected — the running binary wins PATH precedence.");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v(major: u32, minor: u32, patch: u32) -> Version {
Version {
major,
minor,
patch,
}
}
fn bin(path: &str, ver: Option<Version>) -> PathBinary {
PathBinary {
path: PathBuf::from(path),
version: ver,
}
}
#[test]
fn analyze_older_winner_shadows_current_is_problem() {
let current = PathBuf::from("/home/u/.cargo/bin/nab");
let entries = [
bin("/opt/homebrew/Cellar/nab/0.8.5/bin/nab", Some(v(0, 8, 5))),
bin("/home/u/.cargo/bin/nab", Some(v(0, 11, 0))),
];
let report = analyze_shadow(&entries, ¤t, v(0, 11, 0));
assert!(report.shadowed);
assert!(report.winner_is_stale);
assert!(report.is_problem());
}
#[test]
fn analyze_current_wins_no_problem() {
let current = PathBuf::from("/home/u/.cargo/bin/nab");
let entries = [bin("/home/u/.cargo/bin/nab", Some(v(0, 11, 0)))];
let report = analyze_shadow(&entries, ¤t, v(0, 11, 0));
assert!(!report.shadowed);
assert!(!report.winner_is_stale);
assert!(!report.is_problem());
}
#[test]
fn analyze_newer_winner_shadows_but_not_problem() {
let current = PathBuf::from("/home/u/.cargo/bin/nab");
let entries = [
bin("/opt/homebrew/bin/nab", Some(v(0, 12, 0))),
bin("/home/u/.cargo/bin/nab", Some(v(0, 11, 0))),
];
let report = analyze_shadow(&entries, ¤t, v(0, 11, 0));
assert!(report.shadowed);
assert!(!report.winner_is_stale);
assert!(!report.is_problem());
}
#[test]
fn analyze_unknown_winner_version_is_not_stale() {
let current = PathBuf::from("/home/u/.cargo/bin/nab");
let entries = [
bin("/opt/homebrew/bin/nab", None),
bin("/home/u/.cargo/bin/nab", Some(v(0, 11, 0))),
];
let report = analyze_shadow(&entries, ¤t, v(0, 11, 0));
assert!(report.shadowed);
assert!(!report.winner_is_stale);
assert!(!report.is_problem());
}
#[test]
fn analyze_empty_entries_no_shadow() {
let current = PathBuf::from("/home/u/.cargo/bin/nab");
let entries: [PathBinary; 0] = [];
let report = analyze_shadow(&entries, ¤t, v(0, 11, 0));
assert!(!report.shadowed);
assert!(!report.is_problem());
assert!(report.binaries.is_empty());
}
#[test]
fn enumerate_dedupes_and_orders() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir_a = tmp.path().join("a");
let dir_b = tmp.path().join("b");
std::fs::create_dir_all(&dir_a).unwrap();
std::fs::create_dir_all(&dir_b).unwrap();
let bin_a = dir_a.join(BINARY_NAME);
let bin_b = dir_b.join(BINARY_NAME);
std::fs::write(&bin_a, b"#!/bin/sh\n").unwrap();
std::fs::write(&bin_b, b"#!/bin/sh\n").unwrap();
let path_var = std::env::join_paths([&dir_a, &dir_b, &dir_a]).unwrap();
let found = enumerate_path_binaries(&path_var, |_| Some(v(1, 0, 0)));
assert_eq!(found.len(), 2);
assert_eq!(found[0].path, bin_a.canonicalize().unwrap());
assert_eq!(found[1].path, bin_b.canonicalize().unwrap());
assert_eq!(found[0].version, Some(v(1, 0, 0)));
}
#[test]
fn enumerate_skips_missing_binaries() {
let tmp = tempfile::tempdir().expect("tempdir");
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
let path_var = std::env::join_paths([&empty]).unwrap();
let found = enumerate_path_binaries(&path_var, |_| Some(v(1, 0, 0)));
assert!(found.is_empty());
}
#[test]
fn uninstall_hint_recognises_cargo() {
let p = PathBuf::from("/Users/u/.cargo/bin/nab");
assert_eq!(uninstall_hint(&p), Some("cargo uninstall nab"));
}
#[test]
fn uninstall_hint_recognises_homebrew() {
let p = PathBuf::from("/opt/homebrew/Cellar/nab/0.8.5/bin/nab");
assert_eq!(uninstall_hint(&p), Some("brew uninstall nab"));
}
#[test]
fn uninstall_hint_unrecognised_location_is_none() {
let p = PathBuf::from("/usr/local/bin/nab");
assert_eq!(uninstall_hint(&p), None);
}
}