fix(notfiles,nothooks): validate package on unlink; batch state I/O in HookRunner

Closes #10, closes #13
This commit is contained in:
Joseph O'Brien
2026-04-11 23:40:40 -04:00
parent 11d329c5a2
commit 0d60583c65
6 changed files with 118 additions and 39 deletions

View File

@@ -196,11 +196,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

@@ -172,19 +172,27 @@ fn write_html(
.map(|fs| fs.name.as_str()) .map(|fs| fs.name.as_str())
.collect(); .collect();
let mut leaves: Vec<&str> = crate_graph.nodes.iter() let mut leaves: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n)) .filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect(); .collect();
let mut roots: Vec<&str> = crate_graph.nodes.iter() let mut roots: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n)) .filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n))
.collect(); .collect();
let mut features: Vec<&str> = crate_graph.nodes.iter() let mut features: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n)) .filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect(); .collect();
let mut isolated: Vec<&str> = crate_graph.nodes.iter() let mut isolated: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n)) .filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n))
.collect(); .collect();
@@ -202,7 +210,11 @@ fn write_html(
let syms = sym_count.get(n).copied().unwrap_or(0); let syms = sym_count.get(n).copied().unwrap_or(0);
format!( format!(
"{}[\"{}<br/>in:{} out:{} | {} pub\"]", "{}[\"{}<br/>in:{} out:{} | {} pub\"]",
mermaid_id(n), n, fi, fo, syms mermaid_id(n),
n,
fi,
fo,
syms
) )
}; };
@@ -211,17 +223,23 @@ fn write_html(
if !leaves.is_empty() { if !leaves.is_empty() {
mermaid.push_str(" subgraph Core\n direction TB\n"); mermaid.push_str(" subgraph Core\n direction TB\n");
for n in &leaves { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &leaves {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !features.is_empty() { if !features.is_empty() {
mermaid.push_str(" subgraph Features\n direction TB\n"); mermaid.push_str(" subgraph Features\n direction TB\n");
for n in &features { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &features {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !roots.is_empty() { if !roots.is_empty() {
mermaid.push_str(" subgraph Tools\n direction TB\n"); mermaid.push_str(" subgraph Tools\n direction TB\n");
for n in &roots { mermaid.push_str(&format!(" {}\n", node_label(n))); } for n in &roots {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
@@ -234,7 +252,8 @@ fn write_html(
let color = heatmap_color(fs.fan_in); let color = heatmap_color(fs.fan_in);
mermaid.push_str(&format!( mermaid.push_str(&format!(
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n", " style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
mermaid_id(&fs.name), color mermaid_id(&fs.name),
color
)); ));
} }
@@ -316,10 +335,12 @@ fn write_html(
let hotspot_rows: String = stats let hotspot_rows: String = stats
.hotspots .hotspots
.iter() .iter()
.map(|h| format!( .map(|h| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>", "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score h.name, h.kind, h.score
)) )
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");

View File

@@ -63,7 +63,13 @@ fn main() -> Result<()> {
workspace_root.join(&cli.output) workspace_root.join(&cli.output)
}; };
emit::write_all(&output_dir, &crate_g, &module_graphs, &stats, &symbol_tables) emit::write_all(
&output_dir,
&crate_g,
&module_graphs,
&stats,
&symbol_tables,
)
.context("failed to write reports")?; .context("failed to write reports")?;
println!("notgraph: reports written to {}", output_dir.display()); println!("notgraph: reports written to {}", output_dir.display());

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

View File

@@ -50,12 +50,16 @@ impl BitwardenSource {
source: anyhow::anyhow!("bw unlock spawn: {e}"), source: anyhow::anyhow!("bw unlock spawn: {e}"),
})?; })?;
if let Some(mut stdin) = child.stdin.take() { if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(password.as_bytes()).map_err(|e| AgeError::SourceError { stdin
.write_all(password.as_bytes())
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: self.name().to_string(),
source: anyhow::anyhow!("bw unlock stdin write: {e}"), source: anyhow::anyhow!("bw unlock stdin write: {e}"),
})?; })?;
} }
let output = child.wait_with_output().map_err(|e| AgeError::SourceError { let output = child
.wait_with_output()
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(), name: self.name().to_string(),
source: anyhow::anyhow!("bw unlock wait: {e}"), source: anyhow::anyhow!("bw unlock wait: {e}"),
})?; })?;