ito_core/validate_repo/pre_commit_detect.rs
1//! Detect which pre-commit hook framework, if any, is wired into a project.
2//!
3//! The detector is read-only: it inspects filesystem markers under the
4//! project root and returns a [`PreCommitSystem`] enum value. It never
5//! installs anything, modifies files, or contacts the network.
6//!
7//! Detection order (deterministic; later checks only run if earlier checks
8//! did not match):
9//!
10//! 1. **`Prek`** — `.pre-commit-config.yaml` is present AND a prek-specific
11//! marker is present (`prek` resolvable on `PATH`, a `mise.toml` mentioning
12//! `prek`, or a `prek:` toolchain hint inside `.pre-commit-config.yaml`).
13//! 2. **`PreCommit`** — `.pre-commit-config.yaml` exists but no prek marker
14//! is present.
15//! 3. **`Husky`** — `.husky/` directory exists at the project root, OR
16//! `package.json` contains a top-level `husky` key.
17//! 4. **`Lefthook`** — any of `lefthook.{yml,yaml}` or `.lefthook.{yml,yaml}`
18//! exists at the project root.
19//! 5. **`None`** — no markers found.
20//!
21//! Used by the `ito init` advisory for explicit downstream hook setup.
22
23use std::path::{Path, PathBuf};
24
25/// The pre-commit hook framework detected in a project.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum PreCommitSystem {
28 /// `prek` is wired up (`.pre-commit-config.yaml` plus a prek toolchain marker).
29 Prek,
30 /// Plain `pre-commit` (`.pre-commit-config.yaml` only, no prek markers).
31 PreCommit,
32 /// `Husky` (npm/yarn/pnpm Git hooks) — `.husky/` or `package.json` `husky` key.
33 Husky,
34 /// `lefthook` — any `lefthook.{yml,yaml}` or `.lefthook.{yml,yaml}`.
35 Lefthook,
36 /// No pre-commit framework detected.
37 None,
38}
39
40impl PreCommitSystem {
41 /// Short, lowercase identifier suitable for log lines and CLI output.
42 #[must_use]
43 pub const fn as_str(self) -> &'static str {
44 match self {
45 Self::Prek => "prek",
46 Self::PreCommit => "pre-commit",
47 Self::Husky => "husky",
48 Self::Lefthook => "lefthook",
49 Self::None => "none",
50 }
51 }
52}
53
54impl std::fmt::Display for PreCommitSystem {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60/// Detect the pre-commit framework wired into the project at `project_root`.
61///
62/// Pure function over filesystem reads plus a single `PATH` lookup for the
63/// `prek` binary. See the module-level documentation for the detection
64/// order.
65#[must_use]
66pub fn detect_pre_commit_system(project_root: &Path) -> PreCommitSystem {
67 detect_pre_commit_system_with(project_root, prek_on_path())
68}
69
70/// Detect the pre-commit framework with an explicit answer for "is `prek`
71/// on PATH?".
72///
73/// `pub(crate)` so unit tests across the `validate_repo` module tree can
74/// avoid mutating the global `PATH` environment variable during parallel
75/// test runs. External consumers should call [`detect_pre_commit_system`].
76#[must_use]
77pub(crate) fn detect_pre_commit_system_with(
78 project_root: &Path,
79 prek_on_path: bool,
80) -> PreCommitSystem {
81 let pre_commit_yaml = project_root.join(".pre-commit-config.yaml");
82 let pre_commit_yml = project_root.join(".pre-commit-config.yml");
83
84 let pre_commit_path = if pre_commit_yaml.is_file() {
85 Some(pre_commit_yaml)
86 } else if pre_commit_yml.is_file() {
87 Some(pre_commit_yml)
88 } else {
89 None
90 };
91
92 if let Some(pre_commit_path) = pre_commit_path {
93 if has_prek_marker(project_root, &pre_commit_path, prek_on_path) {
94 return PreCommitSystem::Prek;
95 }
96 return PreCommitSystem::PreCommit;
97 }
98
99 if has_husky(project_root) {
100 return PreCommitSystem::Husky;
101 }
102
103 if has_lefthook(project_root) {
104 return PreCommitSystem::Lefthook;
105 }
106
107 PreCommitSystem::None
108}
109
110/// True when any of the prek-specific markers are present.
111fn has_prek_marker(project_root: &Path, pre_commit_path: &Path, prek_on_path: bool) -> bool {
112 if prek_on_path {
113 return true;
114 }
115 if mise_mentions_prek(project_root) {
116 return true;
117 }
118 pre_commit_yaml_mentions_prek(pre_commit_path)
119}
120
121/// True when the `prek` binary is found on `PATH`.
122///
123/// Implemented manually rather than via the `which` crate to avoid an extra
124/// dependency for a single use-site.
125fn prek_on_path() -> bool {
126 let Some(path) = std::env::var_os("PATH") else {
127 return false;
128 };
129 for dir in std::env::split_paths(&path) {
130 if dir.join("prek").is_file() {
131 return true;
132 }
133 // Windows: also check `.exe`.
134 if dir.join("prek.exe").is_file() {
135 return true;
136 }
137 }
138 false
139}
140
141/// True when `mise.toml` or `.mise.toml` at `project_root` mentions `prek`.
142fn mise_mentions_prek(project_root: &Path) -> bool {
143 let candidates = [
144 project_root.join("mise.toml"),
145 project_root.join(".mise.toml"),
146 ];
147 candidates.iter().any(|p| file_contains(p, "prek"))
148}
149
150/// True when the pre-commit config file at `path` contains a `prek` mention.
151///
152/// We deliberately use a substring check rather than a full YAML parse: the
153/// detector is called in tight loops (e.g. once per `ito init`) and a string
154/// scan is cheap. False positives only land projects on the slightly more
155/// strict "Prek" path, which is harmless when the user is running plain
156/// pre-commit.
157fn pre_commit_yaml_mentions_prek(path: &Path) -> bool {
158 file_contains(path, "prek")
159}
160
161/// True when `path` is a regular file that contains `needle` as a substring.
162///
163/// Read errors and missing files return `false`.
164fn file_contains(path: &Path, needle: &str) -> bool {
165 let Ok(content) = std::fs::read_to_string(path) else {
166 return false;
167 };
168 content.contains(needle)
169}
170
171/// True when Husky markers are present.
172fn has_husky(project_root: &Path) -> bool {
173 if project_root.join(".husky").is_dir() {
174 return true;
175 }
176 package_json_has_husky_key(&project_root.join("package.json"))
177}
178
179/// True when `package.json` contains a top-level `husky` key.
180///
181/// Substring scan with a guard for the `"husky":` token. We avoid pulling
182/// in a JSON parser for a one-off check; nested occurrences (e.g. inside
183/// `devDependencies`) would also match, which still signals a Husky-using
184/// project.
185fn package_json_has_husky_key(path: &Path) -> bool {
186 let Ok(content) = std::fs::read_to_string(path) else {
187 return false;
188 };
189 content.contains("\"husky\"")
190}
191
192/// True when any lefthook configuration file is present at `project_root`.
193fn has_lefthook(project_root: &Path) -> bool {
194 const LEFTHOOK_FILES: &[&str] = &[
195 "lefthook.yml",
196 "lefthook.yaml",
197 ".lefthook.yml",
198 ".lefthook.yaml",
199 ];
200 LEFTHOOK_FILES
201 .iter()
202 .map(|name| project_root.join(name))
203 .any(|p: PathBuf| p.is_file())
204}
205
206#[cfg(test)]
207#[path = "pre_commit_detect_tests.rs"]
208mod pre_commit_detect_tests;