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) {
let (field_pm, unparseable) = tool::node::detect_pm_field_with_diagnostics(dir);
if let Some(raw) = unparseable {
ctx.warnings
.push(DetectionWarning::UnparseablePackageManager { raw });
}
field_pm
.or_else(|| tool::node::detect_pm_from_manifest(dir).map(|decl| decl.pm))
.filter(|pm| pm.is_node())
.or_else(|| detect_node_pm_upwards(dir))
} else {
detect_node_pm_upwards(dir)
};
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_node_pm_upwards(dir: &Path) -> Option<PackageManager> {
if !tool::node::within_workspace_upwards(dir) {
return None;
}
tool::files::find_in_ancestors(dir, |ancestor| {
if tool::bun::detect(ancestor) {
Some(PackageManager::Bun)
} else if tool::pnpm::detect(ancestor) {
Some(PackageManager::Pnpm)
} else if tool::yarn::detect(ancestor) {
Some(PackageManager::Yarn)
} else if tool::npm::detect(ancestor) {
Some(PackageManager::Npm)
} else {
tool::node::detect_pm_from_manifest(ancestor)
.map(|decl| decl.pm)
.filter(|pm| pm.is_node())
}
})
}
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);
}
if tool::bacon::detect(dir) {
ctx.task_runners.push(TaskRunner::Bacon);
}
}
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) {
use std::thread;
let with_deno = ctx.package_managers.contains(&PackageManager::Deno);
let has_local_manifest = tool::node::has_package_json(dir);
let workspace_member = !has_local_manifest && tool::node::within_workspace_upwards(dir);
let want_pkg_json = has_local_manifest || workspace_member || with_deno;
let want_turbo = ctx.task_runners.contains(&TaskRunner::Turbo);
let want_make = ctx.task_runners.contains(&TaskRunner::Make);
let want_just = ctx.task_runners.contains(&TaskRunner::Just);
let want_go_task = ctx.task_runners.contains(&TaskRunner::GoTask);
let want_deno_tasks = with_deno;
let want_cargo = ctx.package_managers.contains(&PackageManager::Cargo);
let want_go_packages = ctx.package_managers.contains(&PackageManager::Go);
let want_bacon = ctx.task_runners.contains(&TaskRunner::Bacon);
let want_mise = ctx.task_runners.contains(&TaskRunner::Mise);
thread::scope(|s| {
let pkg_json_h = want_pkg_json.then(|| {
s.spawn(move || {
if has_local_manifest && !with_deno {
tool::node::extract_scripts(dir)
} else {
tool::node::extract_scripts_upwards(dir)
}
})
});
let turbo_h = want_turbo.then(|| s.spawn(move || tool::turbo::extract_tasks(dir)));
let make_h = want_make.then(|| s.spawn(move || tool::make::extract_tasks(dir)));
let just_h = want_just.then(|| s.spawn(move || tool::just::extract_tasks(dir)));
let go_task_h = want_go_task.then(|| s.spawn(move || tool::go_task::extract_tasks(dir)));
let deno_h = want_deno_tasks.then(|| s.spawn(move || tool::deno::extract_tasks(dir)));
let cargo_h = want_cargo.then(|| s.spawn(move || tool::cargo_aliases::extract_tasks(dir)));
let go_h = want_go_packages.then(|| s.spawn(move || tool::go_pm::extract_tasks(dir)));
let bacon_h = want_bacon.then(|| s.spawn(move || tool::bacon::extract_tasks(dir)));
let mise_h = want_mise.then(|| s.spawn(move || tool::mise::extract_tasks(dir)));
if let Some(h) = pkg_json_h {
push_package_json_tasks(ctx, h.join().expect("extractor thread panicked"));
}
if let Some(h) = turbo_h {
push_named_tasks(
ctx,
TaskSource::TurboJson,
h.join().expect("extractor thread panicked"),
);
}
if let Some(h) = make_h {
push_described_tasks(
ctx,
TaskSource::Makefile,
h.join().expect("extractor thread panicked"),
);
}
if let Some(h) = just_h {
push_just_tasks(ctx, h.join().expect("extractor thread panicked"));
}
if let Some(h) = go_task_h {
push_described_tasks(
ctx,
TaskSource::Taskfile,
h.join().expect("extractor thread panicked"),
);
}
if let Some(h) = deno_h {
push_named_tasks(
ctx,
TaskSource::DenoJson,
h.join().expect("extractor thread panicked"),
);
}
if let Some(h) = cargo_h {
push_cargo_aliases(ctx, h.join().expect("extractor thread panicked"));
}
if let Some(h) = go_h {
push_go_tasks(ctx, h.join().expect("extractor thread panicked"));
}
if let Some(h) = bacon_h {
push_described_tasks(
ctx,
TaskSource::BaconToml,
h.join().expect("extractor thread panicked"),
);
}
if let Some(h) = mise_h {
push_mise_tasks(ctx, h.join().expect("extractor thread panicked"));
}
});
}
fn push_go_tasks(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::go_pm::ExtractedTask>>,
) {
match result {
Ok(entries) => {
for entry in entries {
ctx.tasks.push(Task {
name: entry.name,
source: TaskSource::GoPackage,
run_target: Some(entry.run_target),
description: None,
alias_of: None,
passthrough_to: None,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: TaskSource::GoPackage.label(),
error: format!("{err:#}"),
}),
}
}
fn push_mise_tasks(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::mise::ExtractedTask>>,
) {
push_recipe_alias_tasks(
ctx,
TaskSource::MiseToml,
result.map(|entries| entries.into_iter().map(mise_entry_triple).collect()),
);
}
fn mise_entry_triple(entry: tool::mise::ExtractedTask) -> RecipeOrAlias {
match entry {
tool::mise::ExtractedTask::Recipe { name, description } => (name, description, None),
tool::mise::ExtractedTask::Alias { name, target } => (name, None, Some(target)),
}
}
fn push_cargo_aliases(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::cargo_aliases::ExtractedAlias>>,
) {
match result {
Ok(entries) => {
for entry in entries {
let alias_of = Some(entry.display_command());
ctx.tasks.push(Task {
name: entry.name,
source: TaskSource::CargoAliases,
run_target: None,
description: None,
alias_of,
passthrough_to: None,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: TaskSource::CargoAliases.label(),
error: format!("{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,
run_target: None,
description,
alias_of: None,
passthrough_to: None,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: source.label(),
error: format!("{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 = tool::passthrough::detect_target(&name, &command);
ctx.tasks.push(Task {
name,
source: TaskSource::PackageJson,
run_target: None,
description: None,
alias_of: None,
passthrough_to,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: TaskSource::PackageJson.label(),
error: format!("{err:#}"),
}),
}
}
fn push_just_tasks(
ctx: &mut ProjectContext,
result: anyhow::Result<Vec<tool::just::ExtractedTask>>,
) {
push_recipe_alias_tasks(
ctx,
TaskSource::Justfile,
result.map(|entries| entries.into_iter().map(just_entry_triple).collect()),
);
}
fn just_entry_triple(entry: tool::just::ExtractedTask) -> RecipeOrAlias {
match entry {
tool::just::ExtractedTask::Recipe { name, doc } => (name, doc, None),
tool::just::ExtractedTask::Alias { name, target } => (name, None, Some(target)),
}
}
type RecipeOrAlias = (String, Option<String>, Option<String>);
fn push_recipe_alias_tasks(
ctx: &mut ProjectContext,
source: TaskSource,
result: anyhow::Result<Vec<RecipeOrAlias>>,
) {
match result {
Ok(entries) => {
for (name, description, alias_of) in entries {
ctx.tasks.push(Task {
name,
source,
run_target: None,
description,
alias_of,
passthrough_to: None,
});
}
}
Err(err) => ctx.warnings.push(DetectionWarning::TaskListUnreadable {
source: source.label(),
error: format!("{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");
}
#[test]
fn detect_records_warning_for_unparseable_package_manager_field() {
let dir = TempDir::new("detect-unparseable-pm-field");
fs::write(
dir.path().join("package.json"),
r#"{ "packageManager": "pnpmm@9.0.0" }"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
let detail = ctx
.warnings
.iter()
.find_map(|w| {
matches!(
w,
crate::types::DetectionWarning::UnparseablePackageManager { .. }
)
.then(|| w.detail())
})
.expect("unparseable-packageManager warning should be emitted");
assert!(
detail.contains("pnpmm@9.0.0"),
"warning should echo the raw value verbatim: {detail}",
);
assert!(
detail.contains("npm|pnpm|yarn|bun|deno"),
"warning should list the accepted values: {detail}",
);
}
#[test]
fn detect_models_cargo_aliases_as_aliases() {
let dir = TempDir::new("detect-cargo-alias-shape");
let cargo_dir = dir.path().join(".cargo");
fs::create_dir_all(&cargo_dir).expect(".cargo dir should be created");
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2024\"\n",
)
.expect("Cargo.toml should be written");
fs::write(
cargo_dir.join("config.toml"),
"[alias]\nl = \"clippy --all-targets\"\n",
)
.expect("config.toml should be written");
let ctx = detect(dir.path());
let task = ctx
.tasks
.iter()
.find(|task| task.source == crate::types::TaskSource::CargoAliases && task.name == "l")
.expect("cargo alias should be detected");
assert_eq!(task.description, None);
assert_eq!(task.alias_of.as_deref(), Some("clippy --all-targets"));
}
#[test]
fn detect_models_go_cmd_packages_as_tasks() {
let dir = TempDir::new("detect-go-cmd-package");
fs::write(dir.path().join("go.mod"), "module example.com/app\n")
.expect("go.mod should be written");
let cmd_dir = dir.path().join("cmd").join("serve");
fs::create_dir_all(&cmd_dir).expect("cmd package dir should be created");
fs::write(cmd_dir.join("main.go"), "package main\n\nfunc main() {}\n")
.expect("main.go should be written");
let ctx = detect(dir.path());
assert!(ctx.tasks.iter().any(|task| {
task.source == crate::types::TaskSource::GoPackage
&& task.name == "serve"
&& task.run_target.as_deref() == Some("./cmd/serve")
}));
}
#[test]
fn detect_models_root_go_main_package_as_task() {
let dir = TempDir::new("detect-go-root-package");
fs::write(dir.path().join("go.mod"), "module example.com/app\n")
.expect("go.mod should be written");
fs::write(
dir.path().join("main.go"),
"package main\n\nfunc main() {}\n",
)
.expect("main.go should be written");
let ctx = detect(dir.path());
assert!(ctx.tasks.iter().any(|task| {
task.source == crate::types::TaskSource::GoPackage
&& task.name == "app"
&& task.run_target.as_deref() == Some(".")
}));
}
#[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"));
}
#[test]
fn detect_lists_scripts_without_lockfile_or_pm_field() {
let dir = TempDir::new("detect-scripts-no-pm-signal");
fs::write(
dir.path().join("package.json"),
r#"{ "name": "leaf", "scripts": { "build": "wxt build" } }"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
assert!(
ctx.package_managers.is_empty(),
"no lockfile/pm field → no PM detected, yet scripts must still list",
);
assert!(ctx.tasks.iter().any(
|task| task.source == crate::types::TaskSource::PackageJson && task.name == "build"
));
}
#[test]
fn detect_lists_workspace_member_scripts_from_manifestless_subdir() {
let dir = TempDir::new("detect-workspace-member-subdir");
fs::create_dir_all(dir.path().join(".git")).expect("git dir should be created");
fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - apps/*\n",
)
.expect("pnpm-workspace.yaml should be written");
let member = dir.path().join("apps").join("ext");
let nested = member.join("src");
fs::create_dir_all(&nested).expect("nested dir should be created");
fs::write(
member.join("package.json"),
r#"{ "scripts": { "ext-build": "wxt build" } }"#,
)
.expect("member package.json should be written");
let ctx = detect(&nested);
assert!(
ctx.tasks
.iter()
.any(|task| task.source == crate::types::TaskSource::PackageJson
&& task.name == "ext-build")
);
}
#[test]
fn detect_skips_ancestor_manifest_outside_a_workspace() {
let dir = TempDir::new("detect-no-workspace-no-adopt");
fs::create_dir_all(dir.path().join(".git")).expect("git dir should be created");
fs::write(
dir.path().join("package.json"),
r#"{ "scripts": { "root-only": "echo nope" } }"#,
)
.expect("ancestor package.json should be written");
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).expect("subdir should be created");
let ctx = detect(&sub);
assert!(
!ctx.tasks.iter().any(|task| task.name == "root-only"),
"no workspace marker → ancestor manifest must not be adopted",
);
}
#[test]
fn detect_pm_from_dev_engines_without_lockfile() {
let dir = TempDir::new("detect-dev-engines-pm");
fs::write(
dir.path().join("package.json"),
r#"{ "devEngines": { "packageManager": { "name": "pnpm", "version": "9" } },
"scripts": { "build": "vite build" } }"#,
)
.expect("package.json should be written");
let ctx = detect(dir.path());
assert_eq!(ctx.package_managers, [crate::types::PackageManager::Pnpm]);
assert!(ctx.tasks.iter().any(
|task| task.source == crate::types::TaskSource::PackageJson && task.name == "build"
));
}
#[test]
fn detect_pm_upwards_for_workspace_member() {
let dir = TempDir::new("detect-pm-upwards-member");
fs::create_dir_all(dir.path().join(".git")).expect("git dir should be created");
fs::write(
dir.path().join("pnpm-workspace.yaml"),
"packages:\n - apps/*\n",
)
.expect("pnpm-workspace.yaml should be written");
fs::write(
dir.path().join("pnpm-lock.yaml"),
"lockfileVersion: '9.0'\n",
)
.expect("root pnpm-lock.yaml should be written");
let member = dir.path().join("apps").join("ext");
fs::create_dir_all(&member).expect("member dir should be created");
fs::write(
member.join("package.json"),
r#"{ "name": "ext", "scripts": { "build": "wxt build" } }"#,
)
.expect("member package.json should be written");
let ctx = detect(&member);
assert_eq!(ctx.package_managers, [crate::types::PackageManager::Pnpm]);
assert!(ctx.tasks.iter().any(
|task| task.source == crate::types::TaskSource::PackageJson && task.name == "build"
));
}
}