use std::path::Path;
use std::process;
use serde::Deserialize;
use crate::tool;
use crate::types::{
DetectionWarning, NodeVersion, PackageManager, ProjectContext, Task, TaskRunner, TaskSource,
};
pub(crate) fn detect(dir: &Path) -> ProjectContext {
let mut ctx = ProjectContext {
root: dir.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(),
};
detect_package_managers(dir, &mut ctx);
detect_task_runners(dir, &mut ctx);
detect_node_version(dir, &mut ctx);
detect_monorepo(dir, &mut ctx);
extract_tasks(dir, &mut ctx);
ctx.tasks.sort_by(|a, b| {
a.source
.display_order()
.cmp(&b.source.display_order())
.then_with(|| a.name.cmp(&b.name))
});
ctx
}
fn detect_package_managers(dir: &Path, ctx: &mut ProjectContext) {
let node_pm = if tool::bun::detect(dir) {
Some(PackageManager::Bun)
} else if tool::pnpm::detect(dir) {
Some(PackageManager::Pnpm)
} else if tool::yarn::detect(dir) {
Some(PackageManager::Yarn)
} else if tool::npm::detect(dir) {
Some(PackageManager::Npm)
} else if tool::node::has_package_json(dir) {
tool::node::detect_pm_from_field(dir).filter(|pm| pm.is_node())
} else {
None
};
if let Some(pm) = node_pm {
ctx.package_managers.push(pm);
}
if tool::cargo_pm::detect(dir) {
ctx.package_managers.push(PackageManager::Cargo);
}
if tool::deno::detect(dir) {
ctx.package_managers.push(PackageManager::Deno);
}
if tool::uv::detect(dir) {
ctx.package_managers.push(PackageManager::Uv);
} else if tool::poetry::detect(dir) {
ctx.package_managers.push(PackageManager::Poetry);
} else if tool::pipenv::detect(dir) {
ctx.package_managers.push(PackageManager::Pipenv);
}
if tool::go_pm::detect(dir) {
ctx.package_managers.push(PackageManager::Go);
}
if tool::bundler::detect(dir) {
ctx.package_managers.push(PackageManager::Bundler);
}
if tool::composer::detect(dir) {
ctx.package_managers.push(PackageManager::Composer);
}
}
fn detect_task_runners(dir: &Path, ctx: &mut ProjectContext) {
if tool::turbo::detect(dir) {
ctx.task_runners.push(TaskRunner::Turbo);
}
if tool::nx::detect(dir) {
ctx.task_runners.push(TaskRunner::Nx);
}
if tool::make::detect(dir) {
ctx.task_runners.push(TaskRunner::Make);
}
if tool::just::detect(dir) {
ctx.task_runners.push(TaskRunner::Just);
}
if tool::go_task::detect(dir) {
ctx.task_runners.push(TaskRunner::GoTask);
}
if tool::mise::detect(dir) {
ctx.task_runners.push(TaskRunner::Mise);
}
}
fn detect_node_version(dir: &Path, ctx: &mut ProjectContext) {
for (file, source) in [(".nvmrc", ".nvmrc"), (".node-version", ".node-version")] {
if let Ok(raw) = std::fs::read_to_string(dir.join(file)) {
let v = raw.trim();
if !v.is_empty() {
ctx.node_version = Some(NodeVersion {
expected: v.strip_prefix('v').unwrap_or(v).to_string(),
source,
});
break;
}
}
}
if ctx.node_version.is_none()
&& let Ok(content) = std::fs::read_to_string(dir.join(".tool-versions"))
{
for line in content.lines() {
if let Some(v) = parse_tool_versions_node(line) {
ctx.node_version = Some(NodeVersion {
expected: v.to_string(),
source: ".tool-versions",
});
break;
}
}
}
if ctx.node_version.is_none()
&& let Ok(content) = std::fs::read_to_string(dir.join("package.json"))
{
#[derive(Deserialize)]
struct Engines {
node: Option<String>,
}
#[derive(Deserialize)]
struct Partial {
engines: Option<Engines>,
}
if let Ok(p) = serde_json::from_str::<Partial>(&content)
&& let Some(v) = p.engines.and_then(|e| e.node)
{
ctx.node_version = Some(NodeVersion {
expected: v,
source: "package.json engines",
});
}
}
if ctx.node_version.is_some() || ctx.package_managers.iter().any(|pm| pm.is_node()) {
ctx.current_node = detect_current_node();
}
}
fn detect_current_node() -> Option<String> {
let out = process::Command::new("node")
.arg("--version")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let trimmed = raw.trim();
let v = trimmed.strip_prefix('v').unwrap_or(trimmed);
Some(v.to_string())
}
fn parse_tool_versions_node(line: &str) -> Option<&str> {
let content = line.split('#').next()?.trim();
let mut parts = content.split_whitespace();
let tool = parts.next()?;
let version = parts.next()?;
(tool == "nodejs").then_some(version)
}
fn detect_monorepo(dir: &Path, ctx: &mut ProjectContext) {
if dir.join("pnpm-workspace.yaml").exists() || dir.join("lerna.json").exists() {
ctx.is_monorepo = true;
}
if ctx.task_runners.contains(&TaskRunner::Turbo) || ctx.task_runners.contains(&TaskRunner::Nx) {
ctx.is_monorepo = true;
}
if tool::cargo_pm::detect_workspace(dir) {
ctx.is_monorepo = true;
}
if let Ok(content) = std::fs::read_to_string(dir.join("package.json"))
&& let Ok(p) = serde_json::from_str::<serde_json::Value>(&content)
&& p.get("workspaces").is_some()
{
ctx.is_monorepo = true;
}
}
fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) {
if tool::node::has_package_json(dir)
&& ctx
.package_managers
.iter()
.any(|pm| pm.is_node() || *pm == PackageManager::Deno)
{
push_package_json_tasks(
ctx,
if ctx.package_managers.contains(&PackageManager::Deno) {
tool::node::extract_scripts_upwards(dir)
} else {
tool::node::extract_scripts(dir)
},
);
} else if ctx.package_managers.contains(&PackageManager::Deno) {
push_package_json_tasks(ctx, tool::node::extract_scripts_upwards(dir));
}
if ctx.task_runners.contains(&TaskRunner::Turbo) {
push_named_tasks(ctx, TaskSource::TurboJson, tool::turbo::extract_tasks(dir));
}
if ctx.task_runners.contains(&TaskRunner::Make) {
push_described_tasks(ctx, TaskSource::Makefile, tool::make::extract_tasks(dir));
}
if ctx.task_runners.contains(&TaskRunner::Just) {
push_just_tasks(ctx, tool::just::extract_tasks(dir));
}
if ctx.task_runners.contains(&TaskRunner::GoTask) {
push_described_tasks(ctx, TaskSource::Taskfile, tool::go_task::extract_tasks(dir));
}
if ctx.package_managers.contains(&PackageManager::Deno) {
push_named_tasks(ctx, TaskSource::DenoJson, tool::deno::extract_tasks(dir));
}
if ctx.package_managers.contains(&PackageManager::Cargo) {
push_cargo_aliases(ctx, tool::cargo_aliases::extract_tasks(dir));
}
}
fn push_cargo_aliases(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::cargo_aliases::ExtractedAlias>>,
) {
match result {
Ok(entries) => {
for entry in entries {
let description = Some(entry.display_command());
ctx.tasks.push(Task {
name: entry.name,
source: TaskSource::CargoAliases,
description,
alias_of: None,
passthrough_to_turbo: false,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning {
source: TaskSource::CargoAliases.label(),
detail: format!("failed to read aliases: {err:#}"),
}),
}
}
fn push_named_tasks(
ctx: &mut ProjectContext,
source: TaskSource,
result: anyhow::Result<Vec<String>>,
) {
push_described_tasks(
ctx,
source,
result.map(|names| names.into_iter().map(|name| (name, None)).collect()),
);
}
fn push_described_tasks(
ctx: &mut ProjectContext,
source: TaskSource,
result: anyhow::Result<Vec<(String, Option<String>)>>,
) {
match result {
Ok(entries) => {
for (name, description) in entries {
ctx.tasks.push(Task {
name,
source,
description,
alias_of: None,
passthrough_to_turbo: false,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning {
source: source.label(),
detail: format!("failed to read tasks: {err:#}"),
}),
}
}
fn push_package_json_tasks(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<(String, String)>>,
) {
match result {
Ok(entries) => {
for (name, command) in entries {
let passthrough_to_turbo = tool::turbo::is_self_passthrough(&name, &command);
ctx.tasks.push(Task {
name,
source: TaskSource::PackageJson,
description: None,
alias_of: None,
passthrough_to_turbo,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning {
source: TaskSource::PackageJson.label(),
detail: format!("failed to read tasks: {err:#}"),
}),
}
}
fn push_just_tasks(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::just::ExtractedTask>>,
) {
match result {
Ok(entries) => {
for entry in entries {
let (name, description, alias_of) = match entry {
tool::just::ExtractedTask::Recipe { name, doc } => (name, doc, None),
tool::just::ExtractedTask::Alias { name, target } => (name, None, Some(target)),
};
ctx.tasks.push(Task {
name,
source: TaskSource::Justfile,
description,
alias_of,
passthrough_to_turbo: false,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning {
source: TaskSource::Justfile.label(),
detail: format!("failed to read tasks: {err:#}"),
}),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::parse_tool_versions_node;
use crate::detect::detect;
use crate::tool::test_support::TempDir;
#[test]
fn parses_tool_versions_node_entry() {
assert_eq!(parse_tool_versions_node("nodejs 20.11.1"), Some("20.11.1"));
}
#[test]
fn ignores_malformed_tool_versions_entry() {
assert_eq!(parse_tool_versions_node("nodejs20.11.1"), None);
}
#[test]
fn strips_tool_versions_inline_comments() {
assert_eq!(
parse_tool_versions_node("nodejs 20.11.1 # pinned for ci"),
Some("20.11.1")
);
}
#[test]
fn detect_records_warnings_for_invalid_task_configs() {
let dir = TempDir::new("detect-warning");
fs::write(dir.path().join("turbo.json"), "{").expect("turbo.json should be written");
let ctx = detect(dir.path());
assert_eq!(ctx.warnings.len(), 1);
assert_eq!(ctx.warnings[0].source, "turbo.json");
}
#[test]
fn detect_uses_deno_for_package_json_deno_projects() {
let dir = TempDir::new("detect-package-json-deno");
fs::write(
dir.path().join("package.json"),
r#"{
"packageManager": "deno@2.7.12",
"scripts": {
"build": "vite build"
}
}"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
assert_eq!(ctx.package_managers, [crate::types::PackageManager::Deno]);
assert!(ctx.tasks.iter().any(
|task| task.source == crate::types::TaskSource::PackageJson && task.name == "build"
));
}
#[test]
fn detect_uses_nearest_deno_sources_from_nested_dir() {
let dir = TempDir::new("detect-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.lock"), "{}").expect("deno.lock should be written");
fs::write(
dir.path().join("deno.jsonc"),
r#"{ tasks: { root: "deno task root" } }"#,
)
.expect("root deno.jsonc should be written");
fs::write(
dir.path().join("apps").join("site").join("package.json"),
r#"{
"scripts": {
"member": "deno task member"
}
}"#,
)
.expect("member package.json should be written");
let ctx = detect(&nested);
assert!(
ctx.package_managers
.contains(&crate::types::PackageManager::Deno)
);
assert!(ctx.tasks.iter().any(|task| task.name == "member"));
assert!(ctx.tasks.iter().any(|task| task.name == "root"));
}
}