use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use colored::Colorize;
use crate::resolver::ResolutionOverrides;
use crate::types::{DetectionWarning, ProjectContext};
mod clean;
mod completions;
mod doctor;
mod info;
pub(crate) mod install;
mod list;
#[cfg(feature = "man")]
mod man;
pub(crate) mod run;
#[cfg(feature = "schema")]
mod schema;
mod why;
pub(crate) use clean::clean;
pub(crate) use completions::{completions, parse_shell_arg};
pub(crate) use doctor::doctor;
pub(crate) use info::info;
pub(crate) use install::install;
pub(crate) use list::list;
#[cfg(feature = "man")]
pub(crate) use man::{write_man_pages, write_runner_page_to_stdout};
pub(crate) use run::run;
#[cfg(feature = "schema")]
pub(crate) use schema::write_schema;
pub(crate) use why::why;
fn configure_command(command: &mut Command, dir: &Path) {
prepend_node_bin_path(command, dir);
command
.current_dir(dir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
}
fn node_bin_dirs(dir: &Path) -> Vec<PathBuf> {
dir.ancestors()
.map(|ancestor| ancestor.join("node_modules").join(".bin"))
.filter(|bin| bin.is_dir())
.collect()
}
fn prepend_node_bin_path(command: &mut Command, dir: &Path) {
let bins = node_bin_dirs(dir);
if bins.is_empty() {
return;
}
#[cfg(windows)]
resolve_program_in_bins(command, &bins);
if let Some(path) = prepended_path(&bins, std::env::var_os("PATH").as_deref()) {
command.env("PATH", path);
}
}
fn prepended_path(bins: &[PathBuf], parent: Option<&OsStr>) -> Option<OsString> {
let inherited = parent.map(std::env::split_paths).into_iter().flatten();
std::env::join_paths(bins.iter().cloned().chain(inherited)).ok()
}
#[cfg(windows)]
fn resolve_program_in_bins(command: &mut Command, bins: &[PathBuf]) {
let program = command.get_program().to_os_string();
let Some(name) = program.to_str() else { return };
if Path::new(name).components().count() > 1 {
return;
}
let Ok(joined) = std::env::join_paths(bins.iter().cloned()) else {
return;
};
let pathext =
std::env::var_os("PATHEXT").unwrap_or_else(|| crate::tool::program::DEFAULT_PATHEXT.into());
let Some(resolved) = crate::tool::program::resolve_windows(name, &joined, &pathext) else {
return;
};
let args: Vec<OsString> = command.get_args().map(ToOwned::to_owned).collect();
let envs: Vec<(OsString, Option<OsString>)> = command
.get_envs()
.map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned)))
.collect();
let cwd = command.get_current_dir().map(Path::to_path_buf);
let mut next = Command::new(resolved);
next.args(args);
for (key, value) in envs {
match value {
Some(value) => {
next.env(key, value);
}
None => {
next.env_remove(key);
}
}
}
if let Some(cwd) = cwd {
next.current_dir(cwd);
}
*command = next;
}
pub(crate) fn exit_code(status: ExitStatus) -> i32 {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt as _;
if let Some(code) = status.code() {
return code;
}
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
status.code().unwrap_or(1)
}
const fn should_group(group_output: bool, under_github_actions: bool) -> bool {
group_output && under_github_actions
}
fn task_group(overrides: &ResolutionOverrides, name: &str) -> Option<actions_rs::log::GroupGuard> {
should_group(overrides.group_output, actions_rs::env::is_github_actions())
.then(|| actions_rs::log::group_guard(format!("runner: {name}")))
}
pub(crate) type WarningSink<'a> = Option<&'a mut std::collections::HashSet<DetectionWarning>>;
fn print_warnings(ctx: &ProjectContext, overrides: &ResolutionOverrides, sink: WarningSink<'_>) {
print_warning_slice(&ctx.warnings, overrides, sink);
}
fn print_warning_slice(
warnings: &[DetectionWarning],
overrides: &ResolutionOverrides,
sink: WarningSink<'_>,
) {
if overrides.no_warnings {
return;
}
if let Some(set) = sink {
for warning in warnings {
set.insert(warning.clone());
}
return;
}
for warning in warnings {
eprintln!("{} {warning}", "warn:".yellow().bold());
}
}
pub(crate) fn emit_collected_warnings(
warnings: &std::collections::HashSet<DetectionWarning>,
overrides: &ResolutionOverrides,
) {
if overrides.no_warnings {
return;
}
let mut sorted: Vec<(String, &DetectionWarning)> =
warnings.iter().map(|w| (w.to_string(), w)).collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (_, warning) in sorted {
eprintln!("{} {warning}", "warn:".yellow().bold());
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use super::{configure_command, node_bin_dirs, prepended_path};
use crate::tool::test_support::TempDir;
#[test]
fn configure_command_sets_current_dir() {
let dir = std::env::temp_dir();
let mut command = Command::new("runner-test-command");
configure_command(&mut command, dir.as_path());
assert_eq!(command.get_current_dir(), Some(dir.as_path()));
}
#[test]
fn node_bin_dirs_walks_ancestors_nearest_first() {
let dir = TempDir::new("node-bin-walk");
let member = dir.path().join("apps").join("web");
let member_bin = member.join("node_modules").join(".bin");
let root_bin = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&member_bin).expect("member bin should be created");
fs::create_dir_all(&root_bin).expect("root bin should be created");
let bins = node_bin_dirs(&member);
assert_eq!(&bins[..2], [member_bin, root_bin]);
assert!(bins.iter().skip(2).all(|bin| !bin.starts_with(dir.path())));
}
#[test]
fn node_bin_dirs_requires_bin_subdir() {
let dir = TempDir::new("node-bin-missing");
fs::create_dir_all(dir.path().join("node_modules")).expect("dir should be created");
let bins = node_bin_dirs(dir.path());
assert!(bins.iter().all(|bin| !bin.starts_with(dir.path())));
}
#[test]
fn prepended_path_orders_bins_before_parent() {
let bins = vec![
PathBuf::from("/repo/apps/web/node_modules/.bin"),
PathBuf::from("/repo/node_modules/.bin"),
];
let parent = OsString::from("/usr/bin");
let joined = prepended_path(&bins, Some(parent.as_os_str()))
.expect("plain paths should always join");
let parts: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(
parts,
[
PathBuf::from("/repo/apps/web/node_modules/.bin"),
PathBuf::from("/repo/node_modules/.bin"),
PathBuf::from("/usr/bin"),
],
);
}
#[test]
fn prepended_path_handles_missing_parent() {
let bins = vec![PathBuf::from("/repo/node_modules/.bin")];
let joined = prepended_path(&bins, None).expect("plain paths should always join");
let parts: Vec<PathBuf> = std::env::split_paths(&joined).collect();
assert_eq!(parts, [PathBuf::from("/repo/node_modules/.bin")]);
}
#[cfg(unix)]
#[test]
fn spawn_resolves_dev_dependency_binary_via_child_path() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new("child-path-spawn");
let bin = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&bin).expect("bin dir should be created");
let shim = bin.join("runner-test-shim");
fs::write(&shim, "#!/bin/sh\nexit 42\n").expect("shim should be written");
fs::set_permissions(&shim, fs::Permissions::from_mode(0o755))
.expect("shim should be marked executable");
let mut command = Command::new("runner-test-shim");
configure_command(&mut command, dir.path());
let status = command
.status()
.expect("shim should spawn via the child PATH");
assert_eq!(status.code(), Some(42));
}
#[cfg(windows)]
#[test]
fn configure_command_resolves_cmd_shim_from_bin_dir() {
use std::ffi::OsStr;
let dir = TempDir::new("win-bin-shim");
let bin = dir.path().join("node_modules").join(".bin");
fs::create_dir_all(&bin).expect("bin dir should be created");
let shim = bin.join("runner-test-shim.cmd");
fs::write(&shim, "@echo off\r\n").expect("shim should be written");
let mut command = Command::new("runner-test-shim");
command.arg("run").env("RUNNER_TEST_MARKER", "1");
configure_command(&mut command, dir.path());
assert_eq!(PathBuf::from(command.get_program()), shim);
let args: Vec<_> = command.get_args().collect();
assert_eq!(args, [OsStr::new("run")]);
assert!(
command
.get_envs()
.any(|(key, value)| key == "RUNNER_TEST_MARKER" && value == Some(OsStr::new("1"))),
);
}
#[test]
fn no_warnings_suppresses_emission() {
use super::print_warning_slice;
use crate::resolver::ResolutionOverrides;
use crate::types::{DetectionWarning, PackageManager};
let warnings = vec![DetectionWarning::PmMismatch {
declared: PackageManager::Pnpm,
field: "packageManager",
lockfile: PackageManager::Yarn,
}];
let overrides = ResolutionOverrides {
no_warnings: true,
..ResolutionOverrides::default()
};
print_warning_slice(&warnings, &overrides, None);
}
#[cfg(unix)]
#[test]
fn exit_code_preserves_signal_status() {
use std::os::unix::process::ExitStatusExt as _;
use super::exit_code;
assert_eq!(exit_code(std::process::ExitStatus::from_raw(5 << 8)), 5);
assert_eq!(exit_code(std::process::ExitStatus::from_raw(2)), 130);
}
#[test]
fn should_group_requires_both_opt_in_and_github_actions() {
use super::should_group;
assert!(should_group(true, true));
assert!(!should_group(false, true), "config opt-out wins");
assert!(
!should_group(true, false),
"no grouping outside GitHub Actions"
);
assert!(!should_group(false, false));
}
}