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 Wave 4 `ito init` advisory and by the `ito-update-repo`
22//! skill to decide which file to edit when wiring the `ito validate repo`
23//! pre-commit hook.
24
25use std::path::{Path, PathBuf};
26
27/// The pre-commit hook framework detected in a project.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum PreCommitSystem {
30 /// `prek` is wired up (`.pre-commit-config.yaml` plus a prek toolchain marker).
31 Prek,
32 /// Plain `pre-commit` (`.pre-commit-config.yaml` only, no prek markers).
33 PreCommit,
34 /// `Husky` (npm/yarn/pnpm Git hooks) — `.husky/` or `package.json` `husky` key.
35 Husky,
36 /// `lefthook` — any `lefthook.{yml,yaml}` or `.lefthook.{yml,yaml}`.
37 Lefthook,
38 /// No pre-commit framework detected.
39 None,
40}
41
42impl PreCommitSystem {
43 /// Short, lowercase identifier suitable for log lines and CLI output.
44 #[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/// Detect the pre-commit framework wired into the project at `project_root`.
63///
64/// Pure function over filesystem reads plus a single `PATH` lookup for the
65/// `prek` binary. See the module-level documentation for the detection
66/// order.
67#[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/// Detect the pre-commit framework with an explicit answer for "is `prek`
73/// on PATH?".
74///
75/// `pub(crate)` so unit tests across the `validate_repo` module tree can
76/// avoid mutating the global `PATH` environment variable during parallel
77/// test runs. External consumers should call [`detect_pre_commit_system`].
78#[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
112/// True when any of the prek-specific markers are present.
113fn 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
123/// True when the `prek` binary is found on `PATH`.
124///
125/// Implemented manually rather than via the `which` crate to avoid an extra
126/// dependency for a single use-site.
127fn 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 // Windows: also check `.exe`.
136 if dir.join("prek.exe").is_file() {
137 return true;
138 }
139 }
140 false
141}
142
143/// True when `mise.toml` or `.mise.toml` at `project_root` mentions `prek`.
144fn 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
152/// True when the pre-commit config file at `path` contains a `prek` mention.
153///
154/// We deliberately use a substring check rather than a full YAML parse: the
155/// detector is called in tight loops (e.g. once per `ito init`) and a string
156/// scan is cheap. False positives only land projects on the slightly more
157/// strict "Prek" path, which is harmless when the user is running plain
158/// pre-commit.
159fn pre_commit_yaml_mentions_prek(path: &Path) -> bool {
160 file_contains(path, "prek")
161}
162
163/// True when `path` is a regular file that contains `needle` as a substring.
164///
165/// Read errors and missing files return `false`.
166fn 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
173/// True when Husky markers are present.
174fn 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
181/// True when `package.json` contains a top-level `husky` key.
182///
183/// Substring scan with a guard for the `"husky":` token. We avoid pulling
184/// in a JSON parser for a one-off check; nested occurrences (e.g. inside
185/// `devDependencies`) would also match, which still signals a Husky-using
186/// project.
187fn 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
194/// True when any lefthook configuration file is present at `project_root`.
195fn 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)]
209#[path = "pre_commit_detect_tests.rs"]
210mod pre_commit_detect_tests;