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")
}
fn env_suffix(var: &str) -> String {
format!("[env: {}]", cyan_str(var))
}
static DIR_HELP: LazyLock<String> = LazyLock::new(|| {
format!(
"Project directory (default: cwd) {}",
env_suffix("RUNNER_DIR")
)
});
static PM_HELP: LazyLock<String> = LazyLock::new(|| {
let joined = PackageManager::all()
.iter()
.map(|pm| pm.label())
.collect::<Vec<_>>()
.join(", ");
format!("Force PM ({joined}) {}", env_suffix("RUNNER_PM"))
});
static RUNNER_HELP: LazyLock<String> = LazyLock::new(|| {
let joined = TaskRunner::all()
.iter()
.map(|r| r.label())
.collect::<Vec<_>>()
.join(", ");
format!(
"Force task runner ({joined}) {}",
env_suffix("RUNNER_RUNNER")
)
});
const ALIAS_DISPLAY_ORDER_OFFSET: usize = 100;
mod help_order {
pub(super) const DIR: usize = 10;
pub(super) const COMMAND: usize = 20;
pub(super) const CHAIN_MODE: usize = 30;
pub(super) const CHAIN_FAILURE: usize = 40;
pub(super) const PM: usize = 100;
pub(super) const RUNNER: usize = 101;
pub(super) const FALLBACK: usize = 102;
pub(super) const ON_MISMATCH: usize = 103;
pub(super) const EXPLAIN: usize = 200;
pub(super) const NO_WARNINGS: usize = 201;
pub(super) const QUIET: usize = 202;
pub(super) const SCHEMA_VERSION: usize = 203;
}
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 global_value_flags() -> Vec<String> {
use clap::CommandFactory as _;
Cli::command()
.get_arguments()
.filter(|arg| !arg.is_positional() && arg.get_action().takes_values())
.flat_map(|arg| {
arg.get_long()
.map(|long| format!("--{long}"))
.into_iter()
.chain(arg.get_short().map(|short| format!("-{short}")))
})
.collect()
}
fn chain_args_candidates() -> Vec<CompletionCandidate> {
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
if !chain_flag_precedes_first_task(&argv) {
return vec![];
}
task_candidates()
}
fn chain_flag_precedes_first_task(argv: &[std::ffi::OsString]) -> bool {
let value_flags = global_value_flags();
let start = argv.iter().position(|a| a == "--").map_or(1, |idx| idx + 1);
let mut iter = argv.get(start + 1..).unwrap_or(&[]).iter();
let mut subcommand_seen = false;
while let Some(arg) = iter.next() {
let Some(word) = arg.to_str() else {
continue;
};
match word {
"-s" | "--sequential" | "-p" | "--parallel" => return true,
"run" | "r" if !subcommand_seen => subcommand_seen = true,
_ if value_flags.iter().any(|flag| flag == word) => {
iter.next();
}
_ if word.starts_with('-') && !word.starts_with("--") => {
if word.chars().skip(1).any(|c| c == 's' || c == 'p') {
return true;
}
}
_ if word.starts_with('-') => {}
_ => return false,
}
}
false
}
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 no_overrides = crate::resolver::ResolutionOverrides::default();
let bare_rank = |task: &crate::types::Task| {
(
crate::cmd::run::source_priority(&no_overrides, task.source),
task.source.display_order(),
task.alias_of.is_some(),
)
};
let mut bare_winner: HashMap<&str, usize> = HashMap::new();
for (idx, task) in tasks.iter().enumerate() {
if is_self_passthrough(task) {
continue;
}
let is_better = match bare_winner.get(task.name.as_str()) {
Some(&best) => bare_rank(task) < bare_rank(&tasks[best]),
None => true,
};
if is_better {
bare_winner.insert(task.name.as_str(), idx);
}
}
let mut candidates = Vec::new();
for (idx, task) in tasks.iter().enumerate() {
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 bare_winner.get(task.name.as_str()) == Some(&idx) {
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::{
ChainFailureFlags, Cli, Command, RunAliasCli, chain_flag_precedes_first_task,
cli_dir_from_argv, resolve_completion_dir, task_candidates_from,
};
fn osv(words: &[&str]) -> Vec<OsString> {
words.iter().map(OsString::from).collect()
}
#[test]
fn global_value_flags_reflect_clap_definition() {
let flags = super::global_value_flags();
for expected in ["--dir", "--pm", "--runner", "--fallback", "--on-mismatch"] {
assert!(
flags.iter().any(|f| f == expected),
"expected {expected} in derived value flags: {flags:?}",
);
}
assert!(
!flags.iter().any(|f| f == "--quiet"),
"boolean flags take no value and must not be skipped-with-value: {flags:?}",
);
}
#[test]
fn chain_flag_detected_before_first_task() {
assert!(chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"runner",
"run",
"-s",
"build",
""
])));
assert!(chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"runner",
"--dir",
"/repo",
"run",
"--parallel",
"build",
""
])));
assert!(chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"run",
"-p",
"build",
""
])));
assert!(chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"run",
"-sk",
"build",
""
])));
}
#[test]
fn chain_flag_after_first_task_is_forwarded_not_chain() {
assert!(!chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"run",
"build",
"-p",
"3000",
""
])));
assert!(!chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"runner",
"run",
"build",
""
])));
assert!(!chain_flag_precedes_first_task(&osv(&[
"completer",
"--",
"runner",
"run",
""
])));
}
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 bare_label_follows_dispatch_priority_not_detection_order() {
let tasks = vec![
task("build", TaskSource::PackageJson),
task("build", TaskSource::TurboJson),
];
let candidates = task_candidates_from(&tasks);
let bare = candidates
.iter()
.find(|c| c.get_value().to_string_lossy() == "build")
.expect("bare 'build' candidate must exist");
assert_eq!(
bare.get_tag().map(ToString::to_string).as_deref(),
Some("turbo"),
"bare label must name the dispatch-winning source (turbo), not the detection-order \
first source (package.json)"
);
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()));
assert!(values.contains(&"turbo:build".to_string()));
}
#[test]
fn bare_label_skips_suppressed_passthrough_and_ranks_real_sources() {
let tasks = vec![
turbo_passthrough("build"),
task("build", TaskSource::Makefile),
task("build", TaskSource::TurboJson),
];
let candidates = task_candidates_from(&tasks);
let bare = candidates
.iter()
.find(|c| c.get_value().to_string_lossy() == "build")
.expect("bare 'build' candidate must exist");
assert_eq!(
bare.get_tag().map(ToString::to_string).as_deref(),
Some("turbo"),
"bare label must name the rank-winning real source, not the suppressed passthrough \
source"
);
let values: Vec<String> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect();
assert!(
!values.contains(&"package.json:build".to_string()),
"the suppressed passthrough must not surface a qualified form"
);
assert!(values.contains(&"make:build".to_string()));
assert!(values.contains(&"turbo:build".to_string()));
}
#[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", "-K", "build", "test"])
.expect_err("conflict");
let msg = format!("{err}");
assert!(msg.contains("--keep-going") || msg.contains("--kill-on-fail"));
}
#[test]
fn run_parses_kill_on_fail_short_flag() {
let cli =
Cli::try_parse_from(["runner", "run", "-p", "-K", "build", "test"]).expect("parses");
match cli.command {
Some(Command::Run {
failure:
ChainFailureFlags {
kill_on_fail: true, ..
},
..
}) => {}
other => panic!("expected Run with kill_on_fail=true, got {other:?}"),
}
}
#[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_no_scripts_flag() {
let cli = Cli::try_parse_from(["runner", "install", "--no-scripts"]).expect("parses");
let Some(Command::Install {
no_scripts,
scripts,
..
}) = cli.command
else {
panic!("expected Install subcommand");
};
assert!(no_scripts, "--no-scripts should set the flag");
assert!(!scripts, "--scripts stays off when only --no-scripts given");
}
#[test]
fn install_accepts_scripts_flag() {
let cli = Cli::try_parse_from(["runner", "install", "--scripts"]).expect("parses");
let Some(Command::Install {
no_scripts,
scripts,
..
}) = cli.command
else {
panic!("expected Install subcommand");
};
assert!(scripts, "--scripts should set the flag");
assert!(
!no_scripts,
"--no-scripts stays off when only --scripts given"
);
}
#[test]
fn install_scripts_and_no_scripts_are_mutually_exclusive() {
let err = Cli::try_parse_from(["runner", "install", "--scripts", "--no-scripts"])
.expect_err("--scripts and --no-scripts must conflict");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn install_defaults_both_script_flags_to_false() {
let cli = Cli::try_parse_from(["runner", "install"]).expect("parses");
let Some(Command::Install {
no_scripts,
scripts,
..
}) = cli.command
else {
panic!("expected Install subcommand");
};
assert!(!no_scripts, "--no-scripts should default off");
assert!(!scripts, "--scripts should default off");
}
#[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()]);
}
#[test]
fn install_accepts_parallel_flag() {
let cli =
Cli::try_parse_from(["runner", "install", "build", "test", "-p"]).expect("parses");
let Some(Command::Install { tasks, mode, .. }) = cli.command else {
panic!("expected Install subcommand");
};
assert!(mode.parallel, "-p should set parallel");
assert!(!mode.sequential);
assert_eq!(tasks, vec!["build".to_string(), "test".to_string()]);
}
#[test]
fn install_rejects_sequential_and_parallel_together() {
let err =
Cli::try_parse_from(["runner", "install", "-s", "-p", "build"]).expect_err("conflict");
let msg = format!("{err}");
assert!(msg.contains("--parallel") || msg.contains("--sequential"));
}
}
#[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,
value_name = "PATH",
value_hint = clap::ValueHint::DirPath,
value_parser = clap::value_parser!(PathBuf),
help = DIR_HELP.as_str(),
display_order = help_order::DIR,
)]
pub project_dir: Option<PathBuf>,
#[arg(
long = "pm",
global = true,
value_name = "NAME",
help = PM_HELP.as_str(),
display_order = help_order::PM,
)]
pub pm_override: Option<String>,
#[arg(
long = "runner",
global = true,
value_name = "NAME",
help = RUNNER_HELP.as_str(),
display_order = help_order::RUNNER,
)]
pub runner_override: Option<String>,
#[arg(
long = "fallback",
global = true,
value_name = "POLICY",
display_order = help_order::FALLBACK,
help = concat!(
"No detection match: ",
cyan!("probe"), " (default), ",
cyan!("npm"), ", ",
cyan!("error"), " ",
"[env: ", cyan!("RUNNER_FALLBACK"), "]"
),
)]
pub fallback: Option<String>,
#[arg(
long = "on-mismatch",
global = true,
value_name = "POLICY",
display_order = help_order::ON_MISMATCH,
help = concat!(
"Manifest vs lockfile: ",
cyan!("warn"), " (default), ",
cyan!("error"), " (exit 2), ",
cyan!("ignore"), " ",
"[env: ", cyan!("RUNNER_ON_MISMATCH"), "]"
),
)]
pub on_mismatch: Option<String>,
#[arg(
long = "explain",
global = true,
display_order = help_order::EXPLAIN,
help = concat!(
"PM resolution trace ",
"[env: ", cyan!("RUNNER_EXPLAIN"), "]"
),
)]
pub explain: bool,
#[arg(
long = "no-warnings",
global = true,
display_order = help_order::NO_WARNINGS,
help = concat!(
"Hide non-fatal warnings ",
"[env: ", cyan!("RUNNER_NO_WARNINGS"), "]"
),
)]
pub no_warnings: bool,
#[arg(
short = 'q',
long = "quiet",
global = true,
display_order = help_order::QUIET,
help = concat!(
"Hide dispatch line + ", cyan!("--explain"), " trace ",
"[env: ", cyan!("RUNNER_QUIET"), "]"
),
)]
pub quiet: bool,
#[arg(
long = "schema-version",
global = true,
value_parser = clap::value_parser!(u32).range(1..=1),
value_name = "N",
display_order = help_order::SCHEMA_VERSION,
help = concat!("Pin ", cyan!("--json"), " schema (currently always ", cyan!("1"), ")"),
)]
pub schema_version: Option<u32>,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
#[command(
alias = "r",
about = concat!("Run or exec a task; ", cyan!("-s"), "/", cyan!("-p"), " chain multiple"),
)]
Run {
#[arg(add = ArgValueCandidates::new(task_candidates))]
task: Option<String>,
#[arg(
trailing_var_arg = true,
allow_hyphen_values = true,
add = ArgValueCandidates::new(chain_args_candidates),
)]
args: Vec<String>,
#[command(flatten)]
mode: ChainModeFlags,
#[command(flatten)]
failure: ChainFailureFlags,
},
#[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(
alias = "i",
about = concat!("Install deps; may chain tasks after; ", cyan!("-s"), "/", cyan!("-p"), " pick the post-install mode"),
)]
Install {
#[arg(short = 'f', long, display_order = help_order::COMMAND)]
frozen: bool,
#[arg(long = "no-scripts", display_order = help_order::COMMAND + 1)]
no_scripts: bool,
#[arg(
long = "scripts",
conflicts_with = "no_scripts",
display_order = help_order::COMMAND + 2
)]
scripts: bool,
#[arg(add = ArgValueCandidates::new(task_candidates))]
tasks: Vec<String>,
#[command(flatten)]
mode: ChainModeFlags,
#[command(flatten)]
failure: ChainFailureFlags,
},
Clean {
#[arg(short, long)]
yes: bool,
#[arg(long)]
include_framework: bool,
},
#[command(hide = true)]
Info {
#[arg(long)]
json: bool,
},
Why {
task: String,
#[arg(long)]
json: bool,
},
Doctor {
#[arg(long)]
json: bool,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
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>,
},
#[cfg(feature = "man")]
#[command(hide = true)]
Man {
#[arg(
short = 'o',
long = "output",
value_name = "DIR",
value_hint = clap::ValueHint::DirPath,
value_parser = clap::value_parser!(PathBuf),
)]
output: Option<PathBuf>,
},
#[cfg(feature = "schema")]
#[command(about = "Emit JSON Schemas")]
Schema {
#[arg(long)]
all: bool,
#[arg(
short = 'o',
long = "output",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
value_parser = clap::value_parser!(PathBuf),
)]
output: Option<PathBuf>,
},
#[cfg(feature = "lsp")]
#[command(about = "Run the runner.toml language server (LSP) over stdio")]
Lsp,
#[command(external_subcommand)]
External(Vec<String>),
}
#[derive(Debug, Clone, Copy, Subcommand)]
pub(crate) enum ConfigAction {
Init {
#[arg(short, long)]
force: bool,
},
Show {
#[arg(long)]
json: bool,
},
Validate,
Path,
}
#[derive(Debug, Parser)]
#[command(
name = "run",
about = "Run or exec a task via the detected package manager",
help_template = "{about-with-newline}{before-help}{usage-heading} {usage}\n\n{all-args}{after-help}",
// `-h`/`--help`/`-V`/`--version` are no longer clap args (see the
// disable note below), so document them here instead of in the options
// list, and flag the forwarding rule that distinguishes this binary
// from `runner run`.
after_help = concat!(
"\nUse ", cyan!("-h"), "/", cyan!("--help"), " or ", cyan!("-V"), "/", cyan!("--version"),
" before a task for this binary's own help and version.\n",
"After a task name they are forwarded to the task instead (use ", cyan!("--"), " to force forwarding).",
),
styles = HELP_STYLES,
arg_required_else_help = false,
// clap's built-in `--help`/`--version` short-circuit parsing wherever
// they appear, so `run <task> --help` printed *our* help instead of the
// task's. We disable them and leave `--help`/`--version` undefined: a
// *defined* flag would be consumed by clap even after the task (like
// `-k`), but an *undefined* hyphen token after the first positional is
// swallowed by `args` (`trailing_var_arg`) and forwarded to the task.
// A leading `--help`/`--version` (before any task) instead surfaces as
// an `UnknownArgument` error — `task` takes no hyphen values — which
// `run_alias_in_dir` recognises as this binary's own help/version
// request. `run <task> -- --help` keeps forwarding literally.
disable_help_flag = true,
disable_version_flag = true,
)]
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,
add = ArgValueCandidates::new(chain_args_candidates),
)]
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",
display_order = help_order::CHAIN_MODE,
)]
pub sequential: bool,
#[arg(short = 'p', long, display_order = help_order::CHAIN_MODE + 1)]
pub parallel: bool,
}
#[derive(Debug, Args, Default, Clone, Copy)]
pub(crate) struct ChainFailureFlags {
#[arg(
short = 'k',
long,
conflicts_with = "kill_on_fail",
display_order = help_order::CHAIN_FAILURE,
help = concat!(
"Finish chain despite failures ",
"[env: ", cyan!("RUNNER_KEEP_GOING"), "]"
),
)]
pub keep_going: bool,
#[arg(
short = 'K',
long,
conflicts_with = "keep_going",
display_order = help_order::CHAIN_FAILURE + 1,
help = concat!(
"Parallel: kill siblings on first failure ",
"[env: ", cyan!("RUNNER_KILL_ON_FAIL"), "]"
),
)]
pub kill_on_fail: bool,
}