use crate::config::{HookEvent, HooksConfig};
use crate::error::ReleaseError;
#[derive(Debug, serde::Serialize)]
pub struct HookContext<'a> {
pub event: &'a str,
#[serde(flatten)]
pub env: std::collections::BTreeMap<&'a str, &'a str>,
}
pub fn run_event(
config: &HooksConfig,
event: HookEvent,
env: &[(&str, &str)],
) -> Result<(), ReleaseError> {
let commands = match config.hooks.get(&event) {
Some(cmds) if !cmds.is_empty() => cmds,
_ => return Ok(()),
};
let label = format!("{event:?}");
let mut env_map = std::collections::BTreeMap::new();
for &(k, v) in env {
env_map.insert(k, v);
}
let context = HookContext {
event: &label,
env: env_map,
};
let json = serde_json::to_string(&context)
.map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
for cmd in commands {
eprintln!("hook [{label}]: {cmd}");
run_shell(cmd, Some(&json), env)?;
}
Ok(())
}
pub fn run_shell(
cmd: &str,
stdin_data: Option<&str>,
env: &[(&str, &str)],
) -> Result<(), ReleaseError> {
let mut child = {
let mut builder = std::process::Command::new("sh");
builder.args(["-c", cmd]);
for &(k, v) in env {
builder.env(k, v);
}
if stdin_data.is_some() {
builder.stdin(std::process::Stdio::piped());
} else {
builder.stdin(std::process::Stdio::inherit());
}
builder
.spawn()
.map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
};
if let Some(data) = stdin_data
&& let Some(ref mut stdin) = child.stdin
{
use std::io::Write;
let _ = stdin.write_all(data.as_bytes());
}
let status = child
.wait()
.map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
if !status.success() {
let code = status.code().unwrap_or(1);
return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_shell_success() {
run_shell("true", None, &[]).unwrap();
}
#[test]
fn run_shell_failure() {
let result = run_shell("false", None, &[]);
assert!(result.is_err());
}
#[test]
fn run_shell_with_env() {
run_shell("test \"$MY_VAR\" = hello", None, &[("MY_VAR", "hello")]).unwrap();
}
#[test]
fn run_event_empty_config() {
let config = HooksConfig::default();
run_event(&config, HookEvent::PreRelease, &[]).unwrap();
}
#[test]
fn run_event_simple_command() {
use std::collections::BTreeMap;
let mut hooks = BTreeMap::new();
hooks.insert(HookEvent::PreRelease, vec!["true".to_string()]);
let config = HooksConfig { hooks };
run_event(&config, HookEvent::PreRelease, &[]).unwrap();
}
#[test]
fn run_event_failure_aborts() {
use std::collections::BTreeMap;
let mut hooks = BTreeMap::new();
hooks.insert(HookEvent::PreRelease, vec!["false".to_string()]);
let config = HooksConfig { hooks };
let result = run_event(&config, HookEvent::PreRelease, &[]);
assert!(result.is_err());
}
#[test]
fn run_event_passes_env() {
use std::collections::BTreeMap;
let mut hooks = BTreeMap::new();
hooks.insert(
HookEvent::PostRelease,
vec!["test \"$SR_VERSION\" = 1.2.3".to_string()],
);
let config = HooksConfig { hooks };
run_event(&config, HookEvent::PostRelease, &[("SR_VERSION", "1.2.3")]).unwrap();
}
#[test]
fn run_event_passes_json_stdin() {
use std::collections::BTreeMap;
let mut hooks = BTreeMap::new();
hooks.insert(
HookEvent::PreCommit,
vec!["cat | grep -q PreCommit".to_string()],
);
let config = HooksConfig { hooks };
run_event(&config, HookEvent::PreCommit, &[]).unwrap();
}
}