use std::path::Path;
use super::detect::{first_existing_file, hook_to_trigger};
use super::plan::{
ChainSupport, DetectedGate, GateType, HookStage, ProposedCheck, ProposedCheckSource,
TriggerKind,
};
const LEFTHOOK_FILES: &[&str] = &["lefthook.yml", "lefthook.yaml"];
const KNOWN_META_KEYS: &[&str] = &[
"output",
"output_format",
"skip_output",
"settings",
"extends",
"assert_lefthook_installed",
];
pub fn detect(repo_root: &Path) -> std::io::Result<Vec<DetectedGate>> {
let candidate = match first_existing_file(repo_root, LEFTHOOK_FILES) {
Some(p) => p,
None => return Ok(vec![]),
};
let body = std::fs::read_to_string(&candidate)?;
let gate = build_gate(candidate, &body);
Ok(vec![gate])
}
fn build_gate(source_path: std::path::PathBuf, body: &str) -> DetectedGate {
let ParseResult {
checks,
warnings,
has_recognised_stanza,
} = parse_lefthook(body);
let instructions =
"Add `klasp install` to your CI pipeline. To chain klasp into Lefthook, append \
a `klasp gate` command entry under the relevant hook stanza in `lefthook.yml`. \
See https://github.com/klasp-dev/klasp for details."
.to_string();
let mut gate_warnings = warnings;
if !has_recognised_stanza {
gate_warnings.push(
"lefthook.yml contains no recognised pre-commit / pre-push hook stanza; \
review manually and add shell checks to klasp.toml"
.to_string(),
);
}
DetectedGate {
gate_type: GateType::Lefthook,
source_path,
proposed_checks: checks,
chain_support: ChainSupport::ManualOnly,
manual_chain_instructions: Some(instructions),
warnings: gate_warnings,
}
}
struct ParseResult {
checks: Vec<ProposedCheck>,
warnings: Vec<String>,
has_recognised_stanza: bool,
}
fn parse_lefthook(body: &str) -> ParseResult {
enum State {
Idle,
InHook { trigger: TriggerKind },
InCommands { trigger: TriggerKind },
InEntry { trigger: TriggerKind, name: String },
}
let mut state = State::Idle;
let mut checks = Vec::new();
let mut warnings = Vec::new();
let mut has_recognised_stanza = false;
for line in body.lines() {
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let indent = leading_spaces(line);
let trimmed = line.trim();
if indent == 0 {
if let Some(stage) = hook_stage(trimmed) {
has_recognised_stanza = true;
state = State::InHook {
trigger: hook_to_trigger(stage),
};
continue;
}
if KNOWN_META_KEYS
.iter()
.any(|k| trimmed == *k || trimmed.starts_with(&format!("{k}:")))
{
state = State::Idle;
continue;
}
state = State::Idle;
continue;
}
match &state {
State::Idle => {}
State::InHook { trigger } => {
let trigger = *trigger;
if trimmed == "commands:" || trimmed == "commands: {}" {
state = State::InCommands { trigger };
}
}
State::InCommands { trigger } => {
let trigger = *trigger;
if let Some(name) = extract_key_name(trimmed) {
if !name.is_empty() {
state = State::InEntry {
trigger,
name: name.to_string(),
};
}
}
}
State::InEntry { trigger, name } => {
let trigger = *trigger;
let name = name.clone();
if let Some(run_val) = trimmed.strip_prefix("run: ").map(str::trim) {
let run_val = run_val.trim_matches('"').trim_matches('\'');
if run_val.contains("{{") && run_val.contains("}}") {
warnings.push(format!(
"lefthook command `{name}` uses Go template syntax \
(`{run_val}`); klasp cannot expand templates — \
verify the generated command manually"
));
}
checks.push(ProposedCheck {
name: name.clone(),
triggers: vec![trigger],
timeout_secs: 120,
source: ProposedCheckSource::Shell {
command: run_val.to_string(),
},
});
}
if indent <= 4 {
if let Some(new_name) = extract_key_name(trimmed) {
if !new_name.is_empty() && new_name != "run" {
state = State::InEntry {
trigger,
name: new_name.to_string(),
};
}
}
}
}
}
}
ParseResult {
checks,
warnings,
has_recognised_stanza,
}
}
fn hook_stage(trimmed: &str) -> Option<HookStage> {
if trimmed == "pre-commit:" {
Some(HookStage::PreCommit)
} else if trimmed == "pre-push:" {
Some(HookStage::PrePush)
} else {
None
}
}
fn leading_spaces(line: &str) -> usize {
line.chars().take_while(|c| *c == ' ').count()
}
fn extract_key_name(trimmed: &str) -> Option<&str> {
let colon_pos = trimmed.find(':')?;
Some(trimmed[..colon_pos].trim())
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn write_lefthook(dir: &std::path::Path, filename: &str, body: &str) {
fs::write(dir.join(filename), body).unwrap();
}
#[test]
fn no_lefthook_file_returns_empty() {
let dir = TempDir::new().unwrap();
let result = detect(dir.path()).unwrap();
assert!(result.is_empty());
}
#[test]
fn simple_pre_commit_block_two_checks() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"pre-commit:\n commands:\n lint:\n run: pnpm lint\n test:\n run: pnpm test\n",
);
let result = detect(dir.path()).unwrap();
assert_eq!(result.len(), 1);
let gate = &result[0];
assert_eq!(gate.proposed_checks.len(), 2);
let lint = gate
.proposed_checks
.iter()
.find(|c| c.name == "lint")
.unwrap();
assert_eq!(lint.triggers, vec![TriggerKind::Commit]);
assert!(
matches!(&lint.source, ProposedCheckSource::Shell { command } if command == "pnpm lint")
);
let test = gate
.proposed_checks
.iter()
.find(|c| c.name == "test")
.unwrap();
assert_eq!(test.triggers, vec![TriggerKind::Commit]);
assert!(
matches!(&test.source, ProposedCheckSource::Shell { command } if command == "pnpm test")
);
}
#[test]
fn pre_push_block_yields_push_trigger() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"pre-push:\n commands:\n build:\n run: cargo build\n",
);
let result = detect(dir.path()).unwrap();
assert_eq!(result.len(), 1);
let gate = &result[0];
assert_eq!(gate.proposed_checks.len(), 1);
assert_eq!(gate.proposed_checks[0].triggers, vec![TriggerKind::Push]);
}
#[test]
fn templated_run_emits_warning_and_check() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"pre-commit:\n commands:\n fmt:\n run: \"{{ .somevar }}\"\n",
);
let result = detect(dir.path()).unwrap();
let gate = &result[0];
assert!(gate.warnings.iter().any(|w| w.contains("template")));
assert_eq!(gate.proposed_checks.len(), 1);
assert!(
matches!(&gate.proposed_checks[0].source, ProposedCheckSource::Shell { command } if command.contains("{{ .somevar }}"))
);
}
#[test]
fn file_with_no_hook_stanza_has_warning_and_no_checks() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"output:\n - execution\n - skipped_hook\n",
);
let result = detect(dir.path()).unwrap();
let gate = &result[0];
assert!(gate.proposed_checks.is_empty());
assert!(gate.warnings.iter().any(|w| w.contains("no recognised")));
}
#[test]
fn prefers_yml_over_yaml() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"pre-commit:\n commands:\n lint:\n run: pnpm lint\n",
);
write_lefthook(
dir.path(),
"lefthook.yaml",
"pre-push:\n commands:\n test:\n run: pnpm test\n",
);
let result = detect(dir.path()).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0]
.source_path
.to_string_lossy()
.ends_with("lefthook.yml"));
}
#[test]
fn chain_support_is_manual_only() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yml",
"pre-commit:\n commands:\n lint:\n run: eslint .\n",
);
let result = detect(dir.path()).unwrap();
assert!(matches!(result[0].chain_support, ChainSupport::ManualOnly));
}
#[test]
fn yaml_extension_also_detected() {
let dir = TempDir::new().unwrap();
write_lefthook(
dir.path(),
"lefthook.yaml",
"pre-push:\n commands:\n typecheck:\n run: tsc --noEmit\n",
);
let result = detect(dir.path()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].proposed_checks.len(), 1);
assert_eq!(result[0].proposed_checks[0].name, "typecheck");
}
}