#![allow(dead_code, unused_imports, unused_variables)]
use anyhow::Result;
use std::time::Duration;
use tracing::{debug, info, warn};
use super::{HookAction, HookConfig, HookContext};
const MAX_OUTPUT_BYTES: usize = 64 * 1024;
pub async fn execute_hook(hook: &HookConfig, ctx: &HookContext) -> HookAction {
let command = expand_placeholders(&hook.command, ctx);
debug!(
"Executing hook: {} (event: {}, timeout: {}s)",
command, ctx.event, hook.timeout_secs
);
let timeout_duration = Duration::from_secs(hook.timeout_secs.max(1));
let result = run_shell_command(&command, timeout_duration).await;
match result {
Ok(Some(output)) => {
if output.success {
debug!("Hook succeeded: {}", command);
if !output.stdout.is_empty() {
debug!("Hook stdout: {}", output.stdout.trim());
}
HookAction::Continue
} else {
let msg = format!(
"Hook '{}' exited with code {} — {}",
command,
output.exit_code,
output.stderr.trim()
);
warn!("{}", msg);
if ctx.event == super::HookEvent::PreToolUse {
HookAction::Skip { reason: msg }
} else {
HookAction::Error { message: msg }
}
}
}
Ok(None) => {
let msg = format!("Hook '{}' timed out after {}s", command, hook.timeout_secs);
warn!("{}", msg);
if ctx.event == super::HookEvent::PreToolUse {
HookAction::Skip { reason: msg }
} else {
HookAction::Error { message: msg }
}
}
Err(e) => {
let msg = format!("Hook '{}' failed to run: {}", command, e);
warn!("{}", msg);
if ctx.event == super::HookEvent::PreToolUse {
HookAction::Skip { reason: msg }
} else {
HookAction::Error { message: msg }
}
}
}
}
#[derive(Debug)]
struct ShellOutput {
success: bool,
exit_code: i32,
stdout: String,
stderr: String,
}
async fn drain_capped<R: tokio::io::AsyncRead + Unpin>(mut reader: R) -> Vec<u8> {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if buf.len() < MAX_OUTPUT_BYTES {
let take = n.min(MAX_OUTPUT_BYTES - buf.len());
buf.extend_from_slice(&chunk[..take]);
}
}
}
}
buf
}
async fn run_shell_command(command: &str, timeout: Duration) -> Result<Option<ShellOutput>> {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg(command);
cmd.env_clear();
for key in ["PATH", "HOME", "LANG"] {
if let Ok(val) = std::env::var(key) {
cmd.env(key, val);
}
}
cmd.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
let child_pid = child.id();
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_task = tokio::spawn(async move {
match stdout_pipe {
Some(s) => drain_capped(s).await,
None => Vec::new(),
}
});
let stderr_task = tokio::spawn(async move {
match stderr_pipe {
Some(s) => drain_capped(s).await,
None => Vec::new(),
}
});
let wait_result = tokio::time::timeout(timeout, child.wait()).await;
match wait_result {
Ok(Ok(status)) => {
let stdout_bytes = stdout_task.await.unwrap_or_default();
let stderr_bytes = stderr_task.await.unwrap_or_default();
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
Ok(Some(ShellOutput {
success: status.success(),
exit_code: status.code().unwrap_or(-1),
stdout,
stderr,
}))
}
Ok(Err(e)) => {
let _ = stdout_task.await;
let _ = stderr_task.await;
Err(e.into())
}
Err(_) => {
#[cfg(unix)]
if let Some(pid) = child_pid {
use nix::sys::signal::{killpg, Signal};
use nix::unistd::Pid;
let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGKILL);
}
let _ = child.kill().await;
let _ = child.wait().await;
let _ = stdout_task.await;
let _ = stderr_task.await;
Ok(None)
}
}
}
fn expand_placeholders(command: &str, ctx: &HookContext) -> String {
let mut result = command.to_string();
if let Some(ref path) = ctx.affected_path {
result = result.replace("{path}", &shell_quote(path));
} else {
result = result.replace("{path}", "''");
}
if let Some(ref tool) = ctx.tool_name {
result = result.replace("{tool}", &shell_quote(tool));
} else {
result = result.replace("{tool}", "''");
}
result
}
fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
format!("'{}'", value.replace('\'', r#"'\''"#))
}
#[cfg(test)]
#[path = "../../tests/unit/hooks/shell_handler/shell_handler_test.rs"]
mod tests;