use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toolchain {
pub ecosystem: String,
pub marker: String,
pub manager: String,
pub install: Vec<String>,
pub build: Vec<String>,
pub test: Vec<String>,
pub lint: Vec<String>,
pub format: Vec<String>,
pub run: Vec<String>,
}
impl Toolchain {
pub fn describe(&self) -> String {
let mut parts = Vec::new();
for (label, argv) in [
("install", &self.install),
("build", &self.build),
("test", &self.test),
("lint", &self.lint),
("format", &self.format),
("run", &self.run),
] {
if !argv.is_empty() {
parts.push(format!("{label}: {}", argv.join(" ")));
}
}
format!(
"This looks like a {} project ({} in the workspace root, driven by {}). \
Conventional commands — {}. They are defaults, not instructions: check them \
against the project before relying on one.",
self.ecosystem,
self.marker,
self.manager,
parts.join("; ")
)
}
}
#[allow(clippy::too_many_arguments)]
fn tc(
ecosystem: &str,
marker: &str,
manager: &str,
install: &[&str],
build: &[&str],
test: &[&str],
lint: &[&str],
format: &[&str],
run: &[&str],
) -> Toolchain {
let v = |a: &[&str]| a.iter().map(|s| (*s).to_string()).collect();
Toolchain {
ecosystem: ecosystem.to_string(),
marker: marker.to_string(),
manager: manager.to_string(),
install: v(install),
build: v(build),
test: v(test),
lint: v(lint),
format: v(format),
run: v(run),
}
}
const MARKERS: &[&str] = &[
"Cargo.toml",
"deno.json",
"deno.jsonc",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"mix.exs",
"Gemfile",
"composer.json",
"Package.swift",
"CMakeLists.txt",
"Makefile",
];
pub fn detect(root: &Path) -> Option<Toolchain> {
let has = |name: &str| root.join(name).exists();
let marker = MARKERS.iter().copied().find(|m| has(m)).or_else(|| {
std::fs::read_dir(root).ok().and_then(|entries| {
entries
.flatten()
.any(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
[".csproj", ".fsproj", ".sln"]
.iter()
.any(|ext| name.ends_with(ext))
})
.then_some("*.csproj")
})
})?;
Some(match marker {
"Cargo.toml" => tc(
"cargo",
marker,
"cargo",
&["cargo", "fetch"],
&["cargo", "build"],
&["cargo", "test"],
&["cargo", "clippy", "--all-targets"],
&["cargo", "fmt"],
&["cargo", "run"],
),
"deno.json" | "deno.jsonc" => tc(
"deno",
marker,
"deno",
&[],
&["deno", "check", "."],
&["deno", "test"],
&["deno", "lint"],
&["deno", "fmt"],
&["deno", "task", "start"],
),
"package.json" => {
let manager = if has("bun.lockb") || has("bun.lock") {
"bun"
} else if has("pnpm-lock.yaml") {
"pnpm"
} else if has("yarn.lock") {
"yarn"
} else {
"npm"
};
let install = "install";
tc(
"node",
marker,
manager,
&[manager, install],
&[manager, "run", "build"],
&[manager, "test"],
&[manager, "run", "lint"],
&[manager, "run", "format"],
&[manager, "start"],
)
}
"go.mod" => tc(
"go",
marker,
"go",
&[],
&["go", "build", "./..."],
&["go", "test", "./..."],
&["go", "vet", "./..."],
&["gofmt", "-w", "."],
&["go", "run", "."],
),
"pyproject.toml" | "requirements.txt" => {
if has("uv.lock") {
tc(
"python",
marker,
"uv",
&["uv", "sync"],
&[],
&["uv", "run", "pytest"],
&["uv", "run", "ruff", "check"],
&["uv", "run", "ruff", "format"],
&["uv", "run", "python", "-m", "main"],
)
} else if has("poetry.lock") {
tc(
"python",
marker,
"poetry",
&["poetry", "install"],
&[],
&["poetry", "run", "pytest"],
&["poetry", "run", "ruff", "check"],
&["poetry", "run", "ruff", "format"],
&["poetry", "run", "python", "-m", "main"],
)
} else {
tc(
"python",
marker,
"pip",
&["python", "-m", "pip", "install", "-e", "."],
&[],
&["python", "-m", "pytest"],
&["python", "-m", "ruff", "check"],
&["python", "-m", "ruff", "format"],
&["python", "-m", "main"],
)
}
}
"pom.xml" => tc(
"maven",
marker,
"mvn",
&["mvn", "-B", "dependency:go-offline"],
&["mvn", "-B", "compile"],
&["mvn", "-B", "test"],
&[],
&["mvn", "-B", "formatter:format"],
&["mvn", "-B", "exec:java"],
),
"build.gradle" | "build.gradle.kts" => tc(
"gradle",
marker,
"gradle",
&[],
&["gradle", "build"],
&["gradle", "test"],
&["gradle", "check"],
&[],
&["gradle", "run"],
),
"mix.exs" => tc(
"elixir",
marker,
"mix",
&["mix", "deps.get"],
&["mix", "compile"],
&["mix", "test"],
&["mix", "credo"],
&["mix", "format"],
&["mix", "run"],
),
"Gemfile" => tc(
"ruby",
marker,
"bundler",
&["bundle", "install"],
&[],
&["bundle", "exec", "rake", "test"],
&["bundle", "exec", "rubocop"],
&["bundle", "exec", "rubocop", "-a"],
&["bundle", "exec", "ruby", "main.rb"],
),
"composer.json" => tc(
"php",
marker,
"composer",
&["composer", "install"],
&[],
&["composer", "test"],
&["composer", "lint"],
&[],
&["php", "-S", "127.0.0.1:8000"],
),
"Package.swift" => tc(
"swift",
marker,
"swift",
&["swift", "package", "resolve"],
&["swift", "build"],
&["swift", "test"],
&[],
&["swift-format", "-i", "-r", "Sources"],
&["swift", "run"],
),
"CMakeLists.txt" => tc(
"cmake",
marker,
"cmake",
&[],
&["cmake", "--build", "build"],
&["ctest", "--test-dir", "build"],
&[],
&[],
&[],
),
"*.csproj" => tc(
"dotnet",
marker,
"dotnet",
&["dotnet", "restore"],
&["dotnet", "build"],
&["dotnet", "test"],
&["dotnet", "format", "--verify-no-changes"],
&["dotnet", "format"],
&["dotnet", "run"],
),
_ => tc(
"make",
marker,
"make",
&[],
&["make"],
&["make", "test"],
&["make", "lint"],
&["make", "format"],
&["make", "run"],
),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn project(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for f in files {
std::fs::write(dir.path().join(f), "").unwrap();
}
dir
}
#[test]
fn every_marker_names_its_ecosystem_and_its_test_command() {
let cases: &[(&str, &str, &[&str])] = &[
("Cargo.toml", "cargo", &["cargo", "test"]),
("deno.json", "deno", &["deno", "test"]),
("deno.jsonc", "deno", &["deno", "test"]),
("package.json", "node", &["npm", "test"]),
("go.mod", "go", &["go", "test", "./..."]),
("pyproject.toml", "python", &["python", "-m", "pytest"]),
("requirements.txt", "python", &["python", "-m", "pytest"]),
("pom.xml", "maven", &["mvn", "-B", "test"]),
("build.gradle", "gradle", &["gradle", "test"]),
("build.gradle.kts", "gradle", &["gradle", "test"]),
("mix.exs", "elixir", &["mix", "test"]),
("Gemfile", "ruby", &["bundle", "exec", "rake", "test"]),
("composer.json", "php", &["composer", "test"]),
("Package.swift", "swift", &["swift", "test"]),
("CMakeLists.txt", "cmake", &["ctest", "--test-dir", "build"]),
("app.csproj", "dotnet", &["dotnet", "test"]),
("Makefile", "make", &["make", "test"]),
];
assert_eq!(cases.len(), 17);
for (marker, ecosystem, test) in cases {
let dir = project(&[marker]);
let found = detect(dir.path())
.unwrap_or_else(|| panic!("{marker} must be detected as {ecosystem}"));
assert_eq!(&found.ecosystem, ecosystem, "{marker}");
assert_eq!(found.test, *test, "{marker}");
assert!(
!found.test.is_empty(),
"{marker}: every ecosystem here has a test command"
);
}
}
#[test]
fn the_lockfile_beside_package_json_chooses_the_package_manager() {
for (lockfile, manager, install) in [
("bun.lockb", "bun", "install"),
("bun.lock", "bun", "install"),
("pnpm-lock.yaml", "pnpm", "install"),
("yarn.lock", "yarn", "install"),
("package-lock.json", "npm", "install"),
] {
let dir = project(&["package.json", lockfile]);
let found = detect(dir.path()).unwrap();
assert_eq!(found.manager, manager, "{lockfile}");
assert_eq!(found.test, [manager, "test"], "{lockfile}");
assert_eq!(found.install, [manager, install], "{lockfile}");
}
}
#[test]
fn a_directory_with_no_marker_reports_no_detection_rather_than_guessing() {
let dir = project(&["README.md", "notes.txt", "src"]);
assert_eq!(detect(dir.path()), None);
}
#[test]
fn a_makefile_beside_a_real_build_system_does_not_win() {
let dir = project(&["Makefile", "Cargo.toml"]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "cargo");
let alone = project(&["Makefile"]);
assert_eq!(detect(alone.path()).unwrap().ecosystem, "make");
}
#[test]
fn a_deno_project_carrying_a_package_json_is_still_deno() {
let dir = project(&["package.json", "deno.json"]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "deno");
}
#[test]
fn the_python_runner_follows_the_lockfile() {
for (lockfile, manager) in [("uv.lock", "uv"), ("poetry.lock", "poetry")] {
let dir = project(&["pyproject.toml", lockfile]);
let found = detect(dir.path()).unwrap();
assert_eq!(found.manager, manager);
assert_eq!(found.test, [manager, "run", "pytest"]);
}
let plain = project(&["pyproject.toml"]);
assert_eq!(detect(plain.path()).unwrap().manager, "pip");
}
#[test]
fn the_description_names_the_marker_and_omits_the_commands_that_do_not_exist() {
let dir = project(&["CMakeLists.txt"]);
let text = detect(dir.path()).unwrap().describe();
assert!(text.contains("CMakeLists.txt"), "{text}");
assert!(text.contains("test: ctest --test-dir build"), "{text}");
assert!(!text.contains("lint:"), "{text}");
assert!(!text.contains("format:"), "{text}");
}
#[test]
fn a_toolchain_round_trips_through_json() {
let dir = project(&["Cargo.toml"]);
let found = detect(dir.path()).unwrap();
let json = serde_json::to_string(&found).unwrap();
assert_eq!(serde_json::from_str::<Toolchain>(&json).unwrap(), found);
}
}