use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeKind {
Node,
Bun,
}
impl RuntimeKind {
pub const fn name(self) -> &'static str {
match self {
Self::Node => "node",
Self::Bun => "bun",
}
}
fn from_executable(path: &Path) -> Self {
let name = path
.file_stem()
.map(|stem| stem.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name.starts_with("bun") {
Self::Bun
} else {
Self::Node
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsRuntime {
pub kind: RuntimeKind,
pub path: PathBuf,
pub version: String,
}
fn executable_names(base: &str) -> Vec<String> {
if cfg!(windows) {
vec![format!("{base}.exe"), format!("{base}.cmd")]
} else {
vec![base.to_owned()]
}
}
pub fn find_kinds_on_path(
search_path: &OsStr,
kinds: &[RuntimeKind],
) -> Option<(RuntimeKind, PathBuf)> {
for &kind in kinds {
for directory in std::env::split_paths(search_path) {
if directory.as_os_str().is_empty() || !directory.is_absolute() {
continue;
}
for name in executable_names(kind.name()) {
let candidate = directory.join(name);
if candidate.is_file() {
return Some((kind, candidate));
}
}
}
}
None
}
fn version_of(path: &Path) -> Result<String, String> {
let mut command = Command::new(path);
command.arg("--version");
let output = fallow_process::output(&mut command)
.map_err(|error| format!("cannot run {} --version: {error}", path.display()))?;
if !output.status.success() {
return Err(format!(
"{} --version failed: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if version.is_empty() {
return Err(format!("{} --version printed nothing", path.display()));
}
Ok(version)
}
pub fn discover_runtime(
explicit: Option<&Path>,
kinds: &[RuntimeKind],
) -> Result<JsRuntime, String> {
let cwd = std::env::current_dir()
.map_err(|error| format!("cannot read the working directory: {error}"))?;
discover_runtime_in(explicit, kinds, &cwd)
}
fn kind_names(kinds: &[RuntimeKind]) -> String {
kinds
.iter()
.map(|kind| format!("`{}`", kind.name()))
.collect::<Vec<_>>()
.join(" or ")
}
fn discover_runtime_in(
explicit: Option<&Path>,
kinds: &[RuntimeKind],
cwd: &Path,
) -> Result<JsRuntime, String> {
let (kind, path) = if let Some(path) = explicit {
let path = cwd.join(path);
if !path.is_file() {
return Err(format!("the runtime {} is not a file", path.display()));
}
let kind = RuntimeKind::from_executable(&path);
if !kinds.contains(&kind) {
return Err(format!(
"the runtime {} is `{}`, and the test runner needs {}",
path.display(),
kind.name(),
kind_names(kinds)
));
}
(kind, path)
} else {
let search_path = std::env::var_os("PATH").unwrap_or_default();
find_kinds_on_path(&search_path, kinds)
.ok_or_else(|| format!("no {} executable on PATH", kind_names(kinds)))?
};
let version = version_of(&path)?;
Ok(JsRuntime {
kind,
path,
version,
})
}
#[expect(clippy::unwrap_used, reason = "tests unwrap to keep assertions short")]
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
const BOTH: &[RuntimeKind] = &[RuntimeKind::Node, RuntimeKind::Bun];
fn fake_runtime(dir: &Path, name: &str, version: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\necho '{version}'\n")).expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
path
}
#[test]
fn node_wins_over_bun_in_any_path_order() {
let first = tempfile::tempdir().expect("dir");
let second = tempfile::tempdir().expect("dir");
let bun = fake_runtime(first.path(), "bun", "1.4.2");
let node = fake_runtime(second.path(), "node", "v24.20.0");
let search = std::env::join_paths([first.path(), second.path()]).expect("join");
assert_eq!(
find_kinds_on_path(&search, BOTH),
Some((RuntimeKind::Node, node))
);
let only_bun = std::env::join_paths([first.path()]).expect("join");
assert_eq!(
find_kinds_on_path(&only_bun, BOTH),
Some((RuntimeKind::Bun, bun))
);
let relative = std::env::join_paths(["relative/dir"]).expect("join");
assert_eq!(find_kinds_on_path(&relative, BOTH), None);
}
#[test]
fn a_relative_explicit_runtime_resolves_against_the_working_directory() {
let cwd = tempfile::tempdir().expect("dir");
std::fs::create_dir(cwd.path().join("bin")).expect("mkdir");
let node = fake_runtime(&cwd.path().join("bin"), "node", "v24.20.0");
let runtime =
discover_runtime_in(Some(Path::new("bin/node")), BOTH, cwd.path()).expect("runtime");
assert!(runtime.path.is_absolute(), "{}", runtime.path.display());
assert_eq!(runtime.path, node);
assert_eq!(runtime.version, "v24.20.0");
let output = Command::new(&runtime.path)
.current_dir("/")
.output()
.expect("the stored path starts from another directory");
assert!(output.status.success());
}
#[test]
fn an_explicit_runtime_reports_its_kind_and_version() {
let dir = tempfile::tempdir().expect("dir");
let bun = fake_runtime(dir.path(), "bun-canary", "1.4.2");
let runtime = discover_runtime(Some(&bun), BOTH).expect("runtime");
assert_eq!(runtime.kind, RuntimeKind::Bun);
assert_eq!(runtime.version, "1.4.2");
let node = fake_runtime(dir.path(), "node24", "v24.20.0");
assert_eq!(
discover_runtime(Some(&node), BOTH).expect("runtime").kind,
RuntimeKind::Node
);
let missing = dir.path().join("nope");
assert!(
discover_runtime(Some(&missing), BOTH)
.unwrap_err()
.contains("not a file")
);
}
#[test]
fn a_runner_that_needs_one_runtime_finds_only_that_runtime() {
let first = tempfile::tempdir().expect("dir");
let second = tempfile::tempdir().expect("dir");
let node = fake_runtime(first.path(), "node", "v24.20.0");
let bun = fake_runtime(second.path(), "bun", "1.4.2");
let search = std::env::join_paths([first.path(), second.path()]).expect("join");
assert_eq!(
find_kinds_on_path(&search, &[RuntimeKind::Bun]),
Some((RuntimeKind::Bun, bun.clone()))
);
assert_eq!(
find_kinds_on_path(&search, &[RuntimeKind::Node]),
Some((RuntimeKind::Node, node.clone()))
);
let only_node = std::env::join_paths([first.path()]).expect("join");
assert_eq!(find_kinds_on_path(&only_node, &[RuntimeKind::Bun]), None);
let error = discover_runtime_in(Some(&node), &[RuntimeKind::Bun], first.path())
.expect_err("bun test cannot start on node");
assert!(error.contains("needs `bun`"), "{error}");
assert_eq!(
discover_runtime_in(Some(&bun), &[RuntimeKind::Bun], first.path())
.expect("runtime")
.kind,
RuntimeKind::Bun
);
}
}