Refactor: Formalize ports and update related files

This commit is contained in:
Joseph O'Brien
2026-04-15 21:27:25 -04:00
parent ac0672f64f
commit 67f20eef2d
23 changed files with 979 additions and 258 deletions

View File

@@ -14,6 +14,10 @@ pub enum HookResult {
/// Run all hooks matching `phase` and collect into a `Report`.
///
/// State is loaded once and saved once per call — not once per hook.
pub fn run_phase(hooks: &[HookSpec], phase: &HookPhase, runner: &HookRunner) -> Report {
pub fn run_phase(
hooks: &[HookSpec],
phase: &HookPhase,
runner: &HookRunner,
) -> anyhow::Result<Report> {
runner.run_phase(hooks, phase)
}

View File

@@ -62,7 +62,7 @@ fn main() -> Result<()> {
HookRunner::new(state_dir)
};
let report = run_phase(&file.hooks, &phase, &runner);
let report = run_phase(&file.hooks, &phase, &runner)?;
report.print();
if report.has_failures() {

View File

@@ -1,7 +1,8 @@
use crate::HookResult;
use crate::state::HookState;
use anyhow::{Context, Result};
use notcore::{HookPhase, HookSpec, Report, StepStatus};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::Command;
fn resolve_interpreter(spec: &HookSpec) -> Result<String, String> {
@@ -51,8 +52,9 @@ impl HookRunner {
/// 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) -> Report {
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Result<Report> {
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();
@@ -67,10 +69,12 @@ impl HookRunner {
}
if state_dirty {
let _ = state.save(&self.state_dir);
state
.save(&self.state_dir)
.with_context(|| format!("saving hook state into {}", self.state_dir.display()))?;
}
report
Ok(report)
}
/// Execute a single hook, using the caller-owned `state` and `dirty` flag
@@ -89,11 +93,12 @@ impl HookRunner {
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(&spec.script).status();
let result = Command::new(&interp).arg(&script).status();
match result {
Ok(status) if status.success() => {
@@ -112,13 +117,25 @@ impl HookRunner {
///
/// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid
/// repeated state I/O.
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
pub fn run_hook(&self, spec: &HookSpec) -> Result<HookResult> {
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 {
let _ = state.save(&self.state_dir);
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)
}
result
}
}

View File

@@ -22,7 +22,9 @@ impl HookState {
pub fn save(&self, dir: &Path) -> Result<()> {
let path = dir.join(STATE_FILE);
std::fs::write(path, toml::to_string(self)?)?;
let tmp = dir.join(format!("{STATE_FILE}.tmp"));
std::fs::write(&tmp, toml::to_string(self)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}

View File

@@ -19,7 +19,7 @@ fn test_hook_success() {
let dir = TempDir::new().unwrap();
let spec = make_hook_script(&dir, "ok-hook", "echo hello");
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec);
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Ok));
}
@@ -28,7 +28,7 @@ fn test_hook_failure() {
let dir = TempDir::new().unwrap();
let spec = make_hook_script(&dir, "fail-hook", "exit 1");
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec);
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Failed(_)));
}
@@ -39,11 +39,11 @@ fn test_setup_hook_skipped_on_rerun() {
spec.phase = notcore::HookPhase::Setup;
let runner = HookRunner::new(dir.path().to_path_buf());
let r1 = runner.run_hook(&spec);
let r1 = runner.run_hook(&spec).unwrap();
assert!(matches!(r1, HookResult::Ok));
// Second run — should be skipped
let r2 = runner.run_hook(&spec);
let r2 = runner.run_hook(&spec).unwrap();
assert!(matches!(r2, HookResult::Skipped));
}
@@ -54,9 +54,29 @@ fn test_setup_hook_force_reruns() {
spec.phase = notcore::HookPhase::Setup;
let runner = HookRunner::new(dir.path().to_path_buf());
runner.run_hook(&spec);
runner.run_hook(&spec).unwrap();
let runner2 = HookRunner::with_force(dir.path().to_path_buf());
let r2 = runner2.run_hook(&spec);
let r2 = runner2.run_hook(&spec).unwrap();
assert!(matches!(r2, HookResult::Ok));
}
#[test]
fn test_relative_script_path_resolves_from_state_dir() {
let dir = TempDir::new().unwrap();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script = scripts_dir.join("relative.sh");
fs::write(&script, "echo relative-ok").unwrap();
let spec = HookSpec {
name: "relative".to_string(),
script: "scripts/relative.sh".to_string(),
phase: HookPhase::Dot,
interpreter: Some("sh".to_string()),
};
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Ok));
}