use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context as _;
use serde::Deserialize;
use crate::tool::files;
pub(crate) const CLEAN_DIRS: &[&str] = &[".turbo"];
pub(crate) const FILENAMES: &[&str] = &["turbo.json", "turbo.jsonc"];
pub(crate) fn find_config(dir: &Path) -> Option<PathBuf> {
files::find_first(dir, FILENAMES).filter(|path| path.is_file())
}
pub(crate) fn detect(dir: &Path) -> bool {
find_config(dir).is_some()
}
pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result<Vec<String>> {
#[derive(Deserialize)]
struct Partial {
tasks: Option<HashMap<String, serde_json::Value>>,
pipeline: Option<HashMap<String, serde_json::Value>>,
}
let Some(path) = find_config(dir) else {
return Ok(vec![]);
};
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let p = json5::from_str::<Partial>(&content)
.with_context(|| format!("{} is not valid JSON/JSONC", path.display()))?;
let Some(tasks) = p.tasks.or(p.pipeline) else {
return Ok(vec![]);
};
Ok(tasks
.into_keys()
.filter_map(classify_task_key)
.collect::<HashSet<_>>()
.into_iter()
.collect())
}
fn classify_task_key(name: String) -> Option<String> {
if let Some(rest) = name.strip_prefix("//#") {
return (!rest.is_empty() && !rest.contains('#')).then(|| rest.to_string());
}
(!name.contains('#')).then_some(name)
}
pub(crate) fn run_cmd(task: &str, args: &[String]) -> Command {
let mut c = super::program::command("turbo");
c.arg("run").arg(task);
if !args.is_empty() {
c.arg("--").args(args);
}
c
}
pub(crate) fn is_self_passthrough(name: &str, command: &str) -> bool {
let mut tokens = command.split_whitespace();
if tokens.next() != Some("turbo") {
return false;
}
let Some(second) = tokens.next() else {
return false;
};
let target = if second == "run" {
let Some(third) = tokens.next() else {
return false;
};
third
} else {
second
};
if target != name {
return false;
}
let mut expects_flag_value = false;
let mut after_double_dash = false;
for token in tokens {
if is_shell_control_token(token) {
return false;
}
if looks_like_redirect(token) {
return false;
}
if looks_like_shell_expansion(token) {
return false;
}
if after_double_dash {
continue;
}
if token == "--" {
after_double_dash = true;
continue;
}
if token.starts_with('-') {
expects_flag_value = !token.contains('=');
continue;
}
if expects_flag_value {
expects_flag_value = false;
continue;
}
return false;
}
true
}
fn is_shell_control_token(token: &str) -> bool {
matches!(
token,
"&&" | "||" | ";" | ";;" | ";&" | ";;&" | "|" | "|&" | "&" | "!" | "{" | "}" | "(" | ")"
)
}
fn looks_like_redirect(token: &str) -> bool {
let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
let rest = rest.strip_prefix('&').unwrap_or(rest);
rest.starts_with('>') || rest.starts_with('<')
}
fn looks_like_shell_expansion(token: &str) -> bool {
token.contains('$') || token.contains('`')
}
#[cfg(test)]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "test fixtures embed bash parameter-expansion strings like \
`${X:-default}` and `${X:+alt}` as input to is_self_passthrough; \
the lint mistakes them for Rust format args."
)]
mod tests {
use std::fs;
use super::{detect, extract_tasks};
use crate::tool::test_support::TempDir;
#[test]
fn extract_tasks_returns_empty_when_turbo_json_is_missing() {
let dir = TempDir::new("turbo-missing");
assert!(
extract_tasks(dir.path())
.expect("missing turbo.json should be ok")
.is_empty()
);
}
#[test]
fn extract_tasks_errors_on_malformed_json() {
let dir = TempDir::new("turbo-malformed");
fs::write(dir.path().join("turbo.json"), "{").expect("turbo.json should be written");
assert!(extract_tasks(dir.path()).is_err());
}
#[test]
fn extract_tasks_returns_empty_when_no_task_table_exists() {
let dir = TempDir::new("turbo-empty");
fs::write(dir.path().join("turbo.json"), "{}").expect("turbo.json should be written");
assert!(
extract_tasks(dir.path())
.expect("empty turbo config should parse")
.is_empty()
);
}
#[test]
fn extract_tasks_reads_v2_tasks_schema() {
let dir = TempDir::new("turbo-v2");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"build":{},"lint":{},"web#build":{}}}"#,
)
.expect("turbo.json should be written");
let mut tasks = extract_tasks(dir.path()).expect("v2 turbo config should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["build", "lint"]);
}
#[test]
fn extract_tasks_reads_v1_pipeline_schema() {
let dir = TempDir::new("turbo-v1");
fs::write(
dir.path().join("turbo.json"),
r#"{"pipeline":{"test":{},"typecheck":{},"pkg#build":{}}}"#,
)
.expect("turbo.json should be written");
let mut tasks = extract_tasks(dir.path()).expect("v1 turbo config should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["test", "typecheck"]);
}
#[test]
fn detect_finds_turbo_jsonc_filename() {
let dir = TempDir::new("turbo-detect-jsonc");
fs::write(dir.path().join("turbo.jsonc"), r#"{"tasks":{"build":{}}}"#)
.expect("turbo.jsonc should be written");
assert!(detect(dir.path()));
}
#[test]
fn extract_tasks_reads_turbo_jsonc_filename() {
let dir = TempDir::new("turbo-jsonc-filename");
fs::write(
dir.path().join("turbo.jsonc"),
r#"{"tasks":{"build":{},"lint":{}}}"#,
)
.expect("turbo.jsonc should be written");
let mut tasks = extract_tasks(dir.path()).expect("turbo.jsonc should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["build", "lint"]);
}
#[test]
fn extract_tasks_accepts_trailing_commas_in_turbo_json() {
let dir = TempDir::new("turbo-trailing-comma");
fs::write(dir.path().join("turbo.json"), r#"{"tasks":{"build":{},}}"#)
.expect("turbo.json should be written");
let tasks = extract_tasks(dir.path()).expect("trailing comma should parse");
assert_eq!(tasks, ["build"]);
}
#[test]
fn extract_tasks_accepts_jsonc_comments_under_either_filename() {
let dir = TempDir::new("turbo-jsonc-comments");
fs::write(
dir.path().join("turbo.jsonc"),
r#"{
// line comment
"tasks": {
/* block comment */
"build": {},
"test": {},
},
}
"#,
)
.expect("turbo.jsonc should be written");
let mut tasks = extract_tasks(dir.path()).expect("jsonc should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["build", "test"]);
}
#[test]
fn extract_tasks_surfaces_root_tasks_with_bare_name() {
let dir = TempDir::new("turbo-root-tasks");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"//#lint":{},"//#format":{"cache":false}}}"#,
)
.expect("turbo.json should be written");
let mut tasks = extract_tasks(dir.path()).expect("root tasks should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["format", "lint"]);
}
#[test]
fn extract_tasks_mixes_root_plain_and_workspace_scoped_entries() {
let dir = TempDir::new("turbo-mixed-keys");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"build":{},"//#lint":{},"//#format":{"cache":false},"web#build":{}}}"#,
)
.expect("turbo.json should be written");
let mut tasks = extract_tasks(dir.path()).expect("mixed keys should parse");
tasks.sort_unstable();
assert_eq!(tasks, ["build", "format", "lint"]);
}
#[test]
fn extract_tasks_drops_malformed_root_task_keys() {
let dir = TempDir::new("turbo-malformed-root");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"//#":{},"//#a#b":{},"//#ok":{}}}"#,
)
.expect("turbo.json should be written");
let tasks = extract_tasks(dir.path()).expect("malformed root keys should parse");
assert_eq!(tasks, ["ok"]);
}
#[test]
fn extract_tasks_dedupes_root_task_and_plain_task_collision() {
let dir = TempDir::new("turbo-root-collision");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"lint":{},"//#lint":{}}}"#,
)
.expect("turbo.json should be written");
let tasks = extract_tasks(dir.path()).expect("colliding keys should parse");
assert_eq!(tasks, ["lint"]);
}
#[test]
fn extract_tasks_prefers_turbo_json_when_both_filenames_exist() {
let dir = TempDir::new("turbo-priority");
fs::write(
dir.path().join("turbo.json"),
r#"{"tasks":{"from-json":{}}}"#,
)
.expect("turbo.json should be written");
fs::write(
dir.path().join("turbo.jsonc"),
r#"{"tasks":{"from-jsonc":{}}}"#,
)
.expect("turbo.jsonc should be written");
let tasks = extract_tasks(dir.path()).expect("turbo.json should parse");
assert_eq!(tasks, ["from-json"]);
}
use super::is_self_passthrough;
#[test]
fn is_self_passthrough_matches_canonical_run_form() {
assert!(is_self_passthrough("build", "turbo run build"));
}
#[test]
fn is_self_passthrough_matches_with_trailing_flags() {
assert!(is_self_passthrough(
"build",
"turbo run build --filter=web --concurrency=4"
));
}
#[test]
fn is_self_passthrough_matches_shorthand_form() {
assert!(is_self_passthrough("build", "turbo build"));
}
#[test]
fn is_self_passthrough_tolerates_irregular_whitespace() {
assert!(is_self_passthrough("build", " turbo run build "));
}
#[test]
fn is_self_passthrough_rejects_real_script() {
assert!(!is_self_passthrough("build", "vite build"));
}
#[test]
fn is_self_passthrough_rejects_passthrough_to_different_target() {
assert!(!is_self_passthrough("build", "turbo run lint"));
}
#[test]
fn is_self_passthrough_rejects_indirect_invocation_via_pm() {
assert!(!is_self_passthrough("build", "npx turbo run build"));
assert!(!is_self_passthrough("build", "pnpm exec turbo run build"));
}
#[test]
fn is_self_passthrough_rejects_empty_or_partial_command() {
assert!(!is_self_passthrough("build", ""));
assert!(!is_self_passthrough("build", "turbo"));
assert!(!is_self_passthrough("build", "turbo run"));
}
#[test]
fn is_self_passthrough_rejects_shell_chain_and() {
assert!(!is_self_passthrough(
"build",
"turbo run build && echo done"
));
}
#[test]
fn is_self_passthrough_rejects_shell_chain_or() {
assert!(!is_self_passthrough("build", "turbo run build || exit 1"));
}
#[test]
fn is_self_passthrough_rejects_shell_pipe() {
assert!(!is_self_passthrough(
"build",
"turbo run build | tee log.txt"
));
}
#[test]
fn is_self_passthrough_rejects_shell_redirect() {
assert!(!is_self_passthrough("build", "turbo run build > out.log"));
}
#[test]
fn is_self_passthrough_rejects_shell_background() {
assert!(!is_self_passthrough("build", "turbo run build &"));
}
#[test]
fn is_self_passthrough_rejects_extra_positional_target() {
assert!(!is_self_passthrough("build", "turbo run build lint"));
}
#[test]
fn is_self_passthrough_accepts_space_separated_flag_value() {
assert!(is_self_passthrough("build", "turbo run build --filter web"));
}
#[test]
fn is_self_passthrough_accepts_trailing_bool_flag() {
assert!(is_self_passthrough("build", "turbo run build --no-cache"));
}
#[test]
fn is_self_passthrough_rejects_stderr_to_stdout_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache 2>&1"
));
}
#[test]
fn is_self_passthrough_rejects_stdout_to_stderr_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache 1>&2"
));
}
#[test]
fn is_self_passthrough_rejects_dev_null_redirect_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache 2>/dev/null"
));
}
#[test]
fn is_self_passthrough_rejects_combined_fd_redirect_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache &>output.log"
));
}
#[test]
fn is_self_passthrough_rejects_append_redirect_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache >>build.log"
));
}
#[test]
fn is_self_passthrough_rejects_dup_fd_redirect_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache >&2"
));
}
#[test]
fn is_self_passthrough_rejects_pipe_with_stderr_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache |& tee log"
));
}
#[test]
fn is_self_passthrough_rejects_case_terminator_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache ;;"
));
}
#[test]
fn is_self_passthrough_rejects_case_fallthrough_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache ;&"
));
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache ;;&"
));
}
#[test]
fn is_self_passthrough_rejects_negation_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache !"
));
}
#[test]
fn is_self_passthrough_rejects_group_delimiters_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache {"
));
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache }"
));
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache ("
));
assert!(!is_self_passthrough(
"build",
"turbo run build --no-cache )"
));
}
#[test]
fn is_self_passthrough_rejects_var_expansion_after_flag() {
assert!(!is_self_passthrough("build", "turbo run build --filter $X"));
}
#[test]
fn is_self_passthrough_rejects_braced_var_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter ${X}"
));
}
#[test]
fn is_self_passthrough_rejects_default_var_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter ${X:-web}"
));
}
#[test]
fn is_self_passthrough_rejects_pattern_substitution_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter ${X//foo/bar}"
));
}
#[test]
fn is_self_passthrough_rejects_command_substitution_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter $(get_filter)"
));
}
#[test]
fn is_self_passthrough_rejects_backtick_substitution_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter `get_filter`"
));
}
#[test]
fn is_self_passthrough_rejects_arithmetic_expansion_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --concurrency $((CORES * 2))"
));
assert!(!is_self_passthrough(
"build",
"turbo run build --concurrency $((CORES*2))"
));
}
#[test]
fn is_self_passthrough_rejects_special_var_after_flag() {
assert!(!is_self_passthrough("build", "turbo run build --filter $@"));
assert!(!is_self_passthrough("build", "turbo run build --filter $*"));
}
#[test]
fn is_self_passthrough_rejects_quoted_expansion_after_flag() {
assert!(!is_self_passthrough(
"build",
"turbo run build --filter \"${X}\""
));
assert!(!is_self_passthrough("build", "turbo run build \"${X}\""));
}
#[test]
fn is_self_passthrough_rejects_bare_var_positional() {
assert!(!is_self_passthrough("build", "turbo run build $X"));
}
use super::{is_shell_control_token, looks_like_redirect, looks_like_shell_expansion};
#[test]
fn is_shell_control_token_matches_full_bash_set() {
for op in [
"&&", "||", ";", ";;", ";&", ";;&", "|", "|&", "&", "!", "{", "}", "(", ")",
] {
assert!(
is_shell_control_token(op),
"expected `{op}` to be classified as shell control"
);
}
}
#[test]
fn is_shell_control_token_rejects_flags_values_and_redirects() {
for non_op in [
"--filter", "web", "4", "@scope/*", "$(date)", ">", "2>&1", "&>",
] {
assert!(
!is_shell_control_token(non_op),
"`{non_op}` must not classify as shell control"
);
}
}
#[test]
fn looks_like_shell_expansion_matches_full_dollar_family() {
for form in [
"$X",
"${X}",
"${X:-default}",
"${X:=default}",
"${X:?msg}",
"${X:+alt}",
"${X#prefix}",
"${X##prefix}",
"${X%suffix}",
"${X%%suffix}",
"${X/foo/bar}",
"${X//foo/bar}",
"${X^^}",
"${X,,}",
"${#X}",
"${!X}",
"${X[@]}",
"${X[0]}",
"$@",
"$*",
"$#",
"$?",
"$$",
"$!",
"$_",
"$0",
"$9",
"${10}",
"$(cmd)",
"$(cmd --flag)",
"$((1+1))",
"$((CORES*2))",
"\"${X}\"",
"\"$X\"",
"\"prefix-${X}\"",
] {
assert!(
looks_like_shell_expansion(form),
"expected `{form}` to be detected as shell expansion"
);
}
}
#[test]
fn looks_like_shell_expansion_matches_backtick_substitution() {
assert!(looks_like_shell_expansion("`cmd`"));
assert!(looks_like_shell_expansion("`cmd --flag`"));
assert!(looks_like_shell_expansion("prefix-`cmd`-suffix"));
}
#[test]
fn looks_like_shell_expansion_rejects_plain_values_and_flags() {
for plain in [
"--filter",
"--concurrency=4",
"web",
"4",
"@scope/*",
"./packages/web",
">",
"2>&1",
"&>",
"{a,b,c}",
"~/cache",
] {
assert!(
!looks_like_shell_expansion(plain),
"`{plain}` must not classify as shell expansion"
);
}
}
#[test]
fn looks_like_redirect_matches_bare_operators() {
assert!(looks_like_redirect(">"));
assert!(looks_like_redirect(">>"));
assert!(looks_like_redirect("<"));
assert!(looks_like_redirect("<<"));
assert!(looks_like_redirect("<<<"));
}
#[test]
fn looks_like_redirect_matches_combined_fd_forms() {
assert!(looks_like_redirect("&>"));
assert!(looks_like_redirect("&>>"));
assert!(looks_like_redirect(">&"));
}
#[test]
fn looks_like_redirect_matches_fd_prefixed_forms() {
assert!(looks_like_redirect("2>"));
assert!(looks_like_redirect("1>"));
assert!(looks_like_redirect("3<"));
}
#[test]
fn looks_like_redirect_matches_composite_forms() {
assert!(looks_like_redirect("2>&1"));
assert!(looks_like_redirect("1>&2"));
assert!(looks_like_redirect("2>/dev/null"));
assert!(looks_like_redirect("&>file.log"));
assert!(looks_like_redirect(">file"));
assert!(looks_like_redirect(">>append"));
}
#[test]
fn looks_like_redirect_rejects_flags_and_values() {
assert!(!looks_like_redirect("--filter"));
assert!(!looks_like_redirect("--concurrency=4"));
assert!(!looks_like_redirect("web"));
assert!(!looks_like_redirect("4"));
assert!(!looks_like_redirect("@scope/*"));
assert!(!looks_like_redirect("$(date)"));
assert!(!looks_like_redirect("'<pkg>'"));
assert!(!looks_like_redirect("&"));
}
#[test]
fn is_self_passthrough_accepts_double_dash_with_forwarded_flag() {
assert!(is_self_passthrough("build", "turbo run build -- --watch"));
assert!(is_self_passthrough(
"test",
"turbo run test --filter web -- --reporter=verbose"
));
}
#[test]
fn is_self_passthrough_accepts_double_dash_with_multiple_positionals() {
assert!(is_self_passthrough("build", "turbo run build -- arg1 arg2"));
assert!(is_self_passthrough(
"build",
"turbo run build -- arg1 arg2 arg3 arg4"
));
}
#[test]
fn is_self_passthrough_accepts_double_dash_with_no_following_args() {
assert!(is_self_passthrough("build", "turbo run build --"));
}
#[test]
fn is_self_passthrough_rejects_shell_chain_after_double_dash() {
assert!(!is_self_passthrough(
"build",
"turbo run build -- --watch && echo done"
));
assert!(!is_self_passthrough(
"build",
"turbo run build -- arg1 ; cleanup"
));
}
#[test]
fn is_self_passthrough_rejects_redirect_after_double_dash() {
assert!(!is_self_passthrough(
"build",
"turbo run build -- > build.log"
));
assert!(!is_self_passthrough(
"build",
"turbo run build -- --watch 2>&1"
));
}
#[test]
fn is_self_passthrough_rejects_expansion_after_double_dash() {
assert!(!is_self_passthrough("build", "turbo run build -- $TARGET"));
assert!(!is_self_passthrough(
"build",
"turbo run build -- --filter ${SCOPE}"
));
assert!(!is_self_passthrough(
"build",
"turbo run build -- $(date +%s)"
));
}
}