1use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum PreCommitSystem {
30 Prek,
32 PreCommit,
34 Husky,
36 Lefthook,
38 None,
40}
41
42impl PreCommitSystem {
43 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::Prek => "prek",
48 Self::PreCommit => "pre-commit",
49 Self::Husky => "husky",
50 Self::Lefthook => "lefthook",
51 Self::None => "none",
52 }
53 }
54}
55
56impl std::fmt::Display for PreCommitSystem {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(self.as_str())
59 }
60}
61
62#[must_use]
68pub fn detect_pre_commit_system(project_root: &Path) -> PreCommitSystem {
69 detect_pre_commit_system_with(project_root, prek_on_path())
70}
71
72#[must_use]
79pub(crate) fn detect_pre_commit_system_with(
80 project_root: &Path,
81 prek_on_path: bool,
82) -> PreCommitSystem {
83 let pre_commit_yaml = project_root.join(".pre-commit-config.yaml");
84 let pre_commit_yml = project_root.join(".pre-commit-config.yml");
85
86 let pre_commit_path = if pre_commit_yaml.is_file() {
87 Some(pre_commit_yaml)
88 } else if pre_commit_yml.is_file() {
89 Some(pre_commit_yml)
90 } else {
91 None
92 };
93
94 if let Some(pre_commit_path) = pre_commit_path {
95 if has_prek_marker(project_root, &pre_commit_path, prek_on_path) {
96 return PreCommitSystem::Prek;
97 }
98 return PreCommitSystem::PreCommit;
99 }
100
101 if has_husky(project_root) {
102 return PreCommitSystem::Husky;
103 }
104
105 if has_lefthook(project_root) {
106 return PreCommitSystem::Lefthook;
107 }
108
109 PreCommitSystem::None
110}
111
112fn has_prek_marker(project_root: &Path, pre_commit_path: &Path, prek_on_path: bool) -> bool {
114 if prek_on_path {
115 return true;
116 }
117 if mise_mentions_prek(project_root) {
118 return true;
119 }
120 pre_commit_yaml_mentions_prek(pre_commit_path)
121}
122
123fn prek_on_path() -> bool {
128 let Some(path) = std::env::var_os("PATH") else {
129 return false;
130 };
131 for dir in std::env::split_paths(&path) {
132 if dir.join("prek").is_file() {
133 return true;
134 }
135 if dir.join("prek.exe").is_file() {
137 return true;
138 }
139 }
140 false
141}
142
143fn mise_mentions_prek(project_root: &Path) -> bool {
145 let candidates = [
146 project_root.join("mise.toml"),
147 project_root.join(".mise.toml"),
148 ];
149 candidates.iter().any(|p| file_contains(p, "prek"))
150}
151
152fn pre_commit_yaml_mentions_prek(path: &Path) -> bool {
160 file_contains(path, "prek")
161}
162
163fn file_contains(path: &Path, needle: &str) -> bool {
167 let Ok(content) = std::fs::read_to_string(path) else {
168 return false;
169 };
170 content.contains(needle)
171}
172
173fn has_husky(project_root: &Path) -> bool {
175 if project_root.join(".husky").is_dir() {
176 return true;
177 }
178 package_json_has_husky_key(&project_root.join("package.json"))
179}
180
181fn package_json_has_husky_key(path: &Path) -> bool {
188 let Ok(content) = std::fs::read_to_string(path) else {
189 return false;
190 };
191 content.contains("\"husky\"")
192}
193
194fn has_lefthook(project_root: &Path) -> bool {
196 const LEFTHOOK_FILES: &[&str] = &[
197 "lefthook.yml",
198 "lefthook.yaml",
199 ".lefthook.yml",
200 ".lefthook.yaml",
201 ];
202 LEFTHOOK_FILES
203 .iter()
204 .map(|name| project_root.join(name))
205 .any(|p: PathBuf| p.is_file())
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::fs;
212 use tempfile::TempDir;
213
214 fn new_root() -> TempDir {
215 TempDir::new().expect("tempdir")
216 }
217
218 fn write_file(root: &Path, rel: &str, body: &str) {
219 let path = root.join(rel);
220 if let Some(parent) = path.parent() {
221 fs::create_dir_all(parent).expect("create parent");
222 }
223 fs::write(path, body).expect("write fixture");
224 }
225
226 fn snapshot_tree(root: &Path) -> Vec<(PathBuf, Vec<u8>)> {
227 fn walk(root: &Path, base: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
230 for entry in fs::read_dir(root).expect("read_dir") {
231 let entry = entry.expect("entry");
232 let path = entry.path();
233 let rel = path.strip_prefix(base).expect("strip prefix").to_path_buf();
234 if path.is_dir() {
235 walk(&path, base, out);
236 } else if path.is_file() {
237 let bytes = fs::read(&path).expect("read");
238 out.push((rel, bytes));
239 }
240 }
241 }
242 let mut out = Vec::new();
243 walk(root, root, &mut out);
244 out.sort_by(|a, b| a.0.cmp(&b.0));
245 out
246 }
247
248 #[test]
249 fn empty_repo_returns_none() {
250 let tmp = new_root();
251 assert_eq!(
252 detect_pre_commit_system_with(tmp.path(), false),
253 PreCommitSystem::None,
254 );
255 }
256
257 #[test]
258 fn pre_commit_config_alone_returns_pre_commit() {
259 let tmp = new_root();
260 write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
261 assert_eq!(
262 detect_pre_commit_system_with(tmp.path(), false),
263 PreCommitSystem::PreCommit,
264 );
265 }
266
267 #[test]
268 fn pre_commit_config_with_prek_in_yaml_returns_prek() {
269 let tmp = new_root();
270 write_file(
271 tmp.path(),
272 ".pre-commit-config.yaml",
273 "# prek: enabled\nrepos: []\n",
274 );
275 assert_eq!(
276 detect_pre_commit_system_with(tmp.path(), false),
277 PreCommitSystem::Prek,
278 );
279 }
280
281 #[test]
282 fn prek_on_path_promotes_pre_commit_to_prek() {
283 let tmp = new_root();
284 write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
285 assert_eq!(
286 detect_pre_commit_system_with(tmp.path(), true),
287 PreCommitSystem::Prek,
288 );
289 }
290
291 #[test]
292 fn mise_mentioning_prek_returns_prek() {
293 let tmp = new_root();
294 write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
295 write_file(tmp.path(), "mise.toml", "[tools]\nprek = \"latest\"\n");
296 assert_eq!(
297 detect_pre_commit_system_with(tmp.path(), false),
298 PreCommitSystem::Prek,
299 );
300 }
301
302 #[test]
303 fn dot_mise_mentioning_prek_returns_prek() {
304 let tmp = new_root();
305 write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
306 write_file(tmp.path(), ".mise.toml", "[tools]\nprek = \"latest\"\n");
307 assert_eq!(
308 detect_pre_commit_system_with(tmp.path(), false),
309 PreCommitSystem::Prek,
310 );
311 }
312
313 #[test]
314 fn husky_directory_returns_husky() {
315 let tmp = new_root();
316 fs::create_dir(tmp.path().join(".husky")).expect("create .husky");
317 write_file(tmp.path(), ".husky/pre-commit", "echo hi\n");
318 assert_eq!(
319 detect_pre_commit_system_with(tmp.path(), false),
320 PreCommitSystem::Husky,
321 );
322 }
323
324 #[test]
325 fn package_json_with_husky_key_returns_husky() {
326 let tmp = new_root();
327 write_file(
328 tmp.path(),
329 "package.json",
330 "{\n \"name\": \"foo\",\n \"husky\": {}\n}\n",
331 );
332 assert_eq!(
333 detect_pre_commit_system_with(tmp.path(), false),
334 PreCommitSystem::Husky,
335 );
336 }
337
338 #[test]
339 fn lefthook_yml_returns_lefthook() {
340 let tmp = new_root();
341 write_file(tmp.path(), "lefthook.yml", "pre-commit:\n commands: {}\n");
342 assert_eq!(
343 detect_pre_commit_system_with(tmp.path(), false),
344 PreCommitSystem::Lefthook,
345 );
346 }
347
348 #[test]
349 fn dot_lefthook_yaml_returns_lefthook() {
350 let tmp = new_root();
351 write_file(tmp.path(), ".lefthook.yaml", "pre-commit: {}\n");
352 assert_eq!(
353 detect_pre_commit_system_with(tmp.path(), false),
354 PreCommitSystem::Lefthook,
355 );
356 }
357
358 #[test]
359 fn detection_is_read_only_for_every_variant() {
360 let cases: &[(&str, &[(&str, &str)])] = &[
363 ("none", &[]),
364 ("pre_commit", &[(".pre-commit-config.yaml", "repos: []\n")]),
365 (
366 "prek",
367 &[(".pre-commit-config.yaml", "# prek\nrepos: []\n")],
368 ),
369 ("husky", &[("package.json", "{\"husky\": {}}\n")]),
370 ("lefthook", &[("lefthook.yml", "pre-commit: {}\n")]),
371 ];
372 for (label, files) in cases {
373 let tmp = new_root();
374 for (rel, body) in *files {
375 write_file(tmp.path(), rel, body);
376 }
377 let before = snapshot_tree(tmp.path());
378 let _ = detect_pre_commit_system_with(tmp.path(), false);
379 let after = snapshot_tree(tmp.path());
380 assert_eq!(before, after, "detector mutated tree for fixture: {label}");
381 }
382 }
383
384 #[test]
385 fn pre_commit_classification_overrides_husky() {
386 let tmp = new_root();
389 write_file(tmp.path(), ".pre-commit-config.yaml", "repos: []\n");
390 fs::create_dir(tmp.path().join(".husky")).expect("create .husky");
391 assert_eq!(
392 detect_pre_commit_system_with(tmp.path(), false),
393 PreCommitSystem::PreCommit,
394 );
395 }
396
397 #[test]
398 fn pre_commit_system_as_str_round_trips() {
399 for variant in [
400 PreCommitSystem::Prek,
401 PreCommitSystem::PreCommit,
402 PreCommitSystem::Husky,
403 PreCommitSystem::Lefthook,
404 PreCommitSystem::None,
405 ] {
406 assert_eq!(format!("{variant}"), variant.as_str());
407 }
408 }
409}