use std::ffi::OsStr;
use std::path::PathBuf;
use crate::error::PmpxError;
pub(super) fn not_found_error(program: &OsStr) -> PmpxError {
let wanted = program.to_string_lossy().to_ascii_lowercase();
let near = near_misses(&wanted);
let mut msg = format!("cannot find executable {}", program.to_string_lossy());
if near.is_empty() {
msg.push_str(
"\nNothing in PATH has a similar name -- it is probably not installed at all.",
);
} else {
msg.push_str("\nSimilar names in PATH:");
for p in &near {
msg.push_str(&format!("\n - {}", p.display()));
}
}
#[cfg(windows)]
if matches!(near.first().and_then(|p| p.extension()), Some(e) if e.eq_ignore_ascii_case("cmd"))
{
msg.push_str(
"\n\nNote: on Windows these package managers are .cmd scripts and only PATHEXT \
resolution finds them; pmpx already does that, so seeing this means that \
directory really is not on PATH.",
);
}
PmpxError::not_found(msg)
}
fn name_matches(wanted_lower: &str, candidate: &str) -> bool {
let lower = candidate.to_ascii_lowercase();
lower.starts_with(wanted_lower) && lower != wanted_lower
}
fn near_misses(wanted_lower: &str) -> Vec<PathBuf> {
let Ok(path_var) = std::env::var("PATH") else {
return Vec::new();
};
let mut out = Vec::new();
for dir in std::env::split_paths(&path_var) {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.filter_map(std::result::Result::ok) {
if name_matches(wanted_lower, &entry.file_name().to_string_lossy()) {
out.push(entry.path());
}
}
if out.len() >= 5 {
break;
}
}
out.sort();
out.truncate(5);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spawn::resolve::resolve;
#[test]
fn a_cmd_shim_is_a_near_miss_for_its_bare_name() {
assert!(name_matches("pnpm", "pnpm.cmd"));
assert!(name_matches("pnpm", "PNPM.CMD"), "case-insensitive");
assert!(name_matches("pnpm", "pnpm.cmd.old"));
}
#[test]
fn a_different_tool_is_not_a_near_miss() {
assert!(!name_matches("npm", "pnpm.cmd"));
assert!(!name_matches("pnpm", "npmy"));
}
#[test]
fn prefix_matching_accepts_some_false_positives() {
assert!(name_matches("bun", "bunzip2"));
assert!(name_matches("npm", "npmx"));
}
#[test]
fn an_exact_name_is_not_a_near_miss() {
assert!(!name_matches("cargo", "cargo"));
assert!(!name_matches("cargo", "CARGO"));
}
#[test]
fn a_missing_program_says_something_useful() {
let err = resolve(OsStr::new("pmpx-definitely-not-a-real-program-xyz")).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("pmpx-definitely-not-a-real-program-xyz"),
"{msg}"
);
assert!(
msg.contains("not installed") || msg.contains("Similar names"),
"either say it is probably not installed, or list candidates: {msg}"
);
}
}