use crate::HookResult; use crate::state::HookState; use anyhow::{Context, Result}; use notcore::{HookPhase, HookSpec, Report, StepStatus}; use std::path::{Path, PathBuf}; use std::process::Command; fn resolve_interpreter(spec: &HookSpec) -> Result { if let Some(interp) = &spec.interpreter { return Ok(interp.clone()); } let ext = std::path::Path::new(&spec.script) .extension() .and_then(|e| e.to_str()) .unwrap_or(""); match ext { "nu" => Ok("nu".into()), "sh" => Ok("sh".into()), "bash" => Ok("bash".into()), "zsh" => Ok("zsh".into()), "py" => Ok("python3".into()), "rb" => Ok("ruby".into()), _ => Err(format!( "cannot infer interpreter: no interpreter set and unknown extension {:?}", ext )), } } pub struct HookRunner { state_dir: PathBuf, force: bool, } impl HookRunner { pub fn new(state_dir: PathBuf) -> Self { Self { state_dir, force: false, } } pub fn with_force(state_dir: PathBuf) -> Self { Self { state_dir, force: true, } } /// Run all hooks in `hooks` that match `phase`. /// /// State is loaded once before iterating and saved once at the end (only if /// at least one Setup hook completed successfully), avoiding N file reads /// and writes per phase. pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Result { let mut state = HookState::load(&self.state_dir) .with_context(|| format!("loading hook state from {}", self.state_dir.display()))?; let mut state_dirty = false; let mut report = Report::default(); for spec in hooks.iter().filter(|h| &h.phase == phase) { let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty); let status = match &result { HookResult::Ok => StepStatus::Ok, HookResult::Skipped => StepStatus::Skipped, HookResult::Failed(msg) => StepStatus::Failed(msg.clone()), }; report.add(&spec.name, status); } if state_dirty { state .save(&self.state_dir) .with_context(|| format!("saving hook state into {}", self.state_dir.display()))?; } Ok(report) } /// Execute a single hook, using the caller-owned `state` and `dirty` flag /// instead of loading/saving per invocation. fn run_hook_with_state( &self, spec: &HookSpec, state: &mut HookState, state_dirty: &mut bool, ) -> HookResult { if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) { return HookResult::Skipped; } let interp = match resolve_interpreter(spec) { Ok(i) => i, Err(msg) => return HookResult::Failed(msg), }; let script = self.resolve_script_path(&spec.script); // SAFETY: `interp` is derived from the file extension or an explicit `interpreter` field // in HookSpec (both come from config, not untrusted shell input). `spec.script` is a // filesystem path from config. Both are passed as discrete args with no shell involved, // so argument injection is not possible. let result = Command::new(&interp).arg(&script).status(); match result { Ok(status) if status.success() => { if spec.phase == HookPhase::Setup { state.mark_done(&spec.name); *state_dirty = true; } HookResult::Ok } Ok(status) => HookResult::Failed(format!("exit code {}", status.code().unwrap_or(-1))), Err(e) => HookResult::Failed(e.to_string()), } } /// Run a single hook with its own load/save cycle. /// /// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid /// repeated state I/O. pub fn run_hook(&self, spec: &HookSpec) -> Result { let mut state = HookState::load(&self.state_dir) .with_context(|| format!("loading hook state from {}", self.state_dir.display()))?; let mut state_dirty = false; let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty); if state_dirty { state .save(&self.state_dir) .with_context(|| format!("saving hook state into {}", self.state_dir.display()))?; } Ok(result) } fn resolve_script_path(&self, script: &str) -> PathBuf { let script = Path::new(script); if script.is_absolute() { script.to_path_buf() } else { self.state_dir.join(script) } } }