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 `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;