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 config;
mod doctor;
mod info;
pub(crate) mod install;
mod list;
#[cfg(feature = "lsp")]
pub(crate) mod lsp;
#[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 config::config;
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::config_schema;
#[cfg(feature = "schema")]
pub(crate) use schema::write_schema;
pub(crate) use why::why;
fn configure_command(command: &mut Command, dir: &Path, overrides: &ResolutionOverrides) {
prepend_node_bin_path(command, dir);
command
.current_dir(dir)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
if emits_group(overrides) {
command.env(GROUP_ACTIVE_ENV, "1");
}
}
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)
}
pub(crate) const GROUP_ACTIVE_ENV: &str = "RUNNER_GROUP_ACTIVE";
const fn should_group(group_output: bool, under_github_actions: bool) -> bool {
group_output && under_github_actions
}
const fn group_emission(
group_output: bool,
under_github_actions: bool,
parent_group_open: bool,
) -> bool {
should_group(group_output, under_github_actions) && !parent_group_open
}
pub(crate) fn emits_group(overrides: &ResolutionOverrides) -> bool {
group_emission(
overrides.group_output,
actions_rs::env::is_github_actions(),
overrides.parent_group_open,
)
}
fn task_group(overrides: &ResolutionOverrides, name: &str) -> Option<actions_rs::log::GroupGuard> {
emits_group(overrides).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());
}
}
pub(crate) fn format_duration(elapsed: std::time::Duration) -> String {
let millis = elapsed.as_millis();
if millis < 1000 {
return format!("{millis}ms");
}
let tenths = (millis + 50) / 100;
if tenths >= 600 {
let secs = (millis + 500) / 1000;
return format!("{}m {:02}s", secs / 60, secs % 60);
}
format!("{}.{}s", tenths / 10, tenths % 10)
}
pub(crate) fn task_timing_summary(elapsed: std::time::Duration, code: i32) -> String {
format!("finished in {} (exit {code})", format_duration(elapsed))
}
pub(crate) const fn timing_enabled(overrides: &ResolutionOverrides) -> bool {
!overrides.quiet && !overrides.no_warnings
}
pub(crate) fn emit_task_timing(
overrides: &ResolutionOverrides,
name: &str,
elapsed: std::time::Duration,
code: i32,
) {
if !timing_enabled(overrides) {
return;
}
eprintln!(
"{} {} {}",
"·".dimmed(),
name.bold(),
task_timing_summary(elapsed, code).dimmed(),
);
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use super::{configure_command, group_emission, node_bin_dirs, prepended_path};
use crate::resolver::ResolutionOverrides;
use crate::tool::test_support::TempDir;
#[test]
fn group_emission_gates_on_nesting() {
assert!(
group_emission(true, true, false),
"grouping on + under Actions + not nested → emit"
);
assert!(
!group_emission(true, true, true),
"...but a parent's open group suppresses it (GHA groups don't nest)"
);
assert!(!group_emission(false, true, false), "grouping opted out");
assert!(!group_emission(true, false, false), "not under Actions");
}
#[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(), &ResolutionOverrides::default());
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(), &ResolutionOverrides::default());
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(), &ResolutionOverrides::default());
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 format_duration_uses_millis_below_one_second() {
use std::time::Duration;
use super::format_duration;
assert_eq!(format_duration(Duration::from_millis(0)), "0ms");
assert_eq!(format_duration(Duration::from_millis(5)), "5ms");
assert_eq!(format_duration(Duration::from_millis(342)), "342ms");
assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
}
#[test]
fn format_duration_uses_seconds_with_one_decimal_under_a_minute() {
use std::time::Duration;
use super::format_duration;
assert_eq!(format_duration(Duration::from_secs(1)), "1.0s");
assert_eq!(format_duration(Duration::from_millis(1234)), "1.2s");
assert_eq!(format_duration(Duration::from_millis(4200)), "4.2s");
assert_eq!(format_duration(Duration::from_millis(59_400)), "59.4s");
}
#[test]
fn format_duration_uses_minutes_and_padded_seconds_at_a_minute() {
use std::time::Duration;
use super::format_duration;
assert_eq!(format_duration(Duration::from_mins(1)), "1m 00s");
assert_eq!(format_duration(Duration::from_secs(64)), "1m 04s");
assert_eq!(format_duration(Duration::from_secs(125)), "2m 05s");
assert_eq!(format_duration(Duration::from_secs(3661)), "61m 01s");
}
#[test]
fn format_duration_rounds_seconds_to_nearest_inside_minute_band() {
use std::time::Duration;
use super::format_duration;
assert_eq!(format_duration(Duration::from_millis(60_900)), "1m 01s");
assert_eq!(format_duration(Duration::from_millis(90_700)), "1m 31s");
assert_eq!(format_duration(Duration::from_millis(119_940)), "2m 00s");
assert_eq!(format_duration(Duration::from_millis(90_449)), "1m 30s");
assert_eq!(format_duration(Duration::from_millis(60_450)), "1m 00s");
assert_eq!(format_duration(Duration::from_millis(60_499)), "1m 00s");
assert_eq!(format_duration(Duration::from_millis(90_450)), "1m 30s");
assert_eq!(format_duration(Duration::from_millis(119_450)), "1m 59s");
assert_eq!(format_duration(Duration::from_millis(60_500)), "1m 01s");
}
#[test]
fn format_duration_rounds_into_minute_band_near_sixty_seconds() {
use std::time::Duration;
use super::format_duration;
for millis in [59_950, 59_990, 59_999] {
let rendered = format_duration(Duration::from_millis(millis));
assert_eq!(rendered, "1m 00s", "{millis}ms should round into minutes");
assert_ne!(rendered, "60.0s", "{millis}ms must never print as 60.0s");
}
assert_eq!(format_duration(Duration::from_millis(59_940)), "59.9s");
}
#[test]
fn task_timing_summary_includes_duration_and_exit_code() {
use std::time::Duration;
use super::task_timing_summary;
assert_eq!(
task_timing_summary(Duration::from_millis(1500), 0),
"finished in 1.5s (exit 0)"
);
assert_eq!(
task_timing_summary(Duration::from_millis(200), 7),
"finished in 200ms (exit 7)"
);
}
#[test]
fn timing_enabled_respects_quiet_and_no_warnings() {
use super::timing_enabled;
use crate::resolver::ResolutionOverrides;
assert!(timing_enabled(&ResolutionOverrides::default()));
assert!(!timing_enabled(&ResolutionOverrides {
quiet: true,
..ResolutionOverrides::default()
}));
assert!(!timing_enabled(&ResolutionOverrides {
no_warnings: true,
..ResolutionOverrides::default()
}));
}
#[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));
}
}