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)]
209#[path = "pre_commit_detect_tests.rs"]
210mod pre_commit_detect_tests;