//! Lifecycle hook execution. use doot_lang::HookStage; use std::process::Command; use thiserror::Error; /// Hook execution errors. #[derive(Error, Debug)] pub enum HookError { #[error("hook failed: {command}: {message}")] ExecutionFailed { command: String, message: String }, #[error("io error: {0}")] IoError(#[from] std::io::Error), } /// A lifecycle hook. #[derive(Debug, Clone)] pub struct Hook { pub stage: HookStage, pub command: String, pub working_dir: Option, } /// Executes lifecycle hooks. pub struct HookRunner { hooks: Vec, dry_run: bool, } impl HookRunner { /// Creates a new hook runner. pub fn new() -> Self { Self { hooks: Vec::new(), dry_run: false, } } /// Sets dry run mode. pub fn dry_run(mut self, dry_run: bool) -> Self { self.dry_run = dry_run; self } /// Registers a hook. pub fn add_hook(&mut self, hook: Hook) { self.hooks.push(hook); } /// Runs all hooks for a given stage. #[tracing::instrument(skip(self))] pub fn run_stage(&self, stage: HookStage) -> Result<(), HookError> { for hook in self.hooks.iter().filter(|h| h.stage == stage) { self.run_hook(hook)?; } Ok(()) } #[tracing::instrument(skip(self), fields(command = %hook.command))] fn run_hook(&self, hook: &Hook) -> Result<(), HookError> { if self.dry_run { println!("[dry-run] would run: {}", hook.command); return Ok(()); } let mut cmd = Command::new("sh"); cmd.arg("-c").arg(&hook.command); if let Some(ref dir) = hook.working_dir { cmd.current_dir(dir); } let output = cmd.output()?; if !output.status.success() { return Err(HookError::ExecutionFailed { command: hook.command.clone(), message: String::from_utf8_lossy(&output.stderr).to_string(), }); } Ok(()) } } impl Default for HookRunner { fn default() -> Self { Self::new() } }