use std::path::PathBuf;
use std::sync::OnceLock;
use crate::types::PackageManager;
static CACHE: [OnceLock<Option<PathBuf>>; PackageManager::COUNT] =
[const { OnceLock::new() }; PackageManager::COUNT];
pub(crate) fn probe(pm: PackageManager) -> Option<PathBuf> {
CACHE[pm.index()]
.get_or_init(|| {
std::env::var_os("PATH").and_then(|path| {
probe_in(pm.label(), &path, std::env::var_os("PATHEXT").as_deref())
})
})
.clone()
}
pub(crate) fn probe_in(
name: &str,
path: &std::ffi::OsStr,
pathext: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
use std::path::Path;
if name.is_empty() || Path::new(name).components().count() > 1 {
return None;
}
let exts: Vec<String> = pathext
.map(|pe| {
pe.to_string_lossy()
.split(';')
.filter(|e| !e.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default();
let has_explicit_extension = Path::new(name).extension().is_some();
for dir in std::env::split_paths(path) {
let bare = dir.join(name);
if bare.is_file() {
return Some(bare);
}
if has_explicit_extension {
continue;
}
for ext in &exts {
let candidate = dir.join(format!("{name}{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
pub(crate) const NODE_PROBE_ORDER: &[PackageManager] = &[
PackageManager::Bun,
PackageManager::Pnpm,
PackageManager::Yarn,
PackageManager::Npm,
];
pub(crate) fn probe_all(order: &[PackageManager]) -> Vec<(PackageManager, PathBuf)> {
order
.iter()
.filter_map(|&pm| probe(pm).map(|path| (pm, path)))
.collect()
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use super::{NODE_PROBE_ORDER, probe_in};
use crate::tool::test_support::TempDir;
#[test]
fn probe_in_finds_executable_by_bare_name() {
let dir = TempDir::new("probe-bare");
let target = dir.path().join("pnpm");
fs::write(&target, "#!/bin/sh\n").expect("shim should be written");
let resolved = probe_in("pnpm", &OsString::from(dir.path()), None)
.expect("pnpm should resolve via bare name");
assert!(resolved.ends_with("pnpm"));
}
#[test]
fn probe_in_returns_none_when_path_is_empty() {
assert!(probe_in("pnpm", &OsString::new(), None).is_none());
}
#[test]
fn probe_in_skips_directories() {
let dir = TempDir::new("probe-dir");
fs::create_dir(dir.path().join("yarn")).expect("yarn dir should be created");
assert!(probe_in("yarn", &OsString::from(dir.path()), None).is_none());
}
#[test]
fn probe_in_finds_pathext_shim_on_windows_style_input() {
let dir = TempDir::new("probe-pathext");
let shim = dir.path().join("npm.CMD");
fs::write(&shim, "@echo off\n").expect("shim should be written");
let resolved = probe_in(
"npm",
&OsString::from(dir.path()),
Some(&OsString::from(".COM;.EXE;.BAT;.CMD")),
)
.expect("npm.CMD should resolve via PATHEXT");
assert!(resolved.ends_with("npm.CMD"));
}
#[test]
fn probe_in_rejects_names_with_path_separators() {
let dir = TempDir::new("probe-sep");
let target = dir.path().join("nested").join("pnpm");
fs::create_dir_all(target.parent().expect("parent")).expect("parent dir");
fs::write(&target, "").expect("shim should be written");
assert!(probe_in("nested/pnpm", &OsString::from(dir.path()), None).is_none());
}
#[test]
fn probe_returns_consistent_value_across_calls() {
use super::probe;
use crate::types::PackageManager;
let first = probe(PackageManager::Composer);
let second = probe(PackageManager::Composer);
assert_eq!(first, second, "repeat probes must observe same value");
}
#[test]
fn node_probe_order_is_bun_first() {
assert_eq!(
NODE_PROBE_ORDER,
&[
crate::types::PackageManager::Bun,
crate::types::PackageManager::Pnpm,
crate::types::PackageManager::Yarn,
crate::types::PackageManager::Npm,
]
);
}
}