merge: fix unlink validation and HookRunner state batching (closes #10, #13)

This commit is contained in:
Joseph O'Brien
2026-04-11 23:41:16 -04:00
3 changed files with 64 additions and 16 deletions

View File

@@ -217,11 +217,21 @@ pub fn link_package(
} }
pub fn unlink_package( pub fn unlink_package(
_dotfiles_dir: &Path, dotfiles_dir: &Path,
state: &mut State, state: &mut State,
package: &str, package: &str,
opts: &LinkOptions, opts: &LinkOptions,
) -> Result<(), NotfilesError> { ) -> Result<(), NotfilesError> {
// Validate the package: it must either exist as a directory in dotfiles_dir
// or have entries in state. A name that satisfies neither is a user error.
let package_dir = dotfiles_dir.join(package);
let has_state_entries = !state.entries_for_package(package).is_empty();
if !package_dir.is_dir() && !has_state_entries {
return Err(NotfilesError::PackageNotFound {
name: package.to_string(),
});
}
let entries: Vec<StateEntry> = state let entries: Vec<StateEntry> = state
.entries_for_package(package) .entries_for_package(package)
.into_iter() .into_iter()

View File

@@ -1,7 +1,7 @@
pub mod runner; pub mod runner;
pub mod state; pub mod state;
use notcore::{HookPhase, HookSpec, Report, StepStatus}; use notcore::{HookPhase, HookSpec, Report};
pub use runner::HookRunner; pub use runner::HookRunner;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@@ -12,16 +12,8 @@ pub enum HookResult {
} }
/// Run all hooks matching `phase` and collect into a `Report`. /// 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) -> Report {
let mut report = Report::default(); runner.run_phase(hooks, phase)
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

@@ -1,6 +1,6 @@
use crate::HookResult; use crate::HookResult;
use crate::state::HookState; use crate::state::HookState;
use notcore::{HookPhase, HookSpec}; use notcore::{HookPhase, HookSpec, Report, StepStatus};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -46,9 +46,41 @@ impl HookRunner {
} }
} }
pub fn run_hook(&self, spec: &HookSpec) -> HookResult { /// 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) -> Report {
let mut state = HookState::load(&self.state_dir).unwrap_or_default(); let mut state = HookState::load(&self.state_dir).unwrap_or_default();
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 {
let _ = state.save(&self.state_dir);
}
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) { if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) {
return HookResult::Skipped; return HookResult::Skipped;
} }
@@ -63,7 +95,7 @@ impl HookRunner {
Ok(status) if status.success() => { Ok(status) if status.success() => {
if spec.phase == HookPhase::Setup { if spec.phase == HookPhase::Setup {
state.mark_done(&spec.name); state.mark_done(&spec.name);
let _ = state.save(&self.state_dir); *state_dirty = true;
} }
HookResult::Ok HookResult::Ok
} }
@@ -71,4 +103,18 @@ impl HookRunner {
Err(e) => HookResult::Failed(e.to_string()), 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) -> HookResult {
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
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);
}
result
}
} }