use std::path::{Path, PathBuf};
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};
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")
};
}
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 super::{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,
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(&"Makefile: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(&"Makefile:build".to_string()),
"Makefile is a real definition, not a passthrough — keep its qualified form"
);
assert!(
values.contains(&"turbo.json: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.json: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, "justfile (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, "justfile");
}
#[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);
}
}
#[derive(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 = concat!(
"Override the detected package manager (e.g. ",
cyan!("pnpm"), ", ", cyan!("bun"), ", ", cyan!("yarn"),
"). Also reads ", cyan!("RUNNER_PM"), " when omitted."
),
)]
pub pm_override: Option<String>,
#[arg(
long = "runner",
global = true,
value_name = "NAME",
help = concat!(
"Override the detected task runner (e.g. ",
cyan!("just"), ", ", cyan!("turbo"), ", ", cyan!("make"),
"). Also reads ", cyan!("RUNNER_RUNNER"), " when omitted."
),
)]
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,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
#[command(alias = "r")]
Run {
#[arg(add = ArgValueCandidates::new(task_candidates))]
task: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(alias = "i")]
Install {
#[arg(long)]
frozen: bool,
},
Clean {
#[arg(short, long)]
yes: bool,
#[arg(long)]
include_framework: bool,
},
#[command(alias = "ls")]
List {
#[arg(long)]
raw: bool,
#[arg(long)]
json: bool,
#[arg(long, value_name = "SOURCE")]
source: Option<String>,
},
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>,
}