use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use anyhow::Context as _;
use serde::Deserialize;
pub(crate) const CLEAN_DIRS: &[&str] = &[".turbo"];
pub(crate) const FILENAME: &str = "turbo.json";
pub(crate) fn detect(dir: &Path) -> bool {
dir.join(FILENAME).exists()
}
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 path = dir.join(FILENAME);
if !path.exists() {
return Ok(vec![]);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let p = serde_json::from_str::<Partial>(&content)
.with_context(|| format!("{} is not valid JSON", path.display()))?;
let Some(tasks) = p.tasks.or(p.pipeline) else {
return Ok(vec![]);
};
Ok(tasks
.into_keys()
.filter(|name| !name.contains('#'))
.collect())
}
pub(crate) fn run_cmd(task: &str, args: &[String]) -> Command {
let mut c = Command::new("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::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"]);
}
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)"
));
}
}