Merge branch 'feature/nothooks' into feature/workspace-restructure

This commit is contained in:
Joseph O'Brien
2026-03-31 17:26:38 -04:00
6 changed files with 262 additions and 1 deletions

View File

@@ -2,3 +2,21 @@
name = "nothooks" name = "nothooks"
version = "0.1.0" version = "0.1.0"
edition = "2024" 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 }

View File

@@ -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
}

View File

@@ -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<PathBuf>,
}
#[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<HookSpec>,
}
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(())
}

View File

@@ -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()),
}
}
}

View File

@@ -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<String>,
}
impl HookState {
pub fn load(dir: &Path) -> Result<Self> {
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)
}
}

View File

@@ -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));
}