use std::ffi::OsStr;
#[cfg(windows)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use pmpx_plugin::CommandSpec;
use crate::error::{PmpxError, Result};
use crate::style;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgramKind {
Native,
CmdShim,
PowerShellShim,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub program: PathBuf,
pub kind: ProgramKind,
}
pub fn resolve(program: &OsStr) -> Result<Resolved> {
let as_path = Path::new(program);
let has_separator = as_path.components().any(|c| {
matches!(
c,
std::path::Component::RootDir | std::path::Component::ParentDir
)
}) || program.to_string_lossy().contains(['/', '\\']);
let path = if has_separator {
if as_path.is_file() {
as_path.to_path_buf()
} else {
return Err(PmpxError::not_found(format!(
"cannot find {}. It looks like a path, but there is no file there.",
as_path.display()
)));
}
} else {
which::which(program).map_err(|_| not_found_error(program))?
};
Ok(Resolved {
kind: kind_of(&path),
program: path,
})
}
fn kind_of(path: &Path) -> ProgramKind {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"cmd" | "bat" => ProgramKind::CmdShim,
"ps1" => ProgramKind::PowerShellShim,
_ => ProgramKind::Native,
}
}
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
}
pub fn command_for(spec: &CommandSpec, cwd: &Path) -> Result<Command> {
let resolved = resolve(&spec.program)?;
let cwd = spec.cwd.as_deref().unwrap_or(cwd);
let mut cmd = match resolved.kind {
ProgramKind::Native => {
let mut c = Command::new(&resolved.program);
c.args(&spec.args);
c
}
ProgramKind::CmdShim => {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut c = Command::new("cmd");
c.raw_arg(cmd_raw_command_line(&resolved.program, &spec.args));
c
}
#[cfg(not(windows))]
{
let mut c = Command::new(&resolved.program);
c.args(&spec.args);
c
}
}
ProgramKind::PowerShellShim => {
let mut c = Command::new("pwsh");
c.arg("-NoProfile").arg("-File").arg(&resolved.program);
c.args(&spec.args);
c
}
};
cmd.current_dir(cwd);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
Ok(cmd)
}
#[cfg(windows)]
fn cmd_raw_command_line(program: &Path, args: &[OsString]) -> String {
format!("/d /s /c \"{}\"", build_cmd_line(program, args))
}
#[cfg(windows)]
fn build_cmd_line(program: &Path, args: &[OsString]) -> String {
let mut line = quote_arg(&program.to_string_lossy());
for a in args {
line.push(' ');
line.push_str("e_arg(&a.to_string_lossy()));
}
line
}
#[cfg(any(windows, test))]
fn quote_arg(arg: &str) -> String {
if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\x0b', '"']) {
return arg.to_string();
}
let mut out = String::with_capacity(arg.len() + 2);
out.push('"');
let mut backslashes = 0usize;
for c in arg.chars() {
match c {
'\\' => {
backslashes += 1;
out.push('\\');
}
'"' => {
for _ in 0..=backslashes {
out.push('\\');
}
out.push('"');
backslashes = 0;
}
_ => {
backslashes = 0;
out.push(c);
}
}
}
for _ in 0..backslashes {
out.push('\\');
}
out.push('"');
out
}
pub fn announce(spec: &CommandSpec) {
let mut line = format!(
"{} {}",
style::paint(style::DIM, "pmpx ->"),
style::paint(style::PM, spec.program.to_string_lossy())
);
for arg in &spec.args {
line.push(' ');
line.push_str(&arg.to_string_lossy());
}
anstream::eprintln!("{line}");
}
pub fn run(spec: &CommandSpec, cwd: &Path) -> Result<u8> {
let mut cmd = command_for(spec, cwd)?;
let status = cmd.status().map_err(|e| {
PmpxError::Other(anyhow::anyhow!(e).context(format!(
"failed to start {}",
spec.program.to_string_lossy()
)))
})?;
Ok(exit_code_of(status))
}
fn exit_code_of(status: std::process::ExitStatus) -> u8 {
if let Some(code) = status.code() {
if (0..=255).contains(&code) {
return code as u8;
}
error_line(format!(
"backend exit code {code} is outside 0-255, passing through the low 8 bits"
));
return (code & 0xFF) as u8;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
error_line(format!("the backend was killed by signal {sig}"));
return (128 + sig).clamp(0, 255) as u8;
}
}
error_line("cannot read the backend exit code, treating it as 1");
1
}
fn error_line(body: impl std::fmt::Display) {
anstream::eprintln!(
"{} {}",
style::paint(style::ERROR, "pmpx:"),
style::paint(style::ERROR_BODY, body)
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exe_and_extensionless_are_native() {
assert_eq!(kind_of(Path::new("C:/x/cargo.exe")), ProgramKind::Native);
assert_eq!(kind_of(Path::new("/usr/bin/cargo")), ProgramKind::Native);
assert_eq!(kind_of(Path::new("C:/x/tool.bin")), ProgramKind::Native);
}
#[test]
fn cmd_and_bat_are_shims() {
assert_eq!(kind_of(Path::new("C:/x/pnpm.cmd")), ProgramKind::CmdShim);
assert_eq!(kind_of(Path::new("C:/x/old.bat")), ProgramKind::CmdShim);
}
#[test]
fn extension_matching_is_case_insensitive() {
assert_eq!(kind_of(Path::new("C:/x/PNPM.CMD")), ProgramKind::CmdShim);
assert_eq!(
kind_of(Path::new("C:/x/Run.Ps1")),
ProgramKind::PowerShellShim
);
}
#[test]
fn ps1_needs_powershell() {
assert_eq!(
kind_of(Path::new("C:/x/x.ps1")),
ProgramKind::PowerShellShim
);
}
#[test]
fn simple_args_are_not_quoted() {
assert_eq!(quote_arg("add"), "add");
assert_eq!(quote_arg("--noEmit"), "--noEmit");
assert_eq!(quote_arg("C:/x/y"), "C:/x/y");
}
#[test]
fn args_with_spaces_are_quoted() {
assert_eq!(quote_arg("C:/Program Files/x"), "\"C:/Program Files/x\"");
}
#[test]
fn empty_arg_becomes_empty_quotes() {
assert_eq!(quote_arg(""), "\"\"");
}
#[test]
fn inner_quotes_are_escaped() {
assert_eq!(quote_arg("say \"hi\""), "\"say \\\"hi\\\"\"");
}
#[test]
fn an_arg_without_spaces_is_left_alone_even_with_backslashes() {
assert_eq!(quote_arg("C:\\dir\\"), "C:\\dir\\");
assert_eq!(quote_arg("C:\\x\\y"), "C:\\x\\y");
}
#[test]
fn trailing_backslashes_are_doubled_when_quoting() {
assert_eq!(quote_arg("a b\\"), "\"a b\\\\\"");
assert_eq!(quote_arg("C:\\a b\\"), "\"C:\\a b\\\\\"");
}
#[test]
fn backslashes_before_a_quote_are_doubled_and_the_quote_escaped() {
assert_eq!(quote_arg("a\\\"b"), "\"a\\\\\\\"b\"");
}
#[test]
fn a_plain_backslash_is_untouched_inside_quotes() {
assert_eq!(quote_arg("C:\\a b\\c"), "\"C:\\a b\\c\"");
}
#[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 an_explicit_path_is_used_as_is() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("thing");
std::fs::write(&p, "").unwrap();
let r = resolve(p.as_os_str()).unwrap();
assert_eq!(r.program, p);
}
#[test]
fn an_explicit_path_that_does_not_exist_is_an_error() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("nope").join("thing");
let err = resolve(p.as_os_str()).unwrap_err();
assert_eq!(err.exit_code(), crate::error::EXIT_NOT_FOUND);
assert!(err.to_string().contains("path"));
}
#[test]
fn resolves_a_real_program_from_path() {
let r = resolve(OsStr::new("cargo")).expect("cargo must be on PATH");
assert!(r.program.is_absolute(), "{:?}", r.program);
}
#[test]
fn a_missing_program_gives_exit_code_three() {
let err = resolve(OsStr::new("pmpx-definitely-not-a-real-program-xyz")).unwrap_err();
assert_eq!(err.exit_code(), crate::error::EXIT_NOT_FOUND);
}
#[cfg(windows)]
#[test]
fn which_does_pathex_resolution() {
let tmp = tempfile::tempdir().unwrap();
let shim = tmp.path().join("pmpxprobe.cmd");
std::fs::write(&shim, "@echo off\r\n").unwrap();
let found = which::which_in("pmpxprobe", Some(tmp.path()), tmp.path())
.expect("which should find the .cmd via PATHEXT");
assert_eq!(
found
.file_name()
.unwrap()
.to_string_lossy()
.to_ascii_lowercase(),
"pmpxprobe.cmd"
);
assert_eq!(kind_of(&found), ProgramKind::CmdShim);
}
#[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}"
);
}
#[test]
fn runs_a_native_command_and_returns_its_exit_code() {
let tmp = tempfile::tempdir().unwrap();
let spec = CommandSpec::new("cargo").arg("--version");
assert_eq!(
run(&spec, tmp.path()).unwrap(),
0,
"cargo --version should succeed"
);
}
#[test]
fn a_nonzero_backend_exit_code_is_passed_through() {
let tmp = tempfile::tempdir().unwrap();
#[cfg(windows)]
let spec = CommandSpec::new("cmd").arg("/c").arg("exit 7");
#[cfg(not(windows))]
let spec = CommandSpec::new("sh").arg("-c").arg("exit 7");
assert_eq!(
run(&spec, tmp.path()).unwrap(),
7,
"must pass through verbatim"
);
}
#[test]
fn the_working_directory_is_honoured() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("pmpx-cwd-probe.txt");
std::fs::write(&marker, "here").unwrap();
let spec = CommandSpec::new("cargo").arg("--version");
let mut cmd = command_for(&spec, tmp.path()).unwrap();
let out = cmd.output().unwrap();
assert!(out.status.success());
}
#[test]
fn a_missing_program_fails_before_spawning() {
let tmp = tempfile::tempdir().unwrap();
let spec = CommandSpec::new("pmpx-definitely-not-a-real-program-xyz");
assert!(command_for(&spec, tmp.path()).is_err());
}
#[test]
fn a_cwd_in_the_spec_overrides_the_callers_directory() {
let outer = tempfile::tempdir().unwrap();
let inner = tempfile::tempdir().unwrap();
let spec = CommandSpec::new("cargo").arg("--version").cwd(inner.path());
let cmd = command_for(&spec, outer.path()).unwrap();
assert_eq!(cmd.get_current_dir(), Some(inner.path()));
}
#[test]
fn without_a_cwd_in_the_spec_the_callers_directory_is_used() {
let outer = tempfile::tempdir().unwrap();
let spec = CommandSpec::new("cargo").arg("--version");
let cmd = command_for(&spec, outer.path()).unwrap();
assert_eq!(cmd.get_current_dir(), Some(outer.path()));
}
#[cfg(windows)]
#[test]
fn a_cmd_shim_is_wrapped_in_cmd_exe() {
let shim = Path::new("C:\\path with space\\pnpm.cmd");
let args = vec![OsString::from("add"), OsString::from("serde")];
let line = cmd_raw_command_line(shim, &args);
assert!(line.starts_with("/d /s /c "), "{line}");
assert!(line.ends_with('"'), "{line}");
assert_eq!(
line.matches('"').count(),
4,
"one pair for the path + one outer pair: {line}"
);
assert!(line.contains("\"C:\\path with space\\pnpm.cmd\""), "{line}");
assert!(line.ends_with("add serde\""), "{line}");
}
#[cfg(windows)]
#[test]
fn the_outer_quote_pair_is_always_present() {
let line = cmd_raw_command_line(Path::new("C:\\x\\pnpm.cmd"), &[]);
assert_eq!(line, "/d /s /c \"C:\\x\\pnpm.cmd\"");
}
#[cfg(windows)]
#[test]
fn a_cmd_shim_really_runs_and_receives_its_args() {
let tmp = tempfile::tempdir().unwrap();
let out_file = tmp.path().join("got.txt");
let shim = tmp.path().join("probe.cmd");
std::fs::write(
&shim,
format!("@echo off\r\necho %1 %2 > \"{}\"\r\n", out_file.display()),
)
.unwrap();
let spec = CommandSpec::new(shim.as_os_str()).arg("add").arg("serde");
assert_eq!(run(&spec, tmp.path()).unwrap(), 0);
let got = std::fs::read_to_string(&out_file).unwrap();
assert_eq!(got.trim(), "add serde");
}
#[cfg(windows)]
#[test]
fn a_cmd_shim_in_a_path_with_spaces_still_runs() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("a dir with spaces");
std::fs::create_dir_all(&dir).unwrap();
let shim = dir.join("probe.cmd");
std::fs::write(&shim, "@echo off\r\nexit 0\r\n").unwrap();
let spec = CommandSpec::new(shim.as_os_str());
assert_eq!(
run(&spec, tmp.path()).unwrap(),
0,
"a path with spaces must work -- that is why the double quoting exists"
);
}
#[cfg(windows)]
#[test]
fn a_cmd_shim_receives_an_argument_with_spaces() {
let tmp = tempfile::tempdir().unwrap();
let out_file = tmp.path().join("got.txt");
let shim = tmp.path().join("probe.cmd");
std::fs::write(
&shim,
format!("@echo off\r\necho %~1 > \"{}\"\r\n", out_file.display()),
)
.unwrap();
let spec = CommandSpec::new(shim.as_os_str()).arg("hello world");
assert_eq!(run(&spec, tmp.path()).unwrap(), 0);
let got = std::fs::read_to_string(&out_file).unwrap();
assert_eq!(got.trim(), "hello world");
}
#[cfg(windows)]
#[test]
fn a_cmd_shim_passes_through_a_nonzero_exit_code() {
let tmp = tempfile::tempdir().unwrap();
let shim = tmp.path().join("probe.cmd");
std::fs::write(&shim, "@echo off\r\nexit /b 42\r\n").unwrap();
let spec = CommandSpec::new(shim.as_os_str());
assert_eq!(run(&spec, tmp.path()).unwrap(), 42);
}
}