use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::Component;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InstallPathCandidate {
pub(crate) path: PathBuf,
pub(crate) resolved_path: PathBuf,
pub(crate) version: Option<String>,
pub(crate) schema_version: Option<i64>,
pub(crate) first_on_path: bool,
pub(crate) configured: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InstallPathReport {
pub(crate) configured_paths: Vec<PathBuf>,
pub(crate) configured_resolved_paths: Vec<PathBuf>,
pub(crate) candidates: Vec<InstallPathCandidate>,
}
impl InstallPathReport {
pub(crate) fn has_warning(&self) -> bool {
self.has_duplicates()
|| self.configured_paths_disagree()
|| self.first_path_differs_from_configured()
|| self.version_or_schema_disagreement()
}
fn has_duplicates(&self) -> bool {
self.candidates.len() > 1
}
fn first_path_differs_from_configured(&self) -> bool {
if self.configured_resolved_paths.is_empty() {
return false;
}
self.candidates.first().is_some_and(|candidate| {
!self
.configured_resolved_paths
.contains(&candidate.resolved_path)
})
}
fn configured_paths_disagree(&self) -> bool {
unique_paths(&self.configured_resolved_paths).len() > 1
}
fn version_or_schema_disagreement(&self) -> bool {
unique_options(
self.candidates
.iter()
.map(|candidate| candidate.version.as_ref()),
)
.len()
> 1
|| unique_options(
self.candidates
.iter()
.map(|candidate| candidate.schema_version.as_ref()),
)
.len()
> 1
}
}
pub(crate) fn inspect_install_paths(configured_path: Option<&Path>) -> InstallPathReport {
let configured_paths = configured_path
.map(|path| vec![path.to_path_buf()])
.unwrap_or_default();
inspect_install_paths_with_configured_paths(&configured_paths)
}
pub(crate) fn inspect_install_paths_with_configured_paths(
configured_paths: &[PathBuf],
) -> InstallPathReport {
let path_env = std::env::var_os("PATH").unwrap_or_default();
let dirs: Vec<PathBuf> = std::env::split_paths(&path_env).collect();
collect_install_paths(
dirs,
configured_paths,
default_candidate_names(),
probe_version,
)
}
pub(crate) fn format_doctor_detail(report: &InstallPathReport) -> String {
let configured = format_configured_paths(&report.configured_paths);
if report.candidates.is_empty() {
return format!("no remem executable found on PATH; configured {configured}");
}
let candidate_list = report
.candidates
.iter()
.map(format_candidate)
.collect::<Vec<_>>()
.join("; ");
if report.has_warning() {
return format!(
"{} remem executable(s) found; configured {}; candidates: {}; fix: remove or upgrade stale installs, reconcile hook/MCP commands to one binary, or put the intended path first in PATH",
report.candidates.len(),
configured,
candidate_list
);
}
format!(
"one remem executable found; configured {}; candidate: {}",
configured, candidate_list
)
}
pub(crate) fn format_warning_lines(report: &InstallPathReport) -> Vec<String> {
if !report.has_warning() {
return Vec::new();
}
let mut lines = vec!["Install paths: multiple or mismatched remem commands found".to_string()];
for configured in &report.configured_paths {
lines.push(format!(" config -> {}", configured.display()));
}
for candidate in &report.candidates {
let label = if candidate.first_on_path {
"active"
} else {
"stale "
};
lines.push(format!(" {label} -> {}", format_candidate(candidate)));
}
lines.push(
" fix -> remove or upgrade stale package-manager/manual installs, reconcile hook/MCP commands to one binary, or put the intended path first in PATH"
.to_string(),
);
lines
}
fn format_candidate(candidate: &InstallPathCandidate) -> String {
let version = candidate
.version
.as_deref()
.unwrap_or("version unavailable");
let configured = if candidate.configured {
", configured"
} else {
""
};
format!("{} ({version}{configured})", candidate.path.display())
}
fn collect_install_paths<F>(
dirs: Vec<PathBuf>,
configured_paths: &[PathBuf],
candidate_names: &[&str],
mut version_probe: F,
) -> InstallPathReport
where
F: FnMut(&Path) -> Option<String>,
{
let configured_paths = configured_paths.to_vec();
let configured_resolved_paths = configured_paths
.iter()
.map(|path| resolve_configured_path(path, &dirs, candidate_names))
.collect::<Vec<_>>();
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for dir in dirs {
for name in candidate_names {
let candidate_path = dir.join(name);
if !is_candidate_file(&candidate_path) {
continue;
}
let resolved_path = canonical_or_original(&candidate_path);
if !seen.insert(resolved_path.clone()) {
continue;
}
let configured = configured_resolved_paths.contains(&resolved_path);
let first_on_path = candidates.is_empty();
let version = version_probe(&candidate_path);
let schema_version = version.as_deref().and_then(parse_schema_version);
candidates.push(InstallPathCandidate {
path: candidate_path,
resolved_path,
version,
schema_version,
first_on_path,
configured,
});
}
}
for (configured_path, resolved_path) in configured_paths
.iter()
.zip(configured_resolved_paths.iter())
{
if seen.contains(resolved_path)
|| is_unresolved_bare_command(configured_path, resolved_path)
|| !is_candidate_file(resolved_path)
|| !path_matches_candidate_names(resolved_path, candidate_names)
{
continue;
}
seen.insert(resolved_path.clone());
let version = version_probe(resolved_path);
let schema_version = version.as_deref().and_then(parse_schema_version);
candidates.push(InstallPathCandidate {
path: configured_path.clone(),
resolved_path: resolved_path.clone(),
version,
schema_version,
first_on_path: false,
configured: true,
});
}
InstallPathReport {
configured_paths,
configured_resolved_paths,
candidates,
}
}
fn canonical_or_original(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn unique_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut unique = Vec::new();
for path in paths {
if !unique.contains(path) {
unique.push(path.clone());
}
}
unique
}
fn unique_options<'a, T, I>(values: I) -> Vec<&'a T>
where
T: Eq,
I: IntoIterator<Item = Option<&'a T>>,
{
let mut unique = Vec::new();
for value in values.into_iter().flatten() {
if !unique.contains(&value) {
unique.push(value);
}
}
unique
}
fn format_configured_paths(paths: &[PathBuf]) -> String {
if paths.is_empty() {
return "unknown".to_string();
}
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn resolve_configured_path(path: &Path, dirs: &[PathBuf], candidate_names: &[&str]) -> PathBuf {
if bare_command_name(path).is_some() {
return resolve_bare_command_path(path, dirs, candidate_names)
.unwrap_or_else(|| path.to_path_buf());
}
canonical_or_original(path)
}
fn resolve_bare_command_path(
path: &Path,
dirs: &[PathBuf],
candidate_names: &[&str],
) -> Option<PathBuf> {
let command = bare_command_name(path)?;
for dir in dirs {
let exact = dir.join(path);
if is_candidate_file(&exact) {
return Some(canonical_or_original(&exact));
}
for name in candidate_names {
let candidate = Path::new(name);
if candidate.file_stem() != Some(command) {
continue;
}
let candidate_path = dir.join(candidate);
if is_candidate_file(&candidate_path) {
return Some(canonical_or_original(&candidate_path));
}
}
}
None
}
fn bare_command_name(path: &Path) -> Option<&OsStr> {
let mut components = path.components();
let Some(Component::Normal(name)) = components.next() else {
return None;
};
if components.next().is_some() {
return None;
}
Some(name)
}
fn is_unresolved_bare_command(configured_path: &Path, resolved_path: &Path) -> bool {
bare_command_name(configured_path).is_some()
&& configured_path == resolved_path
&& !resolved_path.is_absolute()
}
fn default_candidate_names() -> &'static [&'static str] {
candidate_names_for_platform(cfg!(windows))
}
fn candidate_names_for_platform(windows: bool) -> &'static [&'static str] {
if windows {
&["remem.exe", "remem.cmd", "remem.bat"]
} else {
&["remem"]
}
}
fn is_candidate_file(path: &Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
is_executable(&metadata)
}
fn path_matches_candidate_names(path: &Path, candidate_names: &[&str]) -> bool {
let Some(file_name) = path.file_name() else {
return false;
};
candidate_names
.iter()
.any(|name| file_name == OsStr::new(name))
}
#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_: &std::fs::Metadata) -> bool {
true
}
fn probe_version(path: &Path) -> Option<String> {
let mut child = Command::new(path)
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_millis(500);
loop {
match child.try_wait() {
Ok(Some(_)) => {
let output = child.wait_with_output().ok()?;
return parse_version_output(output.stdout, output.stderr);
}
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
}
fn parse_version_output(stdout: Vec<u8>, stderr: Vec<u8>) -> Option<String> {
let text = if stdout.is_empty() { stderr } else { stdout };
let first_line = String::from_utf8_lossy(&text)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_string();
if first_line.is_empty() {
None
} else {
Some(first_line)
}
}
fn parse_schema_version(version: &str) -> Option<i64> {
let marker = "schema v";
let start = version.find(marker)? + marker.len();
let digits = version[start..]
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn temp_dir(label: &str) -> PathBuf {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"remem-install-paths-{label}-{}-{id}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_candidate(dir: &Path, name: &str, version: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, version).expect("write candidate");
#[cfg(unix)]
{
let mut permissions = std::fs::metadata(&path)
.expect("candidate metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).expect("mark executable");
}
path
}
#[test]
fn detects_multiple_unique_unix_candidates() {
let first = temp_dir("first");
let second = temp_dir("second");
let first_path = write_candidate(&first, "remem", "remem 0.4.5");
let second_path = write_candidate(&second, "remem", "remem 0.4.1");
let report = collect_install_paths(
vec![first, second],
std::slice::from_ref(&first_path),
&["remem"],
|path| Some(std::fs::read_to_string(path).expect("candidate version")),
);
assert!(report.has_warning());
assert_eq!(report.candidates.len(), 2);
assert!(report.candidates[0].first_on_path);
assert!(report.candidates[0].configured);
assert_eq!(report.candidates[1].path, second_path);
assert!(format_doctor_detail(&report).contains("2 remem executable(s) found"));
}
#[cfg(unix)]
#[test]
fn symlink_to_same_binary_is_not_duplicate() {
let real_dir = temp_dir("real");
let link_dir = temp_dir("link");
let real = write_candidate(&real_dir, "remem", "remem 0.4.5");
let link = link_dir.join("remem");
create_symlink(&real, &link).expect("create symlink");
let report = collect_install_paths(
vec![real_dir, link_dir],
std::slice::from_ref(&real),
&["remem"],
|path| {
Some(std::fs::read_to_string(path).unwrap_or_else(|_| path.display().to_string()))
},
);
assert!(!report.has_warning());
assert_eq!(report.candidates.len(), 1);
}
#[test]
fn warns_when_first_path_differs_from_configured_binary() {
let stale_dir = temp_dir("stale");
let configured_dir = temp_dir("configured");
write_candidate(&stale_dir, "remem", "remem 0.4.1");
let configured = write_candidate(&configured_dir, "remem", "remem 0.4.5");
let report = collect_install_paths(
vec![stale_dir, configured_dir],
std::slice::from_ref(&configured),
&["remem"],
|path| Some(std::fs::read_to_string(path).expect("candidate version")),
);
assert!(report.has_warning());
assert!(report.first_path_differs_from_configured());
let lines = format_warning_lines(&report).join("\n");
assert!(lines.contains("active ->"));
assert!(lines.contains("stale ->"));
}
#[test]
fn configured_absolute_path_not_on_path_is_probed() {
let configured_dir = temp_dir("configured-absolute");
let configured = write_candidate(&configured_dir, "remem", "remem 0.5.95 (schema v47)");
let report = collect_install_paths(
Vec::new(),
std::slice::from_ref(&configured),
&["remem"],
|path| std::fs::read_to_string(path).ok(),
);
assert!(!report.has_warning());
assert_eq!(report.candidates.len(), 1);
assert!(report.candidates[0].configured);
assert_eq!(report.candidates[0].schema_version, Some(47));
}
#[test]
fn warns_when_configured_and_path_schema_versions_disagree() {
let stale_dir = temp_dir("schema-stale");
let configured_dir = temp_dir("schema-configured");
write_candidate(&stale_dir, "remem", "remem 0.5.73 (schema v43)");
let configured = write_candidate(&configured_dir, "remem", "remem 0.5.95 (schema v47)");
let report = collect_install_paths(
vec![stale_dir],
std::slice::from_ref(&configured),
&["remem"],
|path| std::fs::read_to_string(path).ok(),
);
assert!(report.has_warning());
assert!(report.version_or_schema_disagreement());
let detail = format_doctor_detail(&report);
assert!(detail.contains("schema v43"), "{detail}");
assert!(detail.contains("schema v47"), "{detail}");
}
#[test]
fn configured_wrapper_path_is_not_probed_as_remem_binary() {
let wrapper_dir = temp_dir("wrapper-command");
let wrapper = write_candidate(&wrapper_dir, "node", "v24.0.0");
let report = collect_install_paths(
vec![wrapper_dir],
std::slice::from_ref(&wrapper),
&["remem"],
|path| std::fs::read_to_string(path).ok(),
);
assert!(report.candidates.is_empty());
assert!(!report.has_warning());
}
#[test]
fn configured_command_name_resolves_through_path() {
let bin_dir = temp_dir("command");
let configured = PathBuf::from("remem");
let expected = write_candidate(&bin_dir, "remem", "remem 0.4.5");
let report = collect_install_paths(
vec![bin_dir],
std::slice::from_ref(&configured),
&["remem"],
|path| match std::fs::read_to_string(path) {
Ok(version) => Some(version),
Err(error) => panic!("candidate version {}: {error}", path.display()),
},
);
assert!(!report.has_warning());
assert_eq!(report.configured_paths, vec![configured]);
assert_eq!(
report.configured_resolved_paths,
vec![canonical_or_original(&expected)]
);
assert!(report.candidates[0].configured);
}
#[test]
fn unresolved_configured_command_name_is_not_probed_from_cwd() {
let configured = PathBuf::from("remem");
let resolved = resolve_configured_path(&configured, &[], &["remem"]);
assert_eq!(resolved, configured);
assert!(is_unresolved_bare_command(&configured, &resolved));
let report = collect_install_paths(
Vec::new(),
std::slice::from_ref(&configured),
&["remem"],
|_| panic!("unresolved bare command should not be probed as a configured binary"),
);
assert_eq!(report.configured_resolved_paths, vec![configured]);
assert!(report.candidates.is_empty());
assert!(!report.has_warning());
}
#[test]
fn configured_command_name_resolves_platform_wrapper() {
let bin_dir = temp_dir("wrapper");
let configured = PathBuf::from("remem");
let expected = write_candidate(&bin_dir, "remem.exe", "remem 0.4.5");
let report = collect_install_paths(
vec![bin_dir],
std::slice::from_ref(&configured),
&["remem.exe", "remem.cmd", "remem.bat"],
|path| match std::fs::read_to_string(path) {
Ok(version) => Some(version),
Err(error) => panic!("candidate version {}: {error}", path.display()),
},
);
assert!(!report.has_warning());
assert_eq!(
report.configured_resolved_paths,
vec![canonical_or_original(&expected)]
);
assert!(report.candidates[0].configured);
}
#[test]
fn warns_for_multiple_configured_paths() {
let first = temp_dir("configured-a");
let second = temp_dir("configured-b");
let first_path = write_candidate(&first, "remem", "remem 0.4.5");
let second_path = write_candidate(&second, "remem", "remem 0.4.5");
let report = collect_install_paths(
vec![first, second],
&[first_path, second_path],
&["remem"],
|path| Some(std::fs::read_to_string(path).expect("candidate version")),
);
assert!(report.has_warning());
assert!(report.configured_paths_disagree());
}
#[test]
fn windows_candidate_names_include_wrappers() {
assert_eq!(
candidate_names_for_platform(true),
["remem.exe", "remem.cmd", "remem.bat"].as_slice()
);
}
#[test]
fn parses_first_version_line_from_stdout_or_stderr() {
assert_eq!(
parse_version_output(b"remem 0.4.5\nextra".to_vec(), Vec::new()),
Some("remem 0.4.5".to_string())
);
assert_eq!(
parse_version_output(Vec::new(), b"remem 0.4.6\n".to_vec()),
Some("remem 0.4.6".to_string())
);
assert_eq!(parse_version_output(Vec::new(), Vec::new()), None);
}
#[test]
fn parses_schema_version_from_version_label() {
assert_eq!(parse_schema_version("remem 0.5.95 (schema v47)"), Some(47));
assert_eq!(parse_schema_version("remem 0.5.95"), None);
}
#[cfg(unix)]
fn create_symlink(from: &Path, to: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(from, to)
}
}