use std::{
path::{Path, PathBuf},
process::Command,
str::FromStr as _,
sync::LazyLock,
};
use switchy_env::var;
static NPM_COMMANDS: [&str; 3] = ["pnpm", "bun", "npm"];
static ENABLED_NPM_COMMANDS: LazyLock<Vec<String>> = LazyLock::new(|| {
NPM_COMMANDS
.iter()
.filter(|x| match **x {
#[cfg(feature = "pnpm")]
"pnpm" => true,
#[cfg(feature = "bun")]
"bun" => true,
#[cfg(feature = "npm")]
"npm" => true,
_ => false,
})
.map(ToString::to_string)
.collect::<Vec<_>>()
});
pub fn run_npm_command(arguments: &[&str], dir: &Path) {
run_command(ENABLED_NPM_COMMANDS.clone().into_iter(), arguments, dir);
}
pub(crate) fn run_command(binaries: impl Iterator<Item = String>, arguments: &[&str], dir: &Path) {
for ref binary in binaries
.map(|x| PathBuf::from_str(&x).unwrap())
.map(|x| {
if x.file_name().is_some_and(|x| x == "pnpm")
&& let Ok(pnpm_home) = var("PNPM_HOME")
{
return PathBuf::from_str(&pnpm_home).unwrap().join(x);
}
x
})
.map(fixup_binary_filename)
.map(|x| x.to_str().unwrap().to_string())
{
let mut command = Command::new(binary);
let mut command = command.current_dir(dir);
for arg in arguments {
command = command.arg(arg);
}
println!("Running {binary} {}", arguments.join(" "));
match command.spawn() {
Ok(mut child) => {
let status = child
.wait()
.unwrap_or_else(|e| panic!("Failed to execute {binary} script: {e:?}"));
if !status.success() {
if status.code() == Some(127) {
println!("Binary {binary} not found (status code 127)");
continue;
}
panic!("{binary} script failed: status_code={:?}", status.code());
}
return;
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
println!("Binary {binary} not found");
continue;
}
panic!("Failed to execute {binary} script: {e:?}");
}
}
}
panic!("Failed to execute script for any of the binaries");
}
#[must_use]
pub(crate) fn fixup_binary_filename(binary: PathBuf) -> PathBuf {
if cfg!(windows) {
let parent = binary.parent();
if let Some(parent) = parent {
let cmd = parent.join(format!(
"{}.CMD",
binary.file_name().unwrap().to_str().unwrap()
));
if cmd.is_file() {
return cmd;
}
}
}
binary
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test_log::test]
fn test_fixup_binary_filename_non_windows() {
if !cfg!(windows) {
let binary = PathBuf::from("/usr/bin/node");
let result = fixup_binary_filename(binary.clone());
assert_eq!(result, binary);
let binary_with_ext = PathBuf::from("/usr/bin/node.exe");
let result = fixup_binary_filename(binary_with_ext.clone());
assert_eq!(result, binary_with_ext);
}
}
#[test_log::test]
fn test_fixup_binary_filename_windows_no_cmd_exists() {
if cfg!(windows) {
let temp_dir = tempfile::tempdir().unwrap();
let binary = temp_dir.path().join("nonexistent");
let result = fixup_binary_filename(binary.clone());
assert_eq!(result, binary);
}
}
#[test_log::test]
fn test_fixup_binary_filename_windows_cmd_exists() {
if cfg!(windows) {
let temp_dir = tempfile::tempdir().unwrap();
let binary = temp_dir.path().join("testbin");
let cmd_file = temp_dir.path().join("testbin.CMD");
fs::write(&cmd_file, "").unwrap();
let result = fixup_binary_filename(binary);
assert_eq!(result, cmd_file);
}
}
#[test_log::test]
fn test_fixup_binary_filename_no_parent() {
let binary = PathBuf::from("node");
let result = fixup_binary_filename(binary.clone());
assert_eq!(result, binary);
}
#[test_log::test]
fn test_enabled_npm_commands_contains_valid_managers() {
let valid_managers = ["pnpm", "bun", "npm"];
for manager in ENABLED_NPM_COMMANDS.iter() {
assert!(
valid_managers.contains(&manager.as_str()),
"Invalid npm manager: {manager}"
);
}
}
#[test_log::test]
fn test_enabled_npm_commands_respects_features() {
#[cfg(feature = "pnpm")]
assert!(ENABLED_NPM_COMMANDS.contains(&"pnpm".to_string()));
#[cfg(feature = "bun")]
assert!(ENABLED_NPM_COMMANDS.contains(&"bun".to_string()));
#[cfg(feature = "npm")]
assert!(ENABLED_NPM_COMMANDS.contains(&"npm".to_string()));
#[cfg(not(any(feature = "pnpm", feature = "bun", feature = "npm")))]
assert!(ENABLED_NPM_COMMANDS.is_empty());
}
#[test_log::test]
fn test_npm_commands_priority_order() {
assert_eq!(NPM_COMMANDS, ["pnpm", "bun", "npm"]);
}
#[test_log::test]
fn test_enabled_npm_commands_maintains_priority_order() {
let enabled: Vec<&str> = ENABLED_NPM_COMMANDS.iter().map(String::as_str).collect();
let mut last_index = None;
for cmd in &enabled {
let current_index = NPM_COMMANDS
.iter()
.position(|x| x == cmd)
.expect("Enabled command should be in NPM_COMMANDS");
if let Some(last) = last_index {
assert!(
current_index > last,
"ENABLED_NPM_COMMANDS should maintain priority order from NPM_COMMANDS"
);
}
last_index = Some(current_index);
}
}
}