use std::path::Path;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Setup,
Format,
Lint,
Test,
Check,
Clean,
}
impl Phase {
pub const ALL: [Self; 6] = [
Self::Setup,
Self::Format,
Self::Lint,
Self::Test,
Self::Check,
Self::Clean,
];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Setup => "setup",
Self::Format => "format",
Self::Lint => "lint",
Self::Test => "test",
Self::Check => "check",
Self::Clean => "clean",
}
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == key)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PhaseCommands {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clean: Option<String>,
}
impl PhaseCommands {
#[must_use]
pub fn get(&self, phase: Phase) -> Option<&str> {
match phase {
Phase::Setup => self.setup.as_deref(),
Phase::Format => self.format.as_deref(),
Phase::Lint => self.lint.as_deref(),
Phase::Test => self.test.as_deref(),
Phase::Check => self.check.as_deref(),
Phase::Clean => self.clean.as_deref(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolingPack {
pub name: String,
#[serde(default)]
pub detect: Vec<String>,
#[serde(default)]
pub require_all: bool,
#[serde(default)]
pub phases: PhaseCommands,
}
impl ToolingPack {
#[must_use]
fn matches(&self, repo_dir: &Path) -> bool {
if self.detect.is_empty() {
return false;
}
let exists = |m: &String| repo_dir.join(m).exists();
if self.require_all {
self.detect.iter().all(exists)
} else {
self.detect.iter().any(exists)
}
}
}
#[must_use]
pub fn builtin_tooling_packs() -> Vec<ToolingPack> {
let pack = |name: &str, marker: &str, phases: PhaseCommands| ToolingPack {
name: name.into(),
detect: vec![marker.into()],
require_all: false,
phases,
};
let some = |s: &str| Some(s.to_string());
vec![
ToolingPack {
name: "pyo3".into(),
detect: vec!["Cargo.toml".into(), "pyproject.toml".into()],
require_all: true,
phases: PhaseCommands {
setup: some("maturin develop"),
format: some("cargo fmt && ruff format ."),
lint: some("cargo clippy --all-targets -- -D warnings && ruff check ."),
test: some("maturin develop && pytest -x"),
check: some(
"cargo fmt -- --check && cargo clippy --all-targets -- -D warnings \
&& maturin develop && cargo test && pytest -x",
),
clean: some("cargo clean"),
},
},
pack(
"rust",
"Cargo.toml",
PhaseCommands {
setup: some("cargo fetch"),
format: some("cargo fmt"),
lint: some("cargo clippy --all-targets -- -D warnings"),
test: some("cargo test"),
check: some("cargo fmt -- --check && cargo test"),
clean: some("cargo clean"),
},
),
pack(
"python",
"pyproject.toml",
PhaseCommands {
format: some("ruff format ."),
lint: some("ruff check ."),
test: some("pytest -x"),
check: some("pytest -x"),
..PhaseCommands::default()
},
),
pack(
"go",
"go.mod",
PhaseCommands {
format: some("gofmt -w ."),
lint: some("go vet ./..."),
test: some("go test ./..."),
check: some("go test ./..."),
clean: some("go clean"),
..PhaseCommands::default()
},
),
]
}
#[must_use]
pub fn load_tooling_packs_from_dir(dir: &Path) -> Vec<ToolingPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut paths: Vec<std::path::PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
let mut out = Vec::new();
for path in paths {
match std::fs::read_to_string(&path).map(|s| toml::from_str::<ToolingPack>(&s)) {
Ok(Ok(pack)) => out.push(pack),
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed tooling pack");
}
Err(_) => {}
}
}
out
}
#[must_use]
pub fn merge_tooling_packs(layers: Vec<Vec<ToolingPack>>) -> Vec<ToolingPack> {
let mut order: Vec<String> = Vec::new();
let mut by_name: std::collections::HashMap<String, ToolingPack> =
std::collections::HashMap::new();
for layer in layers {
for pack in layer {
if !by_name.contains_key(&pack.name) {
order.push(pack.name.clone());
}
by_name.insert(pack.name.clone(), pack);
}
}
order
.into_iter()
.filter_map(|n| by_name.remove(&n))
.collect()
}
#[must_use]
pub fn phase_commands_from_packs(
repo_dir: &Path,
phase: Phase,
packs: &[ToolingPack],
) -> Vec<String> {
matching_packs(repo_dir, packs)
.into_iter()
.filter_map(|p| p.phases.get(phase).map(str::to_string))
.collect()
}
fn matching_packs<'a>(repo_dir: &Path, packs: &'a [ToolingPack]) -> Vec<&'a ToolingPack> {
let matched: Vec<&ToolingPack> = packs.iter().filter(|p| p.matches(repo_dir)).collect();
let supersets: Vec<&[String]> = matched
.iter()
.filter(|p| p.require_all)
.map(|p| p.detect.as_slice())
.collect();
matched
.iter()
.filter(|p| {
!supersets
.iter()
.any(|sup| sup.len() > p.detect.len() && p.detect.iter().all(|m| sup.contains(m)))
})
.copied()
.collect()
}
static LIFECYCLE_OVERRIDE: OnceLock<PhaseCommands> = OnceLock::new();
pub fn set_lifecycle_override(cmds: PhaseCommands) {
let _ = LIFECYCLE_OVERRIDE.set(cmds);
}
#[must_use]
pub fn resolved_phase_commands(repo_dir: &Path, phase: Phase) -> Vec<String> {
if let Some(cmd) = LIFECYCLE_OVERRIDE.get().and_then(|c| c.get(phase)) {
return vec![cmd.to_string()];
}
let dropins = crate::config::Config::user_config_dir()
.map(|d| load_tooling_packs_from_dir(&d.join("tooling")))
.unwrap_or_default();
let packs = merge_tooling_packs(vec![builtin_tooling_packs(), dropins]);
phase_commands_from_packs(repo_dir, phase, &packs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_keys_are_stable() {
assert_eq!(Phase::Format.as_str(), "format");
assert_eq!(Phase::Check.as_str(), "check");
assert_eq!(Phase::ALL.len(), 6);
}
#[test]
fn from_key_round_trips_every_phase_and_rejects_unknown() {
for p in Phase::ALL {
assert_eq!(Phase::from_key(p.as_str()), Some(p));
}
assert_eq!(Phase::from_key("deploy"), None);
assert_eq!(Phase::from_key(""), None);
}
#[test]
fn builtin_format_is_data_per_ecosystem() {
let by = |n: &str| {
builtin_tooling_packs()
.into_iter()
.find(|p| p.name == n)
.unwrap()
};
assert_eq!(by("rust").phases.format.as_deref(), Some("cargo fmt"));
assert_eq!(by("python").phases.format.as_deref(), Some("ruff format ."));
assert_eq!(by("go").phases.check.as_deref(), Some("go test ./..."));
}
#[test]
fn none_matching_yields_no_commands() {
let dir = tempfile::tempdir().unwrap();
assert!(
phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs())
.is_empty()
);
}
#[test]
fn multiple_toolchains_each_contribute_a_command() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
std::fs::write(dir.path().join("go.mod"), "").unwrap();
let cmds = phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs());
assert!(cmds.contains(&"cargo fmt".to_string()), "{cmds:?}");
assert!(cmds.contains(&"gofmt -w .".to_string()), "{cmds:?}");
}
#[test]
fn pyo3_require_all_supersedes_rust_and_python() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
assert_eq!(
phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs()),
vec!["cargo fmt && ruff format .".to_string()]
);
let check = phase_commands_from_packs(dir.path(), Phase::Check, &builtin_tooling_packs());
assert_eq!(check.len(), 1, "one coherent check, not three: {check:?}");
assert!(check[0].contains("maturin develop"), "{check:?}");
let d2 = tempfile::tempdir().unwrap();
std::fs::write(d2.path().join("Cargo.toml"), "").unwrap();
assert_eq!(
phase_commands_from_packs(d2.path(), Phase::Format, &builtin_tooling_packs()),
vec!["cargo fmt".to_string()]
);
}
#[test]
fn a_dropin_overrides_by_name_and_adds_new_toolchains() {
let dropins = vec![
ToolingPack {
name: "rust".into(),
detect: vec!["Cargo.toml".into()],
require_all: false,
phases: PhaseCommands {
format: Some("cargo +nightly fmt".into()),
..PhaseCommands::default()
},
},
ToolingPack {
name: "zig".into(),
detect: vec!["build.zig".into()],
require_all: false,
phases: PhaseCommands {
format: Some("zig fmt .".into()),
..PhaseCommands::default()
},
},
];
let merged = merge_tooling_packs(vec![builtin_tooling_packs(), dropins]);
let rust = merged.iter().find(|p| p.name == "rust").unwrap();
assert_eq!(rust.phases.format.as_deref(), Some("cargo +nightly fmt"));
assert!(merged.iter().any(|p| p.name == "zig"));
}
#[test]
fn pack_and_lifecycle_parse_from_toml() {
let p: ToolingPack = toml::from_str(
"name = \"zig\"\ndetect = [\"build.zig\"]\n[phases]\nformat = \"zig fmt .\"\n",
)
.unwrap();
assert_eq!(p.phases.format.as_deref(), Some("zig fmt ."));
let lc: PhaseCommands = toml::from_str(
"format = \"cargo fmt\"\ncheck = \"just check\"\nclean = \"just clean\"\n",
)
.unwrap();
assert_eq!(lc.check.as_deref(), Some("just check"));
}
}