Merge branch 'feature/nothooks' into feature/workspace-restructure
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
72
crates/nothooks/src/main.rs
Normal file
72
crates/nothooks/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
44
crates/nothooks/src/runner.rs
Normal file
44
crates/nothooks/src/runner.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/nothooks/src/state.rs
Normal file
36
crates/nothooks/src/state.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
crates/nothooks/tests/integration.rs
Normal file
61
crates/nothooks/tests/integration.rs
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user