use anyhow::Result;
mod dispatch;
mod qualify;
mod select;
pub(crate) use qualify::{allowed_runner_sources, precheck_task, runner_constraint_error};
pub(crate) use select::{select_task_entry, source_depth, source_priority};
use crate::resolver::ResolutionOverrides;
use crate::types::ProjectContext;
pub(crate) fn run(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
task: &str,
args: &[String],
sink: super::WarningSink<'_>,
) -> Result<i32> {
let mut cmd = dispatch::resolve_dispatch(ctx, overrides, task, args, sink)?;
Ok(super::exit_code(cmd.status()?))
}
pub(crate) fn dispatch_task_piped(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
task: &str,
args: &[String],
sink: super::WarningSink<'_>,
) -> Result<std::process::Child> {
use std::process::Stdio;
let mut cmd = dispatch::resolve_dispatch(ctx, overrides, task, args, sink)?;
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(cmd.spawn()?)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use super::dispatch::should_use_bun_test_fallback;
use super::qualify::{detect_reversed_qualifier, parse_qualified_task};
use super::{precheck_task, select_task_entry};
use crate::resolver::ResolutionOverrides;
use crate::tool::test_support::TempDir;
use crate::types::{PackageManager, ProjectContext, Task, TaskRunner, 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 detect_reversed_qualifier_catches_task_colon_source() {
let got = detect_reversed_qualifier("lint:cargo");
assert_eq!(got, Some((TaskSource::CargoAliases, "lint")));
}
#[test]
fn detect_reversed_qualifier_returns_none_for_correct_syntax() {
assert!(detect_reversed_qualifier("cargo:lint").is_none());
assert!(detect_reversed_qualifier("lint").is_none());
assert!(detect_reversed_qualifier("lint:zoot").is_none());
}
#[test]
fn detect_reversed_qualifier_matches_last_colon() {
let got = detect_reversed_qualifier("foo:bar:cargo");
assert_eq!(got, Some((TaskSource::CargoAliases, "foo:bar")));
assert!(detect_reversed_qualifier("lint:cargo:extra").is_none());
}
#[test]
fn precheck_reversed_qualifier_beats_runner_constraint() {
let ctx = context(vec![], vec![]);
let overrides = ResolutionOverrides {
prefer_runners: vec![TaskRunner::Just],
..ResolutionOverrides::default()
};
let err = precheck_task(&ctx, &overrides, "lint:cargo")
.expect_err("reversed qualifier should fail precheck");
assert!(format!("{err:#}").contains("cargo:lint"));
}
#[test]
fn reversed_qualifier_fast_fail_does_not_block_real_tasks() {
let ctx = ProjectContext {
root: PathBuf::from("/tmp/has-quirky-task-name"),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks: vec![Task {
name: "lint:cargo".to_string(),
source: TaskSource::Justfile,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
}],
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};
let (qualifier, task_name) = parse_qualified_task("lint:cargo");
assert_eq!(qualifier, None);
assert_eq!(task_name, "lint:cargo");
let found: Vec<_> = ctx.tasks.iter().filter(|t| t.name == task_name).collect();
assert_eq!(
found.len(),
1,
"real task named `lint:cargo` must be reachable; \
fast-fail only fires when the filter is empty",
);
assert_eq!(found[0].source, TaskSource::Justfile);
}
#[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,
run_target: None,
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 source_depth_treats_subdirectory_config_as_depth_zero() {
let dir = TempDir::new("source-depth-subdirectory");
let cargo_dir = dir.path().join(".cargo");
fs::create_dir_all(&cargo_dir).expect(".cargo dir should be created");
fs::write(
cargo_dir.join("config.toml"),
"[alias]\nlint = \"clippy\"\n",
)
.expect("config.toml should be written");
let ctx = ProjectContext {
root: dir.path().to_path_buf(),
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::CargoAliases);
assert_eq!(
depth, 0,
".cargo/config.toml is a subdir of root → treat as depth 0",
);
}
#[test]
fn cargo_aliases_beats_bacon_toml_for_same_name_task() {
let dir = TempDir::new("priority-cargo-vs-bacon");
let cargo_dir = dir.path().join(".cargo");
fs::create_dir_all(&cargo_dir).expect(".cargo dir should be created");
fs::write(
cargo_dir.join("config.toml"),
"[alias]\nlint = \"clippy\"\n",
)
.expect("config.toml should be written");
fs::write(
dir.path().join("bacon.toml"),
"[jobs.lint]\ncommand = [\"cargo\", \"clippy\"]\n",
)
.expect("bacon.toml should be written");
let tasks = vec![
Task {
name: "lint".to_string(),
source: TaskSource::BaconToml,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
},
Task {
name: "lint".to_string(),
source: TaskSource::CargoAliases,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
},
];
let ctx = ProjectContext {
root: dir.path().to_path_buf(),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks,
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};
let candidates: Vec<&Task> = ctx.tasks.iter().collect();
let entry = select_task_entry(&ctx, &ResolutionOverrides::default(), &candidates);
assert_eq!(
entry.source,
TaskSource::CargoAliases,
"display_order should pick CargoAliases over BaconToml once both hit depth 0",
);
}
#[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,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
},
Task {
name: "build".to_string(),
source: TaskSource::PackageJson,
run_target: None,
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,
run_target: None,
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![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);
}
}