use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use klasp_core::{
CheckConfig, CheckResult, CheckSource, CheckSourceConfig, CheckSourceError, Finding, GitEvent,
RepoState, Severity, Verdict,
};
use super::shell::{run_with_timeout, ShellOutcome, DEFAULT_TIMEOUT_SECS};
const SOURCE_ID: &str = "pre_commit";
const DEFAULT_HOOK_STAGE: &str = "pre-commit";
#[allow(dead_code)]
const DEFAULT_CONFIG_PATH: &str = ".pre-commit-config.yaml";
const MIN_SUPPORTED_VERSION: (u32, u32) = (3, 0);
#[derive(Default)]
pub struct PreCommitSource {
_private: (),
}
impl PreCommitSource {
pub const fn new() -> Self {
Self { _private: () }
}
}
impl CheckSource for PreCommitSource {
fn source_id(&self) -> &str {
SOURCE_ID
}
fn supports_config(&self, config: &CheckConfig) -> bool {
matches!(config.source, CheckSourceConfig::PreCommit { .. })
}
fn run(
&self,
config: &CheckConfig,
state: &RepoState,
) -> Result<CheckResult, CheckSourceError> {
let (hook_stage, config_path) = match &config.source {
CheckSourceConfig::PreCommit {
hook_stage,
config_path,
} => (
hook_stage
.as_deref()
.unwrap_or(DEFAULT_HOOK_STAGE)
.to_string(),
config_path.clone(),
),
other => {
return Err(CheckSourceError::Other(
format!("PreCommitSource cannot run {other:?}").into(),
));
}
};
let command = build_command(state.git_event, &hook_stage, config_path.as_deref());
let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
let outcome = run_with_timeout(&command, &state.root, &state.base_ref, timeout)?;
let version_warning = sniff_version_warning(&state.root);
let verdict = outcome_to_verdict(&config.name, &outcome, version_warning.as_deref());
Ok(CheckResult {
source_id: SOURCE_ID.to_string(),
check_name: config.name.clone(),
verdict,
raw_stdout: Some(outcome.stdout),
raw_stderr: Some(outcome.stderr),
})
}
}
fn build_command(
event: GitEvent,
hook_stage: &str,
config_path: Option<&std::path::Path>,
) -> String {
let mut parts: Vec<String> = vec!["pre-commit".into(), "run".into()];
parts.push("--hook-stage".into());
match event {
GitEvent::Commit => {
parts.push(shell_quote(hook_stage));
}
GitEvent::Push => {
parts.push(shell_quote(hook_stage));
parts.push("--from-ref".into());
parts.push("${KLASP_BASE_REF}".into());
parts.push("--to-ref".into());
parts.push("HEAD".into());
}
}
if let Some(path) = config_path {
parts.push("-c".into());
parts.push(shell_quote(&path.to_string_lossy()));
}
parts.join(" ")
}
fn shell_quote(value: &str) -> String {
let escaped = value.replace('\'', "'\\''");
format!("'{escaped}'")
}
fn outcome_to_verdict(
check_name: &str,
outcome: &ShellOutcome,
version_warning: Option<&str>,
) -> Verdict {
match outcome.status_code {
Some(0) => match version_warning {
None => Verdict::Pass,
Some(warning) => Verdict::Warn {
findings: vec![finding(check_name, warning, Severity::Warn)],
message: Some(warning.to_string()),
},
},
Some(1) => {
let mut findings = parse_failed_hooks(check_name, &outcome.stdout);
if findings.is_empty() {
let trimmed = outcome.stderr.trim();
let detail = if trimmed.is_empty() {
format!("pre-commit reported failures for check `{check_name}`")
} else {
format!("pre-commit reported failures for check `{check_name}`: {trimmed}")
};
findings.push(finding(check_name, &detail, Severity::Error));
}
if let Some(warning) = version_warning {
findings.insert(0, finding(check_name, warning, Severity::Warn));
}
let hook_failures = findings
.iter()
.filter(|f| matches!(f.severity, Severity::Error))
.count();
let message = format!(
"pre-commit failed ({} hook{})",
hook_failures,
if hook_failures == 1 { "" } else { "s" }
);
Verdict::Fail { findings, message }
}
Some(other) => {
let trimmed = outcome.stderr.trim();
let detail = if trimmed.is_empty() {
format!(
"pre-commit `{check_name}` exited with unexpected status {other}; \
this usually means a tooling error inside pre-commit itself"
)
} else {
format!(
"pre-commit `{check_name}` exited with unexpected status \
{other}: {trimmed}"
)
};
fail_with_optional_warning(check_name, detail, version_warning)
}
None => fail_with_optional_warning(
check_name,
format!("pre-commit `{check_name}` was terminated before producing an exit code"),
version_warning,
),
}
}
fn fail_with_optional_warning(
check_name: &str,
detail: String,
version_warning: Option<&str>,
) -> Verdict {
let mut findings = vec![finding(check_name, &detail, Severity::Error)];
if let Some(warning) = version_warning {
findings.insert(0, finding(check_name, warning, Severity::Warn));
}
Verdict::Fail {
findings,
message: detail,
}
}
fn finding(check_name: &str, message: &str, severity: Severity) -> Finding {
Finding {
rule: format!("pre_commit:{check_name}"),
message: message.to_string(),
file: None,
line: None,
severity,
}
}
fn parse_failed_hooks(check_name: &str, stdout: &str) -> Vec<Finding> {
stdout
.lines()
.filter_map(|line| {
let line = line.trim_end();
let head = line.strip_suffix("Failed")?;
let head = head.trim_end_matches(|c: char| c == '.' || c.is_whitespace());
(!head.is_empty()).then(|| {
finding(
check_name,
&format!("hook `{head}` failed"),
Severity::Error,
)
})
})
.collect()
}
fn sniff_version_warning(cwd: &std::path::Path) -> Option<String> {
static CACHED: OnceLock<Option<String>> = OnceLock::new();
CACHED
.get_or_init(|| sniff_version_warning_uncached(cwd))
.clone()
}
fn sniff_version_warning_uncached(cwd: &std::path::Path) -> Option<String> {
let output = Command::new("pre-commit")
.arg("--version")
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).to_string();
let (major, minor) = parse_version(&raw)?;
if (major, minor) < MIN_SUPPORTED_VERSION {
let (rmaj, rmin) = MIN_SUPPORTED_VERSION;
return Some(format!(
"pre-commit {major}.{minor} is older than the minimum tested version \
{rmaj}.{rmin}; output parsing may be incomplete"
));
}
None
}
fn parse_version(raw: &str) -> Option<(u32, u32)> {
let line = raw.lines().find(|l| !l.trim().is_empty())?;
let token = line.split_whitespace().last()?;
let mut parts = token.split('.');
let major = parts.next()?.parse::<u32>().ok()?;
let minor = parts.next()?.parse::<u32>().ok()?;
Some((major, minor))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use klasp_core::{CheckConfig, CheckSourceConfig};
use super::*;
fn pre_commit_check() -> CheckConfig {
CheckConfig {
name: "lint".into(),
triggers: vec![],
source: CheckSourceConfig::PreCommit {
hook_stage: None,
config_path: None,
},
timeout_secs: None,
}
}
fn shell_check() -> CheckConfig {
CheckConfig {
name: "shell".into(),
triggers: vec![],
source: CheckSourceConfig::Shell {
command: "true".into(),
},
timeout_secs: None,
}
}
fn outcome(code: Option<i32>, stdout: &str, stderr: &str) -> ShellOutcome {
ShellOutcome {
status_code: code,
stdout: stdout.into(),
stderr: stderr.into(),
}
}
#[test]
fn supports_config_only_for_pre_commit() {
let source = PreCommitSource::new();
assert!(source.supports_config(&pre_commit_check()));
assert!(!source.supports_config(&shell_check()));
}
#[test]
fn build_command_commit_trigger_omits_ref_range() {
let cmd = build_command(GitEvent::Commit, "pre-commit", None);
assert_eq!(cmd, "pre-commit run --hook-stage 'pre-commit'");
assert!(
!cmd.contains("--from-ref"),
"commit trigger must not contain --from-ref: {cmd}"
);
assert!(
!cmd.contains("--to-ref"),
"commit trigger must not contain --to-ref: {cmd}"
);
}
#[test]
fn build_command_push_trigger_includes_ref_range() {
let cmd = build_command(GitEvent::Push, "pre-commit", None);
assert_eq!(
cmd,
"pre-commit run --hook-stage 'pre-commit' --from-ref ${KLASP_BASE_REF} --to-ref HEAD"
);
}
#[test]
fn build_command_push_trigger_passes_config_path() {
let cmd = build_command(GitEvent::Push, "pre-push", Some(Path::new("tools/p.yaml")));
assert_eq!(
cmd,
"pre-commit run --hook-stage 'pre-push' --from-ref ${KLASP_BASE_REF} --to-ref HEAD \
-c 'tools/p.yaml'"
);
}
#[test]
fn build_command_commit_trigger_passes_config_path() {
let cmd = build_command(
GitEvent::Commit,
"pre-commit",
Some(Path::new("tools/p.yaml")),
);
assert_eq!(
cmd,
"pre-commit run --hook-stage 'pre-commit' -c 'tools/p.yaml'"
);
}
#[test]
fn shell_quote_handles_embedded_single_quotes() {
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
}
#[test]
fn outcome_zero_with_version_warning_is_warn() {
let v = outcome_to_verdict("lint", &outcome(Some(0), "", ""), Some("too new"));
match v {
Verdict::Warn { findings, message } => {
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warn);
assert_eq!(message.as_deref(), Some("too new"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn outcome_one_with_failed_hooks_yields_findings() {
let stdout = concat!(
"trim trailing whitespace.................................................Passed\n",
"ruff.....................................................................Failed\n",
"mypy.....................................................................Failed\n",
);
let v = outcome_to_verdict("lint", &outcome(Some(1), stdout, ""), None);
match v {
Verdict::Fail { findings, message } => {
assert_eq!(findings.len(), 2);
assert!(findings[0].message.contains("ruff"));
assert!(findings[1].message.contains("mypy"));
assert!(message.contains("2 hooks"));
assert_eq!(findings[0].rule, "pre_commit:lint");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn outcome_one_without_parseable_stdout_falls_back_to_generic_finding() {
let v = outcome_to_verdict("lint", &outcome(Some(1), "", "boom"), None);
match v {
Verdict::Fail { findings, message } => {
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("boom"));
assert!(message.contains("1 hook"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn outcome_unexpected_exit_code_carries_status_in_message() {
let v = outcome_to_verdict("lint", &outcome(Some(130), "", "Interrupted"), None);
match v {
Verdict::Fail { message, .. } => {
assert!(message.contains("130"), "message = {message}");
assert!(message.contains("Interrupted"));
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn outcome_no_exit_code_is_fail() {
let v = outcome_to_verdict("lint", &outcome(None, "", ""), None);
assert!(matches!(v, Verdict::Fail { .. }));
}
#[test]
fn parse_version_extracts_major_minor() {
assert_eq!(parse_version("pre-commit 3.8.0"), Some((3, 8)));
assert_eq!(parse_version("pre-commit 4.0.1\n"), Some((4, 0)));
assert_eq!(parse_version(""), None);
assert_eq!(parse_version("not a version"), None);
}
#[test]
fn parse_failed_hooks_handles_passed_and_skipped() {
let stdout = concat!(
"ruff.....................................................................Passed\n",
"mypy.....................................................................Skipped\n",
"black....................................................................Failed\n",
);
let findings = parse_failed_hooks("lint", stdout);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("black"));
}
#[test]
fn pre_commit_config_round_trips_path_buf() {
let c = CheckConfig {
name: "lint".into(),
triggers: vec![],
source: CheckSourceConfig::PreCommit {
hook_stage: Some("pre-push".into()),
config_path: Some(PathBuf::from("tools/p.yaml")),
},
timeout_secs: None,
};
match c.source {
CheckSourceConfig::PreCommit { config_path, .. } => {
assert_eq!(config_path.as_deref(), Some(Path::new("tools/p.yaml")));
}
_ => unreachable!(),
}
}
}