use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, bail};
use colored::Colorize;
use crate::resolver::{ResolutionOverrides, ResolveError, Resolver};
use crate::tool;
use crate::types::{PackageManager, ProjectContext, TaskSource};
fn parse_qualified_task(input: &str) -> (Option<TaskSource>, &str) {
if let Some(colon) = input.find(':') {
let prefix = &input[..colon];
if let Some(source) = TaskSource::from_label(prefix) {
return (Some(source), &input[colon + 1..]);
}
}
(None, input)
}
pub(crate) fn run(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
task: &str,
args: &[String],
) -> Result<i32> {
super::print_warnings(ctx, overrides);
let (qualifier, task_name) = parse_qualified_task(task);
let found: Vec<_> = ctx.tasks.iter().filter(|t| t.name == task_name).collect();
let restricted: Vec<_> = if qualifier.is_some() {
found.clone()
} else if let Some(allowed) = allowed_runner_sources(overrides) {
found
.iter()
.copied()
.filter(|t| allowed.contains(&t.source))
.collect()
} else {
found.clone()
};
if restricted.is_empty() {
if let Some(reason) = runner_constraint_error(overrides, &found) {
return Err(reason.into());
}
if qualifier.is_none() {
let resolved_pm = match Resolver::new(ctx, overrides).resolve_node_pm() {
Ok(decision) => {
super::print_warning_slice(&decision.warnings, overrides);
if overrides.explain {
eprintln!(
"{} {} resolved: {}",
"·".dimmed(),
"runner".dimmed(),
decision.describe(),
);
}
Some(decision.pm)
}
Err(ResolveError::NoSignalsFound { soft: true, .. }) => None,
Err(e) => return Err(e.into()),
};
if let Some(code) = run_bun_test_fallback(ctx, resolved_pm, task_name, args)? {
return Ok(code);
}
return run_pm_exec_fallback(ctx, resolved_pm, task_name, args);
}
bail!("task {task:?} not found. Run `runner list` to see available tasks.");
}
let entry = if let Some(source) = qualifier {
restricted
.iter()
.find(|t| t.source == source)
.copied()
.ok_or_else(|| anyhow::anyhow!("task {task_name:?} not found in {}", source.label()))?
} else {
select_task_entry(ctx, overrides, &restricted)
};
eprintln!(
"{} {} {} {}",
"→".dimmed(),
entry.source.label().dimmed(),
task_name.bold(),
args.join(" ").dimmed(),
);
let mut cmd = build_run_command(ctx, overrides, entry.source, task_name, args)?;
super::configure_command(&mut cmd, &ctx.root);
Ok(super::exit_code(cmd.status()?))
}
fn allowed_runner_sources(
overrides: &ResolutionOverrides,
) -> Option<std::collections::HashSet<TaskSource>> {
use std::collections::HashSet;
if let Some(ovr) = overrides.runner.as_ref() {
return Some(ovr.runner.task_source().into_iter().collect());
}
if !overrides.prefer_runners.is_empty() {
let set: HashSet<_> = overrides
.prefer_runners
.iter()
.filter_map(|r| r.task_source())
.collect();
return Some(set);
}
None
}
fn runner_constraint_error(
overrides: &ResolutionOverrides,
found: &[&crate::types::Task],
) -> Option<ResolveError> {
if let Some(ovr) = overrides.runner.as_ref() {
let label = ovr.runner.label();
if ovr.runner.task_source().is_none() {
return Some(ResolveError::InvalidOverride {
value: label.to_string(),
reason: "no task source is registered for this runner; cannot restrict candidates",
});
}
let reason = if found.is_empty() {
"no task with that name exists in the project"
} else {
"no candidate task is registered under this runner's source"
};
return Some(ResolveError::InvalidOverride {
value: label.to_string(),
reason,
});
}
if !overrides.prefer_runners.is_empty() {
let names = overrides
.prefer_runners
.iter()
.map(|r| r.label())
.collect::<Vec<_>>()
.join(", ");
return Some(ResolveError::InvalidOverride {
value: format!("[{names}]"),
reason: "[task_runner].prefer matched no candidate task source",
});
}
None
}
pub(crate) fn select_task_entry<'a>(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
found: &[&'a crate::types::Task],
) -> &'a crate::types::Task {
found
.iter()
.min_by_key(|task| {
(
source_priority(overrides, task.source),
source_depth(ctx, task.source),
task.source.display_order(),
task.alias_of.is_some(),
)
})
.copied()
.expect("task selection should have at least one match")
}
pub(crate) fn source_priority(overrides: &ResolutionOverrides, source: TaskSource) -> u16 {
let default_tier: u16 = match source {
TaskSource::TurboJson => 0,
TaskSource::PackageJson => 1,
_ => 2,
};
if overrides.prefer_runners.is_empty() {
return default_tier;
}
if let Some(idx) = overrides
.prefer_runners
.iter()
.position(|r| r.task_source() == Some(source))
{
return u16::try_from(idx).unwrap_or(u16::MAX);
}
u16::try_from(overrides.prefer_runners.len()).unwrap_or(u16::MAX) + default_tier
}
pub(crate) fn source_depth(ctx: &ProjectContext, source: TaskSource) -> usize {
source_dir(source, &ctx.root)
.and_then(|dir| {
ctx.root
.ancestors()
.position(|ancestor| ancestor == dir.as_path())
})
.unwrap_or(usize::MAX)
}
fn source_dir(source: TaskSource, root: &Path) -> Option<PathBuf> {
let path = match source {
TaskSource::PackageJson => tool::node::find_manifest_upwards(root),
TaskSource::DenoJson => tool::deno::find_config_upwards(root),
TaskSource::CargoAliases => tool::cargo_aliases::find_anchor(root),
TaskSource::TurboJson => tool::files::find_first_upwards(root, tool::turbo::FILENAMES),
TaskSource::Makefile => tool::files::find_first_upwards(root, tool::make::FILENAMES),
TaskSource::Justfile => tool::files::find_first_upwards(root, tool::just::FILENAMES),
TaskSource::Taskfile => tool::files::find_first_upwards(root, tool::go_task::FILENAMES),
TaskSource::BaconToml => tool::files::find_first_upwards(root, tool::bacon::FILENAMES),
TaskSource::MiseToml => tool::files::find_first_upwards(root, tool::mise::FILENAMES),
};
path.and_then(|path| path.parent().map(Path::to_path_buf))
}
fn run_pm_exec_fallback(
ctx: &ProjectContext,
resolved_pm: Option<PackageManager>,
target: &str,
args: &[String],
) -> Result<i32> {
let combined = || {
let mut v = Vec::with_capacity(args.len() + 1);
v.push(target.to_string());
v.extend(args.iter().cloned());
v
};
let (label, mut cmd) = match resolved_pm {
Some(PackageManager::Npm) => ("npm", tool::npm::exec_cmd(&combined())),
Some(PackageManager::Yarn) => ("yarn", tool::yarn::exec_cmd(&ctx.root, &combined())),
Some(PackageManager::Pnpm) => ("pnpm", tool::pnpm::exec_cmd(&combined())),
Some(PackageManager::Bun) => ("bun", tool::bun::exec_cmd(&combined())),
Some(PackageManager::Deno) => ("deno x", tool::deno::exec_cmd(&combined())),
Some(PackageManager::Uv) => ("uvx", tool::uv::exec_cmd(&combined())),
Some(PackageManager::Go) => ("go run", tool::go_pm::exec_cmd(&combined())),
None | Some(_) => {
let mut c = tool::program::command(target);
c.args(args);
("exec", c)
}
};
eprintln!(
"{} {} {} {}",
"→".dimmed(),
label.dimmed(),
target.bold(),
args.join(" ").dimmed(),
);
super::configure_command(&mut cmd, &ctx.root);
Ok(super::exit_code(cmd.status()?))
}
fn run_bun_test_fallback(
ctx: &ProjectContext,
resolved_pm: Option<PackageManager>,
task: &str,
args: &[String],
) -> Result<Option<i32>> {
if !should_use_bun_test_fallback(ctx, resolved_pm, task) {
return Ok(None);
}
eprintln!(
"{} {} {} {}",
"→".dimmed(),
"bun".dimmed(),
"test".bold(),
args.join(" ").dimmed(),
);
let mut cmd = tool::bun::test_cmd(args);
super::configure_command(&mut cmd, &ctx.root);
Ok(Some(super::exit_code(cmd.status()?)))
}
fn should_use_bun_test_fallback(
ctx: &ProjectContext,
resolved_pm: Option<PackageManager>,
task: &str,
) -> bool {
if task != "test" || has_package_script(ctx, task) {
return false;
}
resolved_pm.is_some_and(|pm| pm == PackageManager::Bun)
}
fn has_package_script(ctx: &ProjectContext, task: &str) -> bool {
ctx.tasks
.iter()
.any(|entry| entry.source == TaskSource::PackageJson && entry.name == task)
}
fn build_run_command(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
source: TaskSource,
task: &str,
args: &[String],
) -> Result<Command> {
Ok(match source {
TaskSource::TurboJson => tool::turbo::run_cmd(task, args),
TaskSource::PackageJson => {
let decision = Resolver::new(ctx, overrides).resolve_node_pm()?;
super::print_warning_slice(&decision.warnings, overrides);
if overrides.explain {
eprintln!(
"{} {} resolved: {}",
"·".dimmed(),
"runner".dimmed(),
decision.describe(),
);
}
let pm = decision.pm;
match pm {
PackageManager::Npm => tool::npm::run_cmd(task, args),
PackageManager::Yarn => tool::yarn::run_cmd(task, args),
PackageManager::Pnpm => tool::pnpm::run_cmd(task, args),
PackageManager::Bun => tool::bun::run_cmd(task, args),
PackageManager::Deno => tool::deno::run_cmd(task, args),
other => bail!("{} cannot run scripts", other.label()),
}
}
TaskSource::Makefile => tool::make::run_cmd(task, args),
TaskSource::Justfile => tool::just::run_cmd(task, args),
TaskSource::Taskfile => tool::go_task::run_cmd(task, args),
TaskSource::DenoJson => tool::deno::run_cmd(task, args),
TaskSource::CargoAliases => tool::cargo_aliases::run_cmd(task, args),
TaskSource::BaconToml => tool::bacon::run_cmd(task, args),
TaskSource::MiseToml => tool::mise::run_cmd(task, args),
})
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use super::{parse_qualified_task, select_task_entry, should_use_bun_test_fallback};
use crate::resolver::ResolutionOverrides;
use crate::tool::test_support::TempDir;
use crate::types::{PackageManager, ProjectContext, Task, TaskSource};
#[test]
fn parse_qualified_task_splits_source_and_name() {
let (source, name) = parse_qualified_task("justfile:fmt");
assert_eq!(source, Some(TaskSource::Justfile));
assert_eq!(name, "fmt");
}
#[test]
fn parse_qualified_task_returns_bare_name() {
let (source, name) = parse_qualified_task("build");
assert_eq!(source, None);
assert_eq!(name, "build");
}
#[test]
fn parse_qualified_task_handles_unknown_source() {
let (source, name) = parse_qualified_task("unknown:build");
assert_eq!(source, None);
assert_eq!(name, "unknown:build");
}
#[test]
fn parse_qualified_task_with_colons_in_task_name() {
let (source, name) = parse_qualified_task("package.json:helix:sync");
assert_eq!(source, Some(TaskSource::PackageJson));
assert_eq!(name, "helix:sync");
}
#[test]
fn parse_qualified_task_preserves_colons_in_bare_name() {
let (source, name) = parse_qualified_task("helix:sync");
assert_eq!(source, None);
assert_eq!(name, "helix:sync");
}
#[test]
fn parse_qualified_task_accepts_turbo_jsonc_qualifier() {
let (source, name) = parse_qualified_task("turbo.jsonc:build");
assert_eq!(source, Some(TaskSource::TurboJson));
assert_eq!(name, "build");
}
#[test]
fn parse_qualified_task_accepts_deno_jsonc_qualifier() {
let (source, name) = parse_qualified_task("deno.jsonc:test");
assert_eq!(source, Some(TaskSource::DenoJson));
assert_eq!(name, "test");
}
#[test]
fn parse_qualified_task_accepts_bacon_toml_qualifier() {
let (source, name) = parse_qualified_task("bacon.toml:check");
assert_eq!(source, Some(TaskSource::BaconToml));
assert_eq!(name, "check");
}
#[test]
fn bun_test_fallback_enabled_when_resolved_to_bun() {
let ctx = context(vec![PackageManager::Bun], vec![]);
assert!(should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Bun),
"test"
));
}
#[test]
fn bun_test_fallback_disabled_when_test_script_exists() {
let ctx = context(
vec![PackageManager::Bun],
vec![Task {
name: "test".to_string(),
source: TaskSource::PackageJson,
description: None,
alias_of: None,
passthrough_to: None,
}],
);
assert!(!should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Bun),
"test"
));
}
#[test]
fn bun_test_fallback_disabled_for_other_package_managers() {
let ctx = context(vec![PackageManager::Npm], vec![]);
assert!(!should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Npm),
"test"
));
}
#[test]
fn bun_test_fallback_disabled_for_non_test_task() {
let ctx = context(vec![PackageManager::Bun], vec![]);
assert!(!should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Bun),
"build"
));
}
#[test]
fn bun_test_fallback_suppressed_when_resolver_returns_non_bun() {
let ctx = context(vec![PackageManager::Bun], vec![]);
assert!(!should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Npm),
"test"
));
}
#[test]
fn bun_test_fallback_disabled_when_resolver_returns_none() {
let ctx = context(vec![PackageManager::Bun], vec![]);
assert!(!should_use_bun_test_fallback(&ctx, None, "test"));
}
#[test]
fn bun_test_fallback_enabled_when_resolver_picks_bun_with_no_lockfile() {
let ctx = context(vec![], vec![]);
assert!(should_use_bun_test_fallback(
&ctx,
Some(PackageManager::Bun),
"test"
));
}
#[test]
fn source_depth_walks_upward_for_non_node_sources() {
let dir = TempDir::new("source-depth-upward");
let nested = dir.path().join("apps").join("api");
fs::create_dir_all(&nested).expect("nested dir should be created");
fs::write(dir.path().join("Makefile"), "build:\n\techo build\n")
.expect("root Makefile should be written");
let ctx = ProjectContext {
root: nested,
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};
let depth = super::source_depth(&ctx, TaskSource::Makefile);
assert_ne!(depth, usize::MAX, "Makefile two levels up should resolve");
}
#[test]
fn select_task_entry_prefers_package_json_over_deno_json() {
let dir = TempDir::new("run-deno-nearest");
let nested = dir.path().join("apps").join("site").join("src");
fs::create_dir_all(&nested).expect("nested dir should be created");
fs::write(
dir.path().join("deno.jsonc"),
r#"{ tasks: { build: "deno task build" } }"#,
)
.expect("root deno.jsonc should be written");
fs::write(
dir.path().join("apps").join("site").join("package.json"),
r#"{ "scripts": { "build": "deno task build" } }"#,
)
.expect("member package.json should be written");
let ctx = ProjectContext {
root: nested,
package_managers: vec![PackageManager::Deno],
task_runners: Vec::new(),
tasks: vec![
Task {
name: "build".to_string(),
source: TaskSource::DenoJson,
description: None,
alias_of: None,
passthrough_to: None,
},
Task {
name: "build".to_string(),
source: TaskSource::PackageJson,
description: None,
alias_of: None,
passthrough_to: None,
},
],
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};
let found: Vec<_> = ctx.tasks.iter().collect();
let overrides = ResolutionOverrides::default();
let entry = select_task_entry(&ctx, &overrides, &found);
assert_eq!(entry.source, TaskSource::PackageJson);
}
fn context(package_managers: Vec<PackageManager>, tasks: Vec<Task>) -> ProjectContext {
ProjectContext {
root: PathBuf::from("."),
package_managers,
task_runners: Vec::new(),
tasks,
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
fn task(name: &str, source: TaskSource) -> Task {
Task {
name: name.to_string(),
source,
description: None,
alias_of: None,
passthrough_to: None,
}
}
#[test]
fn prefer_runners_reorders_default_tier() {
let ctx = context(
vec![],
vec![
task("build", TaskSource::TurboJson),
task("build", TaskSource::Justfile),
],
);
let found: Vec<_> = ctx.tasks.iter().collect();
let overrides = ResolutionOverrides {
prefer_runners: vec![crate::types::TaskRunner::Just],
..ResolutionOverrides::default()
};
let entry = select_task_entry(&ctx, &overrides, &found);
assert_eq!(entry.source, TaskSource::Justfile);
}
#[test]
fn runner_override_promotes_just_over_turbo() {
let ctx = context(
vec![],
vec![
task("build", TaskSource::TurboJson),
task("build", TaskSource::Justfile),
],
);
let found: Vec<&Task> = ctx
.tasks
.iter()
.filter(|t| t.source == TaskSource::Justfile)
.collect();
let overrides = ResolutionOverrides::default();
let entry = select_task_entry(&ctx, &overrides, &found);
assert_eq!(entry.source, TaskSource::Justfile);
}
}