Skip to main content

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)]
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        // Capture the tree contents so we can prove the detector did not
228        // mutate anything (pre-commit-hook-detection:read-only).
229        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        // Build one fixture per variant and confirm bytes are unchanged
361        // across the detector call.
362        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        // When both a husky directory AND a pre-commit config exist, the
387        // pre-commit branch wins per the documented detection order.
388        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}