use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PreCommitSystem {
Prek,
PreCommit,
Husky,
Lefthook,
None,
}
impl PreCommitSystem {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Prek => "prek",
Self::PreCommit => "pre-commit",
Self::Husky => "husky",
Self::Lefthook => "lefthook",
Self::None => "none",
}
}
}
impl std::fmt::Display for PreCommitSystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn detect_pre_commit_system(project_root: &Path) -> PreCommitSystem {
detect_pre_commit_system_with(project_root, prek_on_path())
}
#[must_use]
pub(crate) fn detect_pre_commit_system_with(
project_root: &Path,
prek_on_path: bool,
) -> PreCommitSystem {
let pre_commit_yaml = project_root.join(".pre-commit-config.yaml");
let pre_commit_yml = project_root.join(".pre-commit-config.yml");
let pre_commit_path = if pre_commit_yaml.is_file() {
Some(pre_commit_yaml)
} else if pre_commit_yml.is_file() {
Some(pre_commit_yml)
} else {
None
};
if let Some(pre_commit_path) = pre_commit_path {
if has_prek_marker(project_root, &pre_commit_path, prek_on_path) {
return PreCommitSystem::Prek;
}
return PreCommitSystem::PreCommit;
}
if has_husky(project_root) {
return PreCommitSystem::Husky;
}
if has_lefthook(project_root) {
return PreCommitSystem::Lefthook;
}
PreCommitSystem::None
}
fn has_prek_marker(project_root: &Path, pre_commit_path: &Path, prek_on_path: bool) -> bool {
if prek_on_path {
return true;
}
if mise_mentions_prek(project_root) {
return true;
}
pre_commit_yaml_mentions_prek(pre_commit_path)
}
fn prek_on_path() -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
if dir.join("prek").is_file() {
return true;
}
if dir.join("prek.exe").is_file() {
return true;
}
}
false
}
fn mise_mentions_prek(project_root: &Path) -> bool {
let candidates = [
project_root.join("mise.toml"),
project_root.join(".mise.toml"),
];
candidates.iter().any(|p| file_contains(p, "prek"))
}
fn pre_commit_yaml_mentions_prek(path: &Path) -> bool {
file_contains(path, "prek")
}
fn file_contains(path: &Path, needle: &str) -> bool {
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
content.contains(needle)
}
fn has_husky(project_root: &Path) -> bool {
if project_root.join(".husky").is_dir() {
return true;
}
package_json_has_husky_key(&project_root.join("package.json"))
}
fn package_json_has_husky_key(path: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
content.contains("\"husky\"")
}
fn has_lefthook(project_root: &Path) -> bool {
const LEFTHOOK_FILES: &[&str] = &[
"lefthook.yml",
"lefthook.yaml",
".lefthook.yml",
".lefthook.yaml",
];
LEFTHOOK_FILES
.iter()
.map(|name| project_root.join(name))
.any(|p: PathBuf| p.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn new_root() -> TempDir {
TempDir::new().expect("tempdir")
}
fn write_file(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(path, body).expect("write fixture");
}
fn snapshot_tree(root: &Path) -> Vec<(PathBuf, Vec<u8>)> {
fn walk(root: &Path, base: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
for entry in fs::read_dir(root).expect("read_dir") {
let entry = entry.expect("entry");
let path = entry.path();
let rel = path.strip_prefix(base).expect("strip prefix").to_path_buf();
if path.is_dir() {
walk(&path, base, out);
} else if path.is_file() {
let bytes = fs::read(&path).expect("read");
out.push((rel, bytes));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[test]
fn empty_repo_returns_none() {
let tmp = new_root();
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::None,
);
}
#[test]
fn pre_commit_config_alone_returns_pre_commit() {
let tmp = new_root();
write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::PreCommit,
);
}
#[test]
fn pre_commit_config_with_prek_in_yaml_returns_prek() {
let tmp = new_root();
write_file(
tmp.path(),
".pre-commit-config.yaml",
"# prek: enabled\nrepos: []\n",
);
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Prek,
);
}
#[test]
fn prek_on_path_promotes_pre_commit_to_prek() {
let tmp = new_root();
write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), true),
PreCommitSystem::Prek,
);
}
#[test]
fn mise_mentioning_prek_returns_prek() {
let tmp = new_root();
write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
write_file(tmp.path(), "mise.toml", "[tools]\nprek = \"latest\"\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Prek,
);
}
#[test]
fn dot_mise_mentioning_prek_returns_prek() {
let tmp = new_root();
write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
write_file(tmp.path(), ".mise.toml", "[tools]\nprek = \"latest\"\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Prek,
);
}
#[test]
fn husky_directory_returns_husky() {
let tmp = new_root();
fs::create_dir(tmp.path().join(".husky")).expect("create .husky");
write_file(tmp.path(), ".husky/pre-commit", "echo hi\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Husky,
);
}
#[test]
fn package_json_with_husky_key_returns_husky() {
let tmp = new_root();
write_file(
tmp.path(),
"package.json",
"{\n \"name\": \"foo\",\n \"husky\": {}\n}\n",
);
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Husky,
);
}
#[test]
fn lefthook_yml_returns_lefthook() {
let tmp = new_root();
write_file(tmp.path(), "lefthook.yml", "pre-commit:\n commands: {}\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Lefthook,
);
}
#[test]
fn dot_lefthook_yaml_returns_lefthook() {
let tmp = new_root();
write_file(tmp.path(), ".lefthook.yaml", "pre-commit: {}\n");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::Lefthook,
);
}
#[test]
fn detection_is_read_only_for_every_variant() {
let cases: &[(&str, &[(&str, &str)])] = &[
("none", &[]),
("pre_commit", &[(".pre-commit-config.yaml", "repos: []\n")]),
(
"prek",
&[(".pre-commit-config.yaml", "# prek\nrepos: []\n")],
),
("husky", &[("package.json", "{\"husky\": {}}\n")]),
("lefthook", &[("lefthook.yml", "pre-commit: {}\n")]),
];
for (label, files) in cases {
let tmp = new_root();
for (rel, body) in *files {
write_file(tmp.path(), rel, body);
}
let before = snapshot_tree(tmp.path());
let _ = detect_pre_commit_system_with(tmp.path(), false);
let after = snapshot_tree(tmp.path());
assert_eq!(before, after, "detector mutated tree for fixture: {label}");
}
}
#[test]
fn pre_commit_classification_overrides_husky() {
let tmp = new_root();
write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
fs::create_dir(tmp.path().join(".husky")).expect("create .husky");
assert_eq!(
detect_pre_commit_system_with(tmp.path(), false),
PreCommitSystem::PreCommit,
);
}
#[test]
fn pre_commit_system_as_str_round_trips() {
for variant in [
PreCommitSystem::Prek,
PreCommitSystem::PreCommit,
PreCommitSystem::Husky,
PreCommitSystem::Lefthook,
PreCommitSystem::None,
] {
assert_eq!(format!("{variant}"), variant.as_str());
}
}
}