use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};
use clap_complete::aot::Shell;
use clap_complete::engine::{ArgValueCandidates, CompletionCandidate, SubcommandCandidates};
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()?;
Ok(resolve_completion_dir(
&cwd,
std::env::var_os("RUNNER_DIR").as_deref(),
))
}
fn resolve_completion_dir(cwd: &Path, env_dir: Option<&std::ffi::OsStr>) -> PathBuf {
match env_dir.map(PathBuf::from) {
Some(path) if path.is_absolute() => path,
Some(path) => cwd.join(path),
None => cwd.to_path_buf(),
}
}
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_turbo_passthrough = |task: &crate::types::Task| -> bool {
task.passthrough_to_turbo
&& task.source == TaskSource::PackageJson
&& sources_for_name
.get(task.name.as_str())
.is_some_and(|set| set.contains(&TaskSource::TurboJson))
};
let mut effective_count: HashMap<&str, usize> = HashMap::new();
for task in tasks {
if !is_turbo_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_turbo_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 super::{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_turbo: false,
}
}
fn turbo_passthrough(name: &str) -> Task {
Task {
passthrough_to_turbo: true,
..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"),
Some(OsStr::new("/tmp/runner-target")),
);
assert_eq!(dir, PathBuf::from("/tmp/runner-target"));
}
}
#[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,
arg_required_else_help = false,
add = SubcommandCandidates::new(task_candidates)
)]
pub(crate) struct Cli {
#[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>,
#[command(subcommand)]
pub command: Option<Command>,
}
#[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,
},
Info,
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,
arg_required_else_help = false
)]
pub(crate) struct RunAliasCli {
#[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(add = ArgValueCandidates::new(task_candidates))]
pub task: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
}