use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use clap::builder::styling::{AnsiColor, Color, Style, Styles};
use clap::{Args, Parser, Subcommand};
use clap_complete::aot::Shell;
use clap_complete::engine::{ArgValueCandidates, CompletionCandidate, SubcommandCandidates};
use crate::types::{PackageManager, TaskRunner};
const HELP_STYLES: Styles = Styles::styled()
.header(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
.bold()
.underline(),
)
.usage(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Yellow)))
.bold()
.underline(),
)
.literal(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Cyan)))
.bold(),
)
.placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))))
.valid(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Green)))
.bold(),
)
.invalid(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.bold(),
)
.error(
Style::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.bold(),
);
macro_rules! cyan {
($s:literal) => {
concat!("\x1b[36m", $s, "\x1b[0m")
};
}
fn cyan_str(s: &str) -> String {
format!("\x1b[36m{s}\x1b[0m")
}
static PM_HELP: LazyLock<String> = LazyLock::new(|| {
let joined = PackageManager::all()
.iter()
.map(|pm| {
if matches!(pm, PackageManager::Bundler) {
format!("{} (alias: bundle)", pm.label())
} else {
pm.label().to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
format!(
"Override the detected package manager (also reads {} when omitted). Valid: {joined}",
cyan_str("RUNNER_PM"),
)
});
static RUNNER_HELP: LazyLock<String> = LazyLock::new(|| {
let joined = TaskRunner::all()
.iter()
.map(|r| {
if matches!(r, TaskRunner::GoTask) {
format!("{} (alias: go-task)", r.label())
} else {
r.label().to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
format!(
"Override the detected task runner (also reads {} when omitted). Valid: {joined}",
cyan_str("RUNNER_RUNNER"),
)
});
const ALIAS_DISPLAY_ORDER_OFFSET: usize = 100;
fn task_candidates() -> Vec<CompletionCandidate> {
let Ok(dir) = completion_dir() else {
return vec![];
};
let ctx = crate::detect::detect(&dir);
task_candidates_from(&ctx.tasks)
}
fn completion_dir() -> std::io::Result<PathBuf> {
let cwd = std::env::current_dir()?;
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
Ok(resolve_completion_dir(
&cwd,
cli_dir_from_argv(&argv).as_deref(),
std::env::var_os("RUNNER_DIR").as_deref(),
))
}
fn resolve_completion_dir(
cwd: &Path,
cli_dir: Option<&std::ffi::OsStr>,
env_dir: Option<&std::ffi::OsStr>,
) -> PathBuf {
let raw = cli_dir.or(env_dir);
match raw.map(PathBuf::from) {
Some(path) if path.is_absolute() => path,
Some(path) => cwd.join(path),
None => cwd.to_path_buf(),
}
}
fn cli_dir_from_argv(argv: &[std::ffi::OsString]) -> Option<std::ffi::OsString> {
use std::ffi::OsString;
let start = argv.iter().position(|a| a == "--").map_or(1, |idx| idx + 1);
if start >= argv.len() {
return None;
}
let mut found: Option<OsString> = None;
let mut iter = argv[start..].iter();
while let Some(arg) = iter.next() {
if arg == "--dir" {
if let Some(next) = iter.next() {
found = Some(next.clone());
}
continue;
}
if let Some(rest) = arg
.to_str()
.and_then(|s| s.strip_prefix("--dir="))
.map(OsString::from)
{
found = Some(rest);
}
}
found
}
fn task_candidates_from(tasks: &[crate::types::Task]) -> Vec<CompletionCandidate> {
use std::collections::{HashMap, HashSet};
use crate::types::TaskSource;
let mut sources_for_name: HashMap<&str, HashSet<TaskSource>> = HashMap::new();
for task in tasks {
sources_for_name
.entry(&task.name)
.or_default()
.insert(task.source);
}
let is_self_passthrough = |task: &crate::types::Task| -> bool {
let Some(runner) = task.passthrough_to else {
return false;
};
let Some(peer_source) = runner.task_source() else {
return false;
};
task.source == TaskSource::PackageJson
&& sources_for_name
.get(task.name.as_str())
.is_some_and(|set| set.contains(&peer_source))
};
let mut effective_count: HashMap<&str, usize> = HashMap::new();
for task in tasks {
if !is_self_passthrough(task) {
*effective_count.entry(task.name.as_str()).or_default() += 1;
}
}
let mut candidates = Vec::new();
let mut seen_bare = HashSet::new();
for task in tasks {
if is_self_passthrough(task) {
continue;
}
let source_label = task.source.label();
let (help, tag, order) = task.alias_of.as_deref().map_or_else(
|| {
let help = task.description.as_ref().map_or_else(
|| source_label.to_string(),
|desc| format!("{source_label}: {desc}"),
);
(
help,
source_label.to_string(),
usize::from(task.source.display_order()),
)
},
|target| {
let help = format!("→ {target}");
let tag = format!("{source_label} (aliases)");
let order = usize::from(task.source.display_order()) + ALIAS_DISPLAY_ORDER_OFFSET;
(help, tag, order)
},
);
let is_duplicate = effective_count
.get(task.name.as_str())
.copied()
.unwrap_or(0)
> 1;
if seen_bare.insert(&task.name) {
candidates.push(
CompletionCandidate::new(&task.name)
.help(Some(help.clone().into()))
.tag(Some(tag.clone().into()))
.display_order(Some(order)),
);
}
if is_duplicate {
let qualified = format!("{source_label}:{}", task.name);
candidates.push(
CompletionCandidate::new(qualified)
.help(Some(help.into()))
.tag(Some(tag.into()))
.display_order(Some(order)),
);
}
}
candidates
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::ffi::OsString;
use clap::{CommandFactory, Parser};
use super::{
Cli, Command, RunAliasCli, cli_dir_from_argv, resolve_completion_dir, task_candidates_from,
};
use crate::types::{Task, TaskSource};
fn task(name: &str, source: TaskSource) -> Task {
Task {
name: name.into(),
source,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
}
}
fn turbo_passthrough(name: &str) -> Task {
Task {
passthrough_to: Some(crate::types::TaskRunner::Turbo),
..task(name, TaskSource::PackageJson)
}
}
#[test]
fn qualified_candidates_emitted_for_duplicates() {
let tasks = vec![
task("test", TaskSource::PackageJson),
task("test", TaskSource::Makefile),
task("build", TaskSource::PackageJson),
];
let candidates = task_candidates_from(&tasks);
let values: Vec<String> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect();
assert_eq!(
values.iter().filter(|v| *v == "test").count(),
1,
"bare 'test' should appear exactly once"
);
assert!(values.contains(&"package.json:test".to_string()));
assert!(values.contains(&"make:test".to_string()));
assert!(values.contains(&"build".to_string()));
assert!(!values.contains(&"package.json:build".to_string()));
}
#[test]
fn package_json_passthrough_to_turbo_collapses_to_bare_name() {
let tasks = vec![
turbo_passthrough("build"),
task("build", TaskSource::TurboJson),
task("fmt", TaskSource::PackageJson),
];
let candidates = task_candidates_from(&tasks);
let values: Vec<String> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect();
assert_eq!(
values.iter().filter(|v| *v == "build").count(),
1,
"bare 'build' should appear exactly once"
);
assert!(
!values.contains(&"package.json:build".to_string()),
"the package.json passthrough should not surface a qualified form"
);
assert!(
!values.contains(&"turbo.json:build".to_string()),
"with the package.json source swallowed, no qualified form is needed"
);
assert!(values.contains(&"fmt".to_string()));
}
#[test]
fn passthrough_swallow_keeps_unrelated_runner_qualified_forms() {
let tasks = vec![
turbo_passthrough("build"),
task("build", TaskSource::Makefile),
task("build", TaskSource::TurboJson),
];
let candidates = task_candidates_from(&tasks);
let values: Vec<String> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect();
assert!(values.contains(&"build".to_string()));
assert!(
!values.contains(&"package.json:build".to_string()),
"package.json must remain swallowed even when other runners share the name"
);
assert!(
values.contains(&"make:build".to_string()),
"Makefile is a real definition, not a passthrough — keep its qualified form"
);
assert!(
values.contains(&"turbo:build".to_string()),
"turbo.json must keep a qualified form to disambiguate from Makefile"
);
}
#[test]
fn real_package_json_script_keeps_qualified_form_alongside_turbo() {
let tasks = vec![
task("build", TaskSource::PackageJson),
task("build", TaskSource::TurboJson),
];
let candidates = task_candidates_from(&tasks);
let values: Vec<String> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect();
assert!(values.contains(&"build".to_string()));
assert!(
values.contains(&"package.json:build".to_string()),
"a real package.json script must surface its qualified form for disambiguation"
);
assert!(
values.contains(&"turbo:build".to_string()),
"the turbo.json source must surface its qualified form when a real twin exists"
);
}
#[test]
fn passthrough_without_turbo_twin_stays_visible() {
let tasks = vec![turbo_passthrough("build")];
let candidates = task_candidates_from(&tasks);
assert!(
candidates
.iter()
.any(|c| c.get_value().to_string_lossy() == "build"),
"without a turbo.json twin, the passthrough is the only source — keep it"
);
}
#[test]
fn alias_candidate_uses_arrow_help_and_dedicated_tag() {
let tasks = vec![
Task {
description: Some("Build the project".into()),
..task("build", TaskSource::Justfile)
},
Task {
alias_of: Some("build".into()),
..task("b", TaskSource::Justfile)
},
];
let candidates = task_candidates_from(&tasks);
let alias = candidates
.iter()
.find(|c| c.get_value() == "b")
.expect("alias candidate b should be emitted");
let help = alias
.get_help()
.expect("alias candidate should carry help text")
.to_string();
assert_eq!(help, "→ build");
let tag = alias
.get_tag()
.expect("alias candidate should carry a tag")
.to_string();
assert_eq!(tag, "just (aliases)");
let recipe = candidates
.iter()
.find(|c| c.get_value() == "build")
.expect("recipe candidate build should be emitted");
let recipe_tag = recipe
.get_tag()
.expect("recipe candidate should carry a tag")
.to_string();
assert_eq!(recipe_tag, "just");
}
#[test]
fn resolve_completion_dir_uses_absolute_runner_dir_env() {
let dir = resolve_completion_dir(
Path::new("/tmp/workspace"),
None,
Some(OsStr::new("/tmp/runner-target")),
);
assert_eq!(dir, PathBuf::from("/tmp/runner-target"));
}
#[test]
fn resolve_completion_dir_prefers_cli_over_env() {
let dir = resolve_completion_dir(
Path::new("/tmp/workspace"),
Some(OsStr::new("/cli-target")),
Some(OsStr::new("/env-target")),
);
assert_eq!(dir, PathBuf::from("/cli-target"));
}
#[test]
fn cli_dir_from_argv_parses_space_separated_form_with_clap_complete_harness() {
let argv = vec![
OsString::from("/path/to/runner"),
OsString::from("--"),
OsString::from("runner"),
OsString::from("--dir"),
OsString::from("/repo"),
OsString::from("build"),
OsString::from(""),
];
assert_eq!(
cli_dir_from_argv(&argv).as_deref(),
Some(OsStr::new("/repo"))
);
}
#[test]
fn cli_dir_from_argv_parses_space_separated_form_without_separator() {
let argv = vec![
OsString::from("runner"),
OsString::from("--dir"),
OsString::from("/repo"),
OsString::from("build"),
];
assert_eq!(
cli_dir_from_argv(&argv).as_deref(),
Some(OsStr::new("/repo"))
);
}
#[test]
fn cli_dir_from_argv_parses_equals_form() {
let argv = vec![
OsString::from("runner"),
OsString::from("--dir=/repo"),
OsString::from("build"),
];
assert_eq!(
cli_dir_from_argv(&argv).as_deref(),
Some(OsStr::new("/repo"))
);
}
#[test]
fn cli_dir_from_argv_last_occurrence_wins() {
let argv = vec![
OsString::from("runner"),
OsString::from("--dir"),
OsString::from("/first"),
OsString::from("--dir=/second"),
];
assert_eq!(
cli_dir_from_argv(&argv).as_deref(),
Some(OsStr::new("/second"))
);
}
#[test]
fn cli_dir_from_argv_returns_none_without_flag() {
let argv = vec![OsString::from("runner"), OsString::from("build")];
assert_eq!(cli_dir_from_argv(&argv), None);
}
#[test]
fn run_accepts_sequential_chain_flag() {
let cli = Cli::try_parse_from(["runner", "run", "-s", "build", "test"]).expect("parses");
let Some(Command::Run {
task, args, mode, ..
}) = cli.command
else {
panic!("expected Run subcommand");
};
assert!(mode.sequential, "-s should set sequential");
assert!(!mode.parallel, "-p should not be set");
assert_eq!(task.as_deref(), Some("build"));
assert_eq!(args, vec!["test".to_string()]);
}
#[test]
fn run_rejects_sequential_and_parallel_together() {
let err =
Cli::try_parse_from(["runner", "run", "-s", "-p", "build"]).expect_err("conflict");
let msg = format!("{err}");
assert!(msg.contains("--parallel") || msg.contains("--sequential"));
}
#[test]
fn run_rejects_keep_going_and_kill_on_fail_together() {
let err = Cli::try_parse_from([
"runner",
"run",
"-s",
"-k",
"--kill-on-fail",
"build",
"test",
])
.expect_err("conflict");
let msg = format!("{err}");
assert!(msg.contains("--keep-going") || msg.contains("--kill-on-fail"));
}
#[test]
fn list_rejects_conflicting_output_modes() {
let err = Cli::try_parse_from(["runner", "list", "--raw", "--json"])
.expect_err("list output modes must conflict");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn info_subcommand_still_parses_but_is_hidden() {
Cli::try_parse_from(["runner", "info"]).expect("`runner info` still parses");
Cli::try_parse_from(["runner", "info", "--json"])
.expect("`runner info --json` still parses");
let help = Cli::command().render_long_help().to_string();
assert!(
!help.contains("\n info"),
"hidden `info` subcommand must not appear in --help, got:\n{help}",
);
}
#[test]
fn schema_version_rejects_out_of_range_values() {
let err = Cli::try_parse_from(["runner", "--schema-version", "99", "info"])
.expect_err("schema version should be bounded by clap");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn run_alias_parses_chain_flags_too() {
let cli = RunAliasCli::try_parse_from(["run", "-p", "lint", "test"]).expect("parses");
assert!(cli.mode.parallel);
assert!(!cli.mode.sequential);
assert_eq!(cli.task.as_deref(), Some("lint"));
assert_eq!(cli.args, vec!["test".to_string()]);
}
#[test]
fn install_accepts_task_list() {
let cli = Cli::try_parse_from(["runner", "install", "build", "test"]).expect("parses");
let Some(Command::Install {
tasks,
frozen,
failure,
..
}) = cli.command
else {
panic!("expected Install subcommand");
};
assert!(!frozen);
assert!(!failure.keep_going);
assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]);
}
#[test]
fn install_accepts_keep_going_flag() {
let cli = Cli::try_parse_from(["runner", "install", "-k", "build"]).expect("parses");
let Some(Command::Install { tasks, failure, .. }) = cli.command else {
panic!("expected Install subcommand");
};
assert!(failure.keep_going);
assert_eq!(tasks, vec!["build".to_string()]);
}
}
#[derive(Debug, Parser)]
#[command(
name = "runner",
about = clap::crate_description!(),
help_template = "{about-with-newline}{before-help}{usage-heading} {usage}\n\n{all-args}{after-help}",
version,
styles = HELP_STYLES,
arg_required_else_help = false,
add = SubcommandCandidates::new(task_candidates)
)]
pub(crate) struct Cli {
#[command(flatten)]
pub global: GlobalOpts,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Args)]
pub(crate) struct GlobalOpts {
#[arg(
long = "dir",
global = true,
env = "RUNNER_DIR",
value_name = "PATH",
value_hint = clap::ValueHint::DirPath,
value_parser = clap::value_parser!(PathBuf)
)]
pub project_dir: Option<PathBuf>,
#[arg(
long = "pm",
global = true,
value_name = "NAME",
help = PM_HELP.as_str(),
)]
pub pm_override: Option<String>,
#[arg(
long = "runner",
global = true,
value_name = "NAME",
help = RUNNER_HELP.as_str(),
)]
pub runner_override: Option<String>,
#[arg(
long = "fallback",
global = true,
value_name = "POLICY",
help = concat!(
"What to do when no detection signal matches: ",
cyan!("probe"), " (default, PATH probe), ",
cyan!("npm"), " (legacy silent fallback), ",
cyan!("error"), " (refuse). Also reads ",
cyan!("RUNNER_FALLBACK"), " when omitted."
),
)]
pub fallback: Option<String>,
#[arg(
long = "on-mismatch",
global = true,
value_name = "POLICY",
help = concat!(
"What to do when the manifest declaration disagrees with the lockfile: ",
cyan!("warn"), " (default), ",
cyan!("error"), " (exit 2), ",
cyan!("ignore"), " (silent). Also reads ",
cyan!("RUNNER_ON_MISMATCH"), " when omitted."
),
)]
pub on_mismatch: Option<String>,
#[arg(
long = "explain",
global = true,
help = concat!(
"Print a one-line trace describing how the package manager was resolved. \
Also enabled when ", cyan!("RUNNER_EXPLAIN"), " is set to a truthy value."
),
)]
pub explain: bool,
#[arg(
long = "no-warnings",
global = true,
help = concat!(
"Suppress all non-fatal warnings on stderr. Also enabled when ",
cyan!("RUNNER_NO_WARNINGS"), " is set to a truthy value."
),
)]
pub no_warnings: bool,
#[arg(
long = "schema-version",
global = true,
value_parser = clap::value_parser!(u32).range(1..=2),
value_name = "N",
help = concat!(
"Pin JSON output schema version (",
cyan!("1"), " or ", cyan!("2"), "). Defaults to latest. Affects ",
cyan!("--json"), " output of doctor/list/why only."
),
)]
pub schema_version: Option<u32>,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
#[command(alias = "r")]
Run {
#[arg(add = ArgValueCandidates::new(task_candidates))]
task: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
#[command(flatten)]
mode: ChainModeFlags,
#[command(flatten)]
failure: ChainFailureFlags,
},
#[command(alias = "i")]
Install {
#[arg(long)]
frozen: bool,
#[arg(add = ArgValueCandidates::new(task_candidates))]
tasks: Vec<String>,
#[command(flatten)]
failure: ChainFailureFlags,
},
Clean {
#[arg(short, long)]
yes: bool,
#[arg(long)]
include_framework: bool,
},
#[command(alias = "ls")]
List {
#[arg(long, conflicts_with_all = ["json"])]
raw: bool,
#[arg(long, conflicts_with_all = ["raw"])]
json: bool,
#[arg(long, value_name = "SOURCE")]
source: Option<String>,
},
#[command(hide = true)]
Info {
#[arg(long)]
json: bool,
},
Doctor {
#[arg(long)]
json: bool,
},
Why {
task: String,
#[arg(long)]
json: bool,
},
Completions {
#[arg(value_parser = crate::cmd::parse_shell_arg)]
shell: Option<Shell>,
#[arg(
short = 'o',
long = "output",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
value_parser = clap::value_parser!(PathBuf),
)]
output: Option<PathBuf>,
},
#[command(external_subcommand)]
External(Vec<String>),
}
#[derive(Debug, Parser)]
#[command(
name = "run",
about = "Run a project task or exec a command through the detected package manager",
help_template = "{about-with-newline}{before-help}{usage-heading} {usage}\n\n{all-args}{after-help}",
version,
styles = HELP_STYLES,
arg_required_else_help = false
)]
pub(crate) struct RunAliasCli {
#[command(flatten)]
pub global: GlobalOpts,
#[arg(add = ArgValueCandidates::new(task_candidates))]
pub task: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
#[command(flatten)]
pub mode: ChainModeFlags,
#[command(flatten)]
pub failure: ChainFailureFlags,
}
#[derive(Debug, Args, Default, Clone, Copy)]
pub(crate) struct ChainModeFlags {
#[arg(short = 's', long, conflicts_with = "parallel")]
pub sequential: bool,
#[arg(short = 'p', long)]
pub parallel: bool,
}
#[derive(Debug, Args, Default, Clone, Copy)]
pub(crate) struct ChainFailureFlags {
#[arg(short = 'k', long, conflicts_with = "kill_on_fail")]
pub keep_going: bool,
#[arg(long, conflicts_with = "keep_going")]
pub kill_on_fail: bool,
}