use std::io;
use std::path::Path;
use crate::adopt::detect::first_existing_file;
use crate::adopt::plan::{
ChainSupport, DetectedGate, GateType, ProposedCheck, ProposedCheckSource, TriggerKind,
};
const PRE_COMMIT_GENERATED_MARKER: &str = "# Generated by pre-commit";
const CONFIG_YAML: &str = ".pre-commit-config.yaml";
const CONFIG_YML: &str = ".pre-commit-config.yml";
pub fn detect(repo_root: &Path) -> io::Result<Vec<DetectedGate>> {
let config_path = match first_existing_file(repo_root, &[CONFIG_YAML, CONFIG_YML]) {
Some(p) => p,
None => return Ok(vec![]),
};
let warnings = build_warnings(repo_root)?;
let gate = DetectedGate {
gate_type: GateType::PreCommitFramework,
source_path: config_path,
proposed_checks: vec![proposed_check()],
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: Some(manual_instructions()),
warnings,
};
Ok(vec![gate])
}
fn build_warnings(repo_root: &Path) -> io::Result<Vec<String>> {
let hook_path = repo_root.join(".git/hooks/pre-commit");
let contents = match std::fs::read_to_string(&hook_path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
Err(e) => return Err(e),
};
if contents.contains(PRE_COMMIT_GENERATED_MARKER) {
return Ok(vec![
"running both pre-commit's git hook AND klasp's `pre_commit` recipe will execute \
hooks twice; choose one: either keep pre-commit's hook as the single source of truth \
and remove the `pre_commit` check from klasp.toml, or uninstall pre-commit's shim \
(`pre-commit uninstall`) and let klasp own the git hook."
.to_string(),
]);
}
Ok(vec![])
}
fn proposed_check() -> ProposedCheck {
ProposedCheck {
name: "pre-commit".to_string(),
triggers: vec![TriggerKind::Commit],
timeout_secs: 120,
source: ProposedCheckSource::PreCommit {
hook_stage: None,
config_path: None,
},
}
}
fn manual_instructions() -> String {
"To run klasp inside pre-commit's flow: add a local hook in \
`.pre-commit-config.yaml` that calls `klasp gate`. To run pre-commit \
inside klasp: the proposed `type = \"pre_commit\"` check already does this. \
Do not enable both simultaneously or hooks will fire twice."
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn empty_repo_yields_no_findings() {
let dir = tempfile::tempdir().unwrap();
let findings = detect(dir.path()).unwrap();
assert!(findings.is_empty(), "expected no findings in empty repo");
}
#[test]
fn yaml_config_yields_one_finding() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert_eq!(findings.len(), 1);
let gate = &findings[0];
assert_eq!(gate.gate_type, GateType::PreCommitFramework);
assert_eq!(gate.source_path, dir.path().join(CONFIG_YAML));
}
#[test]
fn yml_config_yields_one_finding() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].source_path, dir.path().join(CONFIG_YML));
}
#[test]
fn prefers_yaml_over_yml_when_both_present() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
fs::write(dir.path().join(CONFIG_YML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert_eq!(findings.len(), 1, "must not produce two findings");
assert_eq!(
findings[0].source_path,
dir.path().join(CONFIG_YAML),
"must prefer .yaml over .yml"
);
}
#[test]
fn proposed_check_has_pre_commit_source_type() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
let check = &findings[0].proposed_checks[0];
assert_eq!(check.name, "pre-commit");
assert_eq!(check.triggers, vec![TriggerKind::Commit]);
assert_eq!(check.timeout_secs, 120);
assert!(
matches!(
&check.source,
ProposedCheckSource::PreCommit {
hook_stage: None,
config_path: None
}
),
"source must be PreCommit with no overrides"
);
}
#[test]
fn chain_support_is_manual_only() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert_eq!(findings[0].chain_support, ChainSupport::ManualOnly);
assert!(
findings[0].manual_chain_instructions.is_some(),
"manual instructions must be set when chain_support = ManualOnly"
);
}
#[test]
fn duplicate_execution_warning_fires_when_hook_has_marker() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let hooks_dir = dir.path().join(".git/hooks");
fs::create_dir_all(&hooks_dir).unwrap();
fs::write(
hooks_dir.join("pre-commit"),
"#!/bin/sh\n# Generated by pre-commit\nexec pre-commit run\n",
)
.unwrap();
let findings = detect(dir.path()).unwrap();
assert_eq!(findings.len(), 1);
assert!(
!findings[0].warnings.is_empty(),
"should warn about duplicate execution"
);
assert!(
findings[0].warnings[0].contains("twice"),
"warning should mention duplicate execution: {}",
findings[0].warnings[0]
);
}
#[test]
fn no_duplicate_warning_when_hook_is_not_pre_commit_generated() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let hooks_dir = dir.path().join(".git/hooks");
fs::create_dir_all(&hooks_dir).unwrap();
fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert!(
findings[0].warnings.is_empty(),
"should not warn when hook is not pre-commit-generated"
);
}
#[test]
fn no_duplicate_warning_when_git_hook_absent() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
let findings = detect(dir.path()).unwrap();
assert!(
findings[0].warnings.is_empty(),
"no warning when .git/hooks/pre-commit does not exist"
);
}
}