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, TaskSource};
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");
}
command.env(WARNED_ROOT_ENV, dir);
}
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";
pub(crate) const WARNED_ROOT_ENV: &str = "RUNNER_WARNED_ROOT";
pub(crate) const TASK_STACK_ENV: &str = "RUNNER_TASK_STACK";
const FRAME_SEP: char = '\u{1e}';
const FIELD_SEP: char = '\u{1f}';
fn task_frame(root: &Path, source: TaskSource, name: &str) -> String {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
format!(
"{}{FIELD_SEP}{}{FIELD_SEP}{name}",
root.to_string_lossy(),
source.label(),
)
}
fn frame_label(frame: &str) -> String {
let mut fields = frame.split(FIELD_SEP).skip(1);
let source = fields.next().unwrap_or_default();
let name = fields.next().unwrap_or_default();
format!("{source}:{name}")
}
fn inherited_task_stack() -> Vec<String> {
let Ok(raw) = std::env::var(TASK_STACK_ENV) else {
return Vec::new();
};
raw.split(FRAME_SEP)
.filter(|frame| !frame.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn push_task_frame(
root: &Path,
source: TaskSource,
name: &str,
) -> anyhow::Result<OsString> {
let pushed = admit_frame(inherited_task_stack(), task_frame(root, source, name))?;
Ok(OsString::from(pushed.join(&FRAME_SEP.to_string())))
}
fn admit_frame(stack: Vec<String>, frame: String) -> anyhow::Result<Vec<String>> {
if let Some(start) = stack.iter().position(|seen| *seen == frame) {
let cycle: Vec<String> = stack[start..]
.iter()
.map(String::as_str)
.chain(std::iter::once(frame.as_str()))
.map(frame_label)
.collect();
anyhow::bail!(
"recursive task resolution detected: {}\nnote: this task dispatches itself through a \
nested `runner`/`run`; point the script at the binary, file, or qualified task it \
actually means",
cycle.join(" -> "),
);
}
let mut pushed = stack;
pushed.push(frame);
Ok(pushed)
}
pub(crate) fn parent_warned_about(root: &Path) -> bool {
let Some(marked) = std::env::var_os(WARNED_ROOT_ENV) else {
return false;
};
same_root(Path::new(&marked), root)
}
fn same_root(marked: &Path, root: &Path) -> bool {
match (marked.canonicalize(), root.canonicalize()) {
(Ok(marked), Ok(root)) => marked == root,
_ => marked == root,
}
}
const fn should_group(group_output: bool, under_github_actions: bool) -> bool {
group_output && under_github_actions
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupSuppression {
None,
ParentGroupOpen,
Quiet,
}
const fn suppression(parent_group_open: bool, quiet: bool) -> GroupSuppression {
if quiet {
GroupSuppression::Quiet
} else if parent_group_open {
GroupSuppression::ParentGroupOpen
} else {
GroupSuppression::None
}
}
const fn group_emission(
group_output: bool,
under_github_actions: bool,
suppression: GroupSuppression,
) -> bool {
should_group(group_output, under_github_actions)
&& matches!(suppression, GroupSuppression::None)
}
pub(crate) fn emits_group(overrides: &ResolutionOverrides) -> bool {
group_emission(
overrides.group_output,
actions_rs::env::is_github_actions(),
suppression(overrides.parent_group_open, overrides.quiet),
)
}
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);
}
const fn silenced(overrides: &ResolutionOverrides) -> bool {
overrides.no_warnings || overrides.parent_warned
}
fn print_warning_slice(
warnings: &[DetectionWarning],
overrides: &ResolutionOverrides,
sink: WarningSink<'_>,
) {
if silenced(overrides) {
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 silenced(overrides) {
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::{
GroupSuppression, 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, GroupSuppression::None),
"grouping on + under Actions + not nested → emit"
);
assert!(
!group_emission(true, true, GroupSuppression::ParentGroupOpen),
"...but a parent's open group suppresses it (GHA groups don't nest)"
);
assert!(
!group_emission(false, true, GroupSuppression::None),
"grouping opted out"
);
assert!(
!group_emission(true, false, GroupSuppression::None),
"not under Actions"
);
assert!(
!group_emission(true, true, GroupSuppression::Quiet),
"--quiet keeps ::group:: off a stdout the caller is parsing",
);
}
#[test]
fn quiet_outranks_a_parents_open_group() {
use super::suppression;
assert_eq!(suppression(false, false), GroupSuppression::None);
assert_eq!(suppression(true, false), GroupSuppression::ParentGroupOpen);
assert_eq!(suppression(false, true), GroupSuppression::Quiet);
assert_eq!(suppression(true, true), GroupSuppression::Quiet);
}
#[test]
fn admit_frame_accepts_distinct_tasks() {
use super::{admit_frame, task_frame};
use crate::types::TaskSource;
let root = PathBuf::from("/repo");
let stack = admit_frame(Vec::new(), task_frame(&root, TaskSource::PackageJson, "a"))
.expect("first frame");
let stack = admit_frame(stack, task_frame(&root, TaskSource::PackageJson, "b"))
.expect("a different task is not a cycle");
assert_eq!(stack.len(), 2);
}
#[test]
fn admit_frame_reports_the_whole_cycle() {
use super::{admit_frame, task_frame};
use crate::types::TaskSource;
let root = PathBuf::from("/repo");
let frame = |name: &str| task_frame(&root, TaskSource::PackageJson, name);
let stack = admit_frame(Vec::new(), frame("a")).expect("first frame");
let stack = admit_frame(stack, frame("b")).expect("second frame");
let err = admit_frame(stack, frame("a")).expect_err("a -> b -> a is a cycle");
let msg = format!("{err:#}");
assert!(
msg.contains("package.json:a -> package.json:b -> package.json:a"),
"the diagnostic must name every task in the loop. msg: {msg}",
);
}
#[test]
fn admit_frame_separates_the_same_task_in_two_roots() {
use super::{admit_frame, task_frame};
use crate::types::TaskSource;
let stack = admit_frame(
Vec::new(),
task_frame(&PathBuf::from("/repo"), TaskSource::PackageJson, "build"),
)
.expect("root frame");
assert!(
admit_frame(
stack,
task_frame(
&PathBuf::from("/repo/apps/web"),
TaskSource::PackageJson,
"build",
),
)
.is_ok(),
"same task name under a different root must still dispatch",
);
}
#[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));
}
}