use std::{collections::HashMap, process::Stdio, time::Duration}; use tokio::{io::AsyncWriteExt, process::Command, time::timeout}; use crate::error::{AppError, AppResult}; pub async fn run(command: &[String], vars: &[(&str, &str)], stdin: Option<&[u8]>) -> AppResult<()> { if command.is_empty() { return Ok(()); } let vars: HashMap<&str, &str> = vars.iter().copied().collect(); let expand = |value: &str| { let mut out = value.to_string(); for (key, replacement) in &vars { out = out.replace(&format!("{{{key}}}"), replacement); } out }; let program = expand(&command[0]); if program.is_empty() { return Err(AppError::config("hook program cannot be empty")); } let mut cmd = Command::new(program); for arg in command.iter().skip(1) { cmd.arg(expand(arg)); } cmd.kill_on_drop(true) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if stdin.is_some() { cmd.stdin(Stdio::piped()); } else { cmd.stdin(Stdio::null()); } let mut child = cmd.spawn()?; if let (Some(data), Some(mut pipe)) = (stdin, child.stdin.take()) { pipe.write_all(data).await?; } let output = timeout(Duration::from_secs(30), child.wait_with_output()) .await .map_err(|_| AppError::config("hook timed out after 30 seconds"))??; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(AppError::config(format!( "hook failed with {}: {}", output.status, stderr.trim() ))); } Ok(()) }