From 4cc6fb022d35eecc017024396cf43a14c10453e7 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:26:26 -0400 Subject: [PATCH] feat(nothooks): add phase-aware hook runner with setup-hook state tracking --- crates/nothooks/Cargo.toml | 18 +++++++ crates/nothooks/src/lib.rs | 32 ++++++++++++- crates/nothooks/src/main.rs | 72 ++++++++++++++++++++++++++++ crates/nothooks/src/runner.rs | 44 +++++++++++++++++ crates/nothooks/src/state.rs | 36 ++++++++++++++ crates/nothooks/tests/integration.rs | 61 +++++++++++++++++++++++ 6 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 crates/nothooks/src/main.rs create mode 100644 crates/nothooks/src/runner.rs create mode 100644 crates/nothooks/src/state.rs create mode 100644 crates/nothooks/tests/integration.rs diff --git a/crates/nothooks/Cargo.toml b/crates/nothooks/Cargo.toml index bf738cf..af18a1f 100644 --- a/crates/nothooks/Cargo.toml +++ b/crates/nothooks/Cargo.toml @@ -2,3 +2,21 @@ name = "nothooks" version = "0.1.0" edition = "2024" + +[[bin]] +name = "nothooks" +path = "src/main.rs" + +[lib] +name = "nothooks" +path = "src/lib.rs" + +[dependencies] +notcore = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +clap = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/nothooks/src/lib.rs b/crates/nothooks/src/lib.rs index ff7bd09..a55d4c4 100644 --- a/crates/nothooks/src/lib.rs +++ b/crates/nothooks/src/lib.rs @@ -1 +1,31 @@ -// placeholder +pub mod runner; +pub mod state; + +pub use runner::HookRunner; +use notcore::{HookPhase, HookSpec, Report, StepStatus}; + +#[derive(Debug, PartialEq)] +pub enum HookResult { + Ok, + Skipped, + Failed(String), +} + +/// Run all hooks matching `phase` and collect into a `Report`. +pub fn run_phase( + hooks: &[HookSpec], + phase: &HookPhase, + runner: &HookRunner, +) -> Report { + let mut report = Report::default(); + for hook in hooks.iter().filter(|h| &h.phase == phase) { + let result = runner.run_hook(hook); + let status = match &result { + HookResult::Ok => StepStatus::Ok, + HookResult::Skipped => StepStatus::Skipped, + HookResult::Failed(msg) => StepStatus::Failed(msg.clone()), + }; + report.add(&hook.name, status); + } + report +} diff --git a/crates/nothooks/src/main.rs b/crates/nothooks/src/main.rs new file mode 100644 index 0000000..256b397 --- /dev/null +++ b/crates/nothooks/src/main.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use nothooks::{run_phase, HookRunner}; +use notcore::{HookPhase, HookSpec}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "nothooks", about = "Bootstrap hook runner")] +struct Cli { + #[command(subcommand)] + command: Cmd, + + /// Force re-run of setup hooks + #[arg(long, global = true)] + force: bool, + + /// Path to hooks config TOML + #[arg(long, global = true, default_value = "notstrap.toml")] + config: PathBuf, + + /// Directory for state file (default: current dir) + #[arg(long, global = true)] + state_dir: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// Run hooks for a phase + Run { + /// Phase to run: dot or setup + #[arg(long)] + phase: String, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let state_dir = cli + .state_dir + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + let content = std::fs::read_to_string(&cli.config) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", cli.config.display()))?; + + #[derive(serde::Deserialize)] + struct HooksFile { + hooks: Vec, + } + let file: HooksFile = toml::from_str(&content)?; + + let phase = match cli.command { + Cmd::Run { ref phase } => match phase.as_str() { + "dot" => HookPhase::Dot, + "setup" => HookPhase::Setup, + other => anyhow::bail!("unknown phase '{other}', use dot or setup"), + }, + }; + + let runner = if cli.force { + HookRunner::with_force(state_dir) + } else { + HookRunner::new(state_dir) + }; + + let report = run_phase(&file.hooks, &phase, &runner); + report.print(); + + if report.has_failures() { + std::process::exit(1); + } + Ok(()) +} diff --git a/crates/nothooks/src/runner.rs b/crates/nothooks/src/runner.rs new file mode 100644 index 0000000..3279958 --- /dev/null +++ b/crates/nothooks/src/runner.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; +use std::process::Command; +use notcore::{HookPhase, HookSpec}; +use crate::HookResult; +use crate::state::HookState; + +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 } + } + + pub fn run_hook(&self, spec: &HookSpec) -> HookResult { + let mut state = HookState::load(&self.state_dir).unwrap_or_default(); + + if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) { + return HookResult::Skipped; + } + + let result = Command::new("nu") + .arg(&spec.script) + .status(); + + match result { + Ok(status) if status.success() => { + if spec.phase == HookPhase::Setup { + state.mark_done(&spec.name); + let _ = state.save(&self.state_dir); + } + HookResult::Ok + } + Ok(status) => HookResult::Failed(format!("exit code {}", status.code().unwrap_or(-1))), + Err(e) => HookResult::Failed(e.to_string()), + } + } +} diff --git a/crates/nothooks/src/state.rs b/crates/nothooks/src/state.rs new file mode 100644 index 0000000..46cce9b --- /dev/null +++ b/crates/nothooks/src/state.rs @@ -0,0 +1,36 @@ +use std::collections::HashSet; +use std::path::Path; +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +const STATE_FILE: &str = ".nothooks-state.toml"; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct HookState { + pub completed_setup_hooks: HashSet, +} + +impl HookState { + pub fn load(dir: &Path) -> Result { + let path = dir.join(STATE_FILE); + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&content)?) + } + + pub fn save(&self, dir: &Path) -> Result<()> { + let path = dir.join(STATE_FILE); + std::fs::write(path, toml::to_string(self)?)?; + Ok(()) + } + + pub fn mark_done(&mut self, name: &str) { + self.completed_setup_hooks.insert(name.to_string()); + } + + pub fn is_done(&self, name: &str) -> bool { + self.completed_setup_hooks.contains(name) + } +} diff --git a/crates/nothooks/tests/integration.rs b/crates/nothooks/tests/integration.rs new file mode 100644 index 0000000..26b812a --- /dev/null +++ b/crates/nothooks/tests/integration.rs @@ -0,0 +1,61 @@ +use std::fs; +use tempfile::TempDir; +use nothooks::{HookResult, HookRunner}; +use notcore::{HookPhase, HookSpec}; + +fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec { + let path = dir.path().join(format!("{name}.nu")); + fs::write(&path, content).unwrap(); + HookSpec { + name: name.to_string(), + script: path.to_str().unwrap().to_string(), + phase: HookPhase::Dot, + } +} + +#[test] +fn test_hook_success() { + let dir = TempDir::new().unwrap(); + let spec = make_hook_script(&dir, "ok-hook", "print hello"); + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec); + assert!(matches!(result, HookResult::Ok)); +} + +#[test] +fn test_hook_failure() { + let dir = TempDir::new().unwrap(); + let spec = make_hook_script(&dir, "fail-hook", "exit 1\n"); + let runner = HookRunner::new(dir.path().to_path_buf()); + let result = runner.run_hook(&spec); + assert!(matches!(result, HookResult::Failed(_))); +} + +#[test] +fn test_setup_hook_skipped_on_rerun() { + let dir = TempDir::new().unwrap(); + let mut spec = make_hook_script(&dir, "setup-hook", "print ran"); + spec.phase = notcore::HookPhase::Setup; + + let runner = HookRunner::new(dir.path().to_path_buf()); + let r1 = runner.run_hook(&spec); + assert!(matches!(r1, HookResult::Ok)); + + // Second run — should be skipped + let r2 = runner.run_hook(&spec); + assert!(matches!(r2, HookResult::Skipped)); +} + +#[test] +fn test_setup_hook_force_reruns() { + let dir = TempDir::new().unwrap(); + let mut spec = make_hook_script(&dir, "force-hook", "print ran"); + spec.phase = notcore::HookPhase::Setup; + + let runner = HookRunner::new(dir.path().to_path_buf()); + runner.run_hook(&spec); + + let runner2 = HookRunner::with_force(dir.path().to_path_buf()); + let r2 = runner2.run_hook(&spec); + assert!(matches!(r2, HookResult::Ok)); +}