use std::io::{self, Read, Write};
use std::process::ExitCode;
use klasp_core::{
CheckConfig, ConfigV1, GateProtocol, GitEvent, RepoState, Trigger, Verdict, VerdictPolicy,
};
use crate::cli::GateArgs;
use crate::git;
use crate::sources::SourceRegistry;
const NOTICE_PREFIX: &str = "klasp-gate:";
pub fn run(_args: &GateArgs) -> ExitCode {
let mut stderr = io::stderr().lock();
match gate(&mut stderr) {
Outcome::Pass => ExitCode::SUCCESS,
Outcome::Block => ExitCode::from(2),
}
}
enum Outcome {
Pass,
Block,
}
fn gate<W: Write>(stderr: &mut W) -> Outcome {
match GateProtocol::read_schema_from_env() {
Ok(env_value) => {
if let Err(e) = GateProtocol::check_schema_env(env_value) {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} schema mismatch ({e}), skipping. \
Re-run `klasp install` to update the hook."
);
return Outcome::Pass;
}
}
Err(e) => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} could not read KLASP_GATE_SCHEMA ({e}), \
skipping. Re-run `klasp install` to regenerate the hook."
);
return Outcome::Pass;
}
}
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} could not read stdin ({e}), skipping."
);
return Outcome::Pass;
}
let input = match GateProtocol::parse(&buf) {
Ok(i) => i,
Err(e) => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} could not parse input ({e}), skipping."
);
return Outcome::Pass;
}
};
let command = match input.tool_input.command.as_deref() {
Some(c) => c,
None => return Outcome::Pass,
};
let event = match Trigger::classify(command) {
Some(e) => e,
None => return Outcome::Pass,
};
let repo_root = match git::find_repo_root_from_cwd() {
Some(r) => r,
None => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} could not resolve repo root, skipping."
);
return Outcome::Pass;
}
};
let config = match ConfigV1::load(&repo_root) {
Ok(c) => c,
Err(e) => {
let _ = writeln!(stderr, "{NOTICE_PREFIX} config error ({e}), skipping.");
return Outcome::Pass;
}
};
let registry = SourceRegistry::default_v1();
let base_ref = git::compute_base_ref(&repo_root);
let repo_state = RepoState {
root: repo_root,
git_event: event,
base_ref,
};
let mut verdicts: Vec<Verdict> = Vec::new();
for check in &config.checks {
if !triggers_match(check, event) {
continue;
}
let source = match registry.find_for(check) {
Some(s) => s,
None => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} no source registered for check `{}`, skipping.",
check.name,
);
continue;
}
};
match source.run(check, &repo_state) {
Ok(result) => verdicts.push(result.verdict),
Err(e) => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} check `{}` runtime error ({e}), skipping.",
check.name,
);
}
}
}
let final_verdict = Verdict::merge(verdicts, config.gate.policy);
render_terminal_summary(stderr, &final_verdict, config.gate.policy);
if final_verdict.is_blocking() {
Outcome::Block
} else {
Outcome::Pass
}
}
fn triggers_match(check: &CheckConfig, event: GitEvent) -> bool {
if check.triggers.is_empty() {
return true;
}
let needle = match event {
GitEvent::Commit => "commit",
GitEvent::Push => "push",
};
check
.triggers
.iter()
.any(|t| t.on.iter().any(|name| name == needle))
}
fn render_terminal_summary<W: Write>(stderr: &mut W, verdict: &Verdict, policy: VerdictPolicy) {
match verdict {
Verdict::Pass => {
}
Verdict::Warn { findings, message } => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} warnings ({} findings):",
findings.len()
);
if let Some(m) = message {
let _ = writeln!(stderr, " {m}");
}
for f in findings {
let _ = writeln!(stderr, " - [{}] {}", f.rule, f.message);
}
}
Verdict::Fail { findings, message } => {
let _ = writeln!(
stderr,
"{NOTICE_PREFIX} blocked ({} findings, policy={:?}):",
findings.len(),
policy,
);
let _ = writeln!(stderr, "{message}");
for f in findings {
let location = match (f.file.as_deref(), f.line) {
(Some(file), Some(line)) => format!(" ({file}:{line})"),
(Some(file), None) => format!(" ({file})"),
_ => String::new(),
};
let _ = writeln!(stderr, " - [{}] {}{location}", f.rule, f.message,);
}
}
}
}
#[cfg(test)]
mod tests {
use klasp_core::{CheckConfig, CheckSourceConfig, TriggerConfig};
use super::*;
fn check_with_triggers(on: Vec<&str>) -> CheckConfig {
CheckConfig {
name: "demo".into(),
triggers: if on.is_empty() {
vec![]
} else {
vec![TriggerConfig {
on: on.into_iter().map(String::from).collect(),
}]
},
source: CheckSourceConfig::Shell {
command: "true".into(),
},
timeout_secs: None,
}
}
#[test]
fn empty_triggers_match_every_event() {
let c = check_with_triggers(vec![]);
assert!(triggers_match(&c, GitEvent::Commit));
assert!(triggers_match(&c, GitEvent::Push));
}
#[test]
fn commit_trigger_matches_only_commit() {
let c = check_with_triggers(vec!["commit"]);
assert!(triggers_match(&c, GitEvent::Commit));
assert!(!triggers_match(&c, GitEvent::Push));
}
#[test]
fn push_trigger_matches_only_push() {
let c = check_with_triggers(vec!["push"]);
assert!(!triggers_match(&c, GitEvent::Commit));
assert!(triggers_match(&c, GitEvent::Push));
}
#[test]
fn either_trigger_matches_both_events() {
let c = check_with_triggers(vec!["commit", "push"]);
assert!(triggers_match(&c, GitEvent::Commit));
assert!(triggers_match(&c, GitEvent::Push));
}
#[test]
fn unknown_trigger_name_matches_nothing() {
let c = check_with_triggers(vec!["pre-merge"]);
assert!(!triggers_match(&c, GitEvent::Commit));
assert!(!triggers_match(&c, GitEvent::Push));
}
}