use serde::Serialize;
use crate::catalog::CatalogItem;
use crate::cli::HookEventArg;
use crate::error::{MindError, Result};
use crate::hook_log::{HookRecord, Outcome};
use crate::manifest::{InstalledItem, Manifest};
use crate::mindfile::{HookEvent, MindToml, ResolvedHook};
use crate::paths::Paths;
use crate::resolve::{
HookTarget, parse_hook_target, parse_item_ref, select_installed, source_matches_glob,
};
use crate::sanitize::strip_ansi;
use crate::source::{Pin, RecordedEvent, RecordedHook, RecordedSourceHook, Registry, Source};
fn emit_json<T: Serialize>(value: &T) -> Result<()> {
crate::commands::print_json(value)
}
#[derive(Serialize, Debug, PartialEq, Eq)]
struct HooksRunResult {
schema: u8,
action: &'static str,
target: String,
event: &'static str,
existed: usize,
ran: usize,
skipped: usize,
}
#[derive(Serialize, Debug, PartialEq)]
struct HooksListResult {
schema: u8,
action: &'static str,
target: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
sources: Vec<SourceHooksJson>,
#[serde(skip_serializing_if = "Vec::is_empty")]
items: Vec<ItemHooksJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct SourceHooksJson {
source: String,
hooks: Vec<SourceHookJson>,
items: Vec<ItemHooksJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct SourceHookJson {
event: &'static str,
required: bool,
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last: Option<HookRecord>,
}
#[derive(Serialize, Debug, PartialEq)]
struct ItemHooksJson {
item: String,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<String>,
hooks: Vec<ItemHookJson>,
}
#[derive(Serialize, Debug, PartialEq)]
struct ItemHookJson {
event: &'static str,
required: bool,
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
last: Option<HookRecord>,
}
pub fn run(
paths: &Paths,
target: &str,
event: HookEventArg,
force: bool,
accept: crate::hook::Accept,
) -> Result<()> {
let tally = match resolve_hook_target(paths, target)? {
HookTarget::Source(selector) => {
if event == HookEventArg::Build {
return Err(MindError::BuildEventRequiresItemTarget);
}
run_source_hooks(paths, &selector, event, force, accept)?
}
HookTarget::Item(item_ref) => {
run_item_hooks(paths, &item_ref, target, event, force, accept)?
}
};
if crate::render::ctx().json {
emit_json(&HooksRunResult {
schema: 1,
action: "hooks-run",
target: target.to_string(),
event: event_label(event),
existed: tally.existed,
ran: tally.ran,
skipped: tally.skipped_for_consent,
})?;
}
Ok(())
}
pub fn list(paths: &Paths, target: &str) -> Result<()> {
match resolve_hook_target(paths, target)? {
HookTarget::Source(selector) => list_source_hooks(paths, target, &selector),
HookTarget::Item(item_ref) => list_item_hooks(paths, target, &item_ref),
}
}
fn resolve_hook_target(paths: &Paths, target: &str) -> Result<HookTarget> {
let trimmed = target.trim();
if let Some(rest) = trimmed.strip_prefix("source:") {
return Ok(HookTarget::Source(rest.trim().to_string()));
}
let registry = Registry::load(paths)?;
let source_match = registry.sources.iter().any(|s| s.name == trimmed);
if source_match
&& trimmed.contains('#')
&& let Ok(item_ref) = parse_item_ref(trimmed)
{
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, &item_ref);
if !matches.is_empty() {
let item_forms: Vec<String> = matches
.iter()
.map(|it| {
format!(
"{}#{}:{}",
strip_ansi(&it.source),
it.kind.as_str(),
strip_ansi(&it.name)
)
})
.collect();
return Err(MindError::AmbiguousHookTarget {
target: trimmed.to_string(),
item_forms,
});
}
}
if source_match {
return Ok(HookTarget::Source(trimmed.to_string()));
}
parse_hook_target(target)
}
fn run_source_hooks(
paths: &Paths,
selector: &str,
event: HookEventArg,
force: bool,
accept: crate::hook::Accept,
) -> Result<HookTally> {
let mut registry = Registry::load(paths)?;
let indices: Vec<usize> = registry
.sources
.iter()
.enumerate()
.filter(|(_, s)| source_matches_glob(&s.name, selector))
.map(|(i, _)| i)
.collect();
if indices.is_empty() {
return Err(MindError::SourceNotFound {
name: selector.to_string(),
});
}
let mut registry_dirty = false;
let mut existed: usize = 0;
let mut ran: usize = 0;
let mut skipped_for_consent: usize = 0;
let mut contributors: Vec<String> = Vec::new();
for idx in indices {
let source = ®istry.sources[idx];
let clone_dir = source.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
let hook_event = match event {
HookEventArg::Install => HookEvent::Install,
HookEventArg::Update => HookEvent::Update,
HookEventArg::Uninstall => HookEvent::Uninstall,
HookEventArg::Build => unreachable!("caller guards build for source targets"),
};
let event_name = event_label(event);
let override_cmd = source.override_command().map(str::to_string);
let (resolved, replaced) = match (&override_cmd, hook_event) {
(Some(cmd), HookEvent::Install) => {
crate::hook::apply_hook_override(resolved, Some(cmd), hook_event)
}
(Some(cmd), HookEvent::Update)
if resolved.iter().any(|h| h.event == HookEvent::Update) =>
{
crate::hook::apply_hook_override(resolved, Some(cmd), hook_event)
}
_ => (resolved, None),
};
let replaced = replaced.unwrap_or_default();
let hooks: Vec<&ResolvedHook> = resolved.iter().filter(|h| h.event == hook_event).collect();
if hooks.is_empty() {
crate::render::note(format!(
"note: no {event_name} hooks declared for source {}",
strip_ansi(&source.name)
));
continue;
}
let source_name = source.name.clone();
let pin_desc = pin_description(&source.pin);
let commit = source.commit.clone().unwrap_or_default();
let current = source.commit.clone();
let clone_path = clone_dir.display().to_string();
let browse_url = source.browse_url(&commit);
let mut source_contributed = false;
let mut settled: Vec<String> = Vec::new();
for h in &hooks {
if let Some(rec_event) = recorded_event(hook_event)
&& !force
&& hook_already_ran(
®istry.sources[idx],
&h.run,
rec_event,
current.as_deref(),
)
{
settled.push(strip_ansi(h.label()));
continue;
}
existed += 1;
source_contributed = true;
let replaced_here: &[String] = if override_cmd.as_deref() == Some(h.run.as_str()) {
&replaced
} else {
&[]
};
let disclosure = crate::hook::hook_disclosure_text(
h.label(),
h.event.as_str(),
h.optional,
&source_name,
&pin_desc,
&commit,
&clone_path,
&h.run,
(!replaced_here.is_empty())
.then(|| replaced_here.join("; "))
.as_deref(),
browse_url.as_deref(),
);
let site = crate::hook_log::Site {
item: None,
commit: current.as_deref(),
replaced: replaced_here,
};
match crate::hook::decide(&disclosure, h.optional, accept.event(h.event))? {
crate::hook::HookAct::Run => {
ran += 1;
println!(
"running {event_name} hook '{}' for {}",
h.label(),
source_name
);
let hook_env = crate::commands::source_hook_env(®istry.sources[idx]);
if let Err(e) = crate::hook::run_hook(
&h.run,
&clone_dir,
&source_name,
event_name,
h.label(),
&hook_env,
site,
) {
if registry_dirty {
let _ = registry.save(paths);
}
return Err(e);
}
if let Some(rec_event) = recorded_event(hook_event) {
record_hook_run(
&mut registry.sources[idx],
&h.run,
rec_event,
current.clone(),
);
registry_dirty = true;
}
}
crate::hook::HookAct::Skip => {
crate::hook_log::not_run(&source_name, site, event_name, h.label(), &h.run);
if !crate::hook::is_tty() {
skipped_for_consent += 1;
crate::render::note(source_skip_note(event_name, h.label(), &source_name));
} else {
crate::render::note(format!(
"note: skipped {event_name} hook '{}' for {}",
strip_ansi(h.label()),
strip_ansi(&source_name)
));
}
if let Some(rec_event) = recorded_event(hook_event) {
record_hook_run(&mut registry.sources[idx], &h.run, rec_event, None);
registry_dirty = true;
}
}
crate::hook::HookAct::Abort => {
crate::hook_log::aborted(&source_name, site, event_name, h.label(), &h.run);
if registry_dirty {
let _ = registry.save(paths);
}
return Err(MindError::HookAborted {
label: h.label().to_string(),
});
}
}
}
if source_contributed {
contributors.push(source_name.clone());
} else if !settled.is_empty() {
crate::render::note(nothing_pending_note(
event_name,
&settled,
&source_name,
current.as_deref(),
));
}
}
if registry_dirty {
registry.save(paths)?;
}
if existed > 0 && ran == 0 && skipped_for_consent > 0 {
return Err(MindError::HooksNotRun {
target: selector.to_string(),
event: event_label(event).to_string(),
skipped: skipped_for_consent,
resolved: contributors,
});
}
Ok(HookTally {
existed,
ran,
skipped_for_consent,
})
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct HookTally {
existed: usize,
ran: usize,
skipped_for_consent: usize,
}
impl HookTally {
fn offered(&mut self, count: usize, accepted: bool) {
self.existed += count;
if accepted {
self.ran += count;
} else if !crate::hook::is_tty() {
self.skipped_for_consent += count;
}
}
fn nothing_could_consent(self) -> bool {
self.existed > 0 && self.ran == 0 && self.skipped_for_consent > 0
}
}
fn run_item_hooks(
paths: &Paths,
item_ref: &crate::resolve::ItemRef,
target: &str,
event: HookEventArg,
force: bool,
accept: crate::hook::Accept,
) -> Result<HookTally> {
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, item_ref);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: item_ref.name.clone(),
});
}
let mut tally = HookTally::default();
let mut contributors: Vec<String> = Vec::new();
for installed in matches {
match event {
HookEventArg::Build => {
run_item_build(paths, installed, accept.build)?;
}
HookEventArg::Install | HookEventArg::Update | HookEventArg::Uninstall => {
run_item_lifecycle_hooks(
paths,
installed,
event,
force,
accept,
&mut tally,
&mut contributors,
)?;
}
}
}
if tally.nothing_could_consent() {
let resolved = if contributors.len() == 1 {
if crate::resolve::is_glob(target) {
contributors
} else {
vec![target.to_string()]
}
} else {
contributors
};
return Err(MindError::HooksNotRun {
target: target.to_string(),
event: event_label(event).to_string(),
skipped: tally.skipped_for_consent,
resolved,
});
}
Ok(tally)
}
fn run_item_build(paths: &Paths, installed: &InstalledItem, accept_build: bool) -> Result<()> {
let registry = Registry::load(paths)?;
let source = registry
.sources
.iter()
.find(|s| s.name == installed.source)
.ok_or_else(|| MindError::SourceNotFound {
name: installed.source.clone(),
})?;
let mut catalog_items = Vec::new();
crate::catalog::scan_source(paths, source, &mut catalog_items)?;
let catalog_item = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.ok_or_else(|| MindError::NotInstalled {
name: installed.name.clone(),
})?;
let commit = installed.commit.clone();
println!(
"rebuilding {} via transactional reinstall",
installed.display_key()
);
let hook_env = crate::hook::HookEnv {
item_profiles: installed.profiles.clone(),
..crate::commands::source_hook_env(source)
};
let mut new_installed = crate::install::install(
paths,
catalog_item,
&commit,
&catalog_items,
false,
accept_build,
&hook_env,
)?;
new_installed.version = installed.version.clone();
let mut manifest = Manifest::load(paths)?;
manifest.insert(new_installed);
manifest.save(paths)?;
Ok(())
}
fn run_item_lifecycle_hooks(
paths: &Paths,
installed: &InstalledItem,
event: HookEventArg,
force: bool,
accept: crate::hook::Accept,
tally: &mut HookTally,
contributors: &mut Vec<String>,
) -> Result<()> {
let registry = Registry::load(paths)?;
let source = registry
.sources
.iter()
.find(|s| s.name == installed.source)
.ok_or_else(|| MindError::SourceNotFound {
name: installed.source.clone(),
})?;
let mut catalog_items: Vec<CatalogItem> = Vec::new();
crate::catalog::scan_source(paths, source, &mut catalog_items)?;
let catalog_item = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.ok_or_else(|| MindError::NotInstalled {
name: installed.name.clone(),
})?;
let store = paths.mind_home.join(&installed.store);
let commit = &installed.commit;
let hook_env = crate::hook::HookEnv {
item_profiles: installed.profiles.clone(),
..crate::commands::source_hook_env(source)
};
match event {
HookEventArg::Install | HookEventArg::Update => {
let event_name = event_label(event);
let all_hooks = if event == HookEventArg::Update {
catalog_item.update_hooks()
} else {
catalog_item.install_hooks()
};
if all_hooks.is_empty() {
crate::render::note(format!(
"note: no {event_name} hooks declared for {}",
installed.display_key()
));
return Ok(());
}
let current = Some(commit.as_str());
let hooks: Vec<&crate::mindfile::ResolvedHook> = all_hooks
.into_iter()
.filter(|h| force || !item_hook_already_ran(installed, &h.run, current))
.collect();
if hooks.is_empty() {
return Ok(());
}
tally.offered(hooks.len(), accept.event_str(event_name));
contributors.push(item_hook_target(installed));
let (recorded, outcome) = crate::install::run_item_install_hooks_partial(
catalog_item,
&hooks,
&store,
commit,
accept,
&hook_env,
);
record_item_hooks_run(paths, installed.key().as_str(), &recorded)?;
outcome?;
}
HookEventArg::Uninstall => {
let hooks = catalog_item.uninstall_hooks();
if hooks.is_empty() {
crate::render::note(format!(
"note: no uninstall hooks declared for {}",
installed.display_key()
));
return Ok(());
}
tally.offered(hooks.len(), accept.uninstall);
contributors.push(item_hook_target(installed));
crate::install::run_item_uninstall_hooks(
installed, &hooks, &store, commit, accept, &hook_env,
)?;
}
HookEventArg::Build => unreachable!("build handled by run_item_build"),
}
Ok(())
}
fn list_source_hooks(paths: &Paths, target: &str, selector: &str) -> Result<()> {
let registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let history = load_history(paths);
let matching: Vec<&Source> = registry
.sources
.iter()
.filter(|s| source_matches_glob(&s.name, selector))
.collect();
if matching.is_empty() {
return Err(MindError::SourceNotFound {
name: selector.to_string(),
});
}
let mut sources_json: Vec<SourceHooksJson> = Vec::new();
for source in matching {
let clone_dir = source.clone_dir(paths);
let toml_path = clone_dir.join("mind.toml");
let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
let resolved = mindfile
.as_ref()
.map(|m| m.resolved_hooks(&toml_path))
.transpose()?
.unwrap_or_default();
println!("source: {}", strip_ansi(&source.name));
let current = source.commit.as_deref();
let mut hooks_json: Vec<SourceHookJson> = Vec::new();
if resolved.is_empty() {
println!(" (no source-level hooks declared)");
} else {
for h in &resolved {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
let status = recorded_event(h.event)
.map(|rec_event| install_hook_status(source, &h.run, rec_event, current));
println!(
" [{event_str}] {kind_str} {:?} {}",
h.run,
status.as_deref().unwrap_or("(not recorded)")
);
let last = last_result(&history, &source.name, None, event_str, &h.run);
if let Some(r) = last {
println!(" {}", last_result_text(r, &h.run));
}
hooks_json.push(SourceHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
status,
last: last.cloned(),
});
}
}
let source_items: Vec<&InstalledItem> = manifest
.items
.values()
.filter(|it| it.source == source.name)
.collect();
let mut items_json: Vec<ItemHooksJson> = Vec::new();
if !source_items.is_empty() {
let mut catalog_items = Vec::new();
let _ = crate::catalog::scan_source(paths, source, &mut catalog_items);
for installed in source_items {
let item_hooks: Vec<_> = if let Some(c) = catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
{
c.hooks.iter().collect()
} else {
vec![]
};
if !item_hooks.is_empty() {
println!(" item: {}", installed.display_key());
let mut item_hooks_json: Vec<ItemHookJson> = Vec::new();
for h in item_hooks {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
println!(" [{event_str}] {kind_str} {:?}", h.run);
let key = installed.display_key();
let last =
last_result(&history, &source.name, Some(&key), event_str, &h.run);
if let Some(r) = last {
println!(" {}", last_result_text(r, &h.run));
}
item_hooks_json.push(ItemHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
last: last.cloned(),
});
}
items_json.push(ItemHooksJson {
item: installed.display_key(),
source: None,
hooks: item_hooks_json,
});
}
}
}
sources_json.push(SourceHooksJson {
source: strip_ansi(&source.name),
hooks: hooks_json,
items: items_json,
});
}
if crate::render::ctx().json {
emit_json(&HooksListResult {
schema: 1,
action: "hooks-list",
target: target.to_string(),
sources: sources_json,
items: Vec::new(),
})?;
}
Ok(())
}
fn list_item_hooks(paths: &Paths, target: &str, item_ref: &crate::resolve::ItemRef) -> Result<()> {
let manifest = Manifest::load(paths)?;
let matches = select_installed(&manifest.items, item_ref);
if matches.is_empty() {
return Err(MindError::NotInstalled {
name: item_ref.name.clone(),
});
}
let registry = Registry::load(paths)?;
let history = load_history(paths);
let mut items_json: Vec<ItemHooksJson> = Vec::new();
for installed in matches {
println!(
"item: {} (source {})",
installed.display_key(),
strip_ansi(&installed.source)
);
let source = registry.sources.iter().find(|s| s.name == installed.source);
let hooks: Vec<ResolvedHook> = if let Some(src) = source {
let mut catalog_items = Vec::new();
let _ = crate::catalog::scan_source(paths, src, &mut catalog_items);
catalog_items
.iter()
.find(|c| c.kind == installed.kind && c.name == installed.bare_name)
.map(|c| c.hooks.clone())
.unwrap_or_default()
} else {
vec![]
};
let mut hooks_json: Vec<ItemHookJson> = Vec::new();
if hooks.is_empty() {
println!(" (no hooks declared)");
} else {
for h in &hooks {
let kind_str = if h.optional { "optional" } else { "required" };
let event_str = h.event.as_str();
println!(" [{event_str}] {kind_str} {:?}", h.run);
let key = installed.display_key();
let last = last_result(&history, &installed.source, Some(&key), event_str, &h.run);
if let Some(r) = last {
println!(" {}", last_result_text(r, &h.run));
}
hooks_json.push(ItemHookJson {
event: event_str,
required: !h.optional,
command: h.run.clone(),
last: last.cloned(),
});
}
}
items_json.push(ItemHooksJson {
item: installed.display_key(),
source: Some(strip_ansi(&installed.source)),
hooks: hooks_json,
});
}
if crate::render::ctx().json {
emit_json(&HooksListResult {
schema: 1,
action: "hooks-list",
target: target.to_string(),
sources: Vec::new(),
items: items_json,
})?;
}
Ok(())
}
#[derive(Serialize, Debug)]
struct HooksHistoryResult {
schema: u8,
action: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
target: Option<String>,
records: Vec<HistoryRecordJson>,
}
#[derive(Serialize, Debug)]
struct HistoryRecordJson {
#[serde(flatten)]
record: HookRecord,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<Option<String>>,
}
#[derive(Debug, Default)]
pub struct HistoryQuery {
pub target: Option<String>,
pub failed: bool,
pub outcomes: Vec<Outcome>,
pub events: Vec<String>,
pub since: Option<String>,
pub limit: Option<usize>,
pub show: bool,
}
impl HistoryQuery {
fn filters(&self) -> bool {
self.target.is_some()
|| self.failed
|| !self.outcomes.is_empty()
|| !self.events.is_empty()
|| self.since.is_some()
|| self.limit == Some(0)
}
}
pub fn history(paths: &Paths, q: &HistoryQuery) -> Result<()> {
let path = crate::hook_log::history_file(&paths.mind_home);
let all = crate::hook_log::read_history(&path).map_err(|e| MindError::io(&path, e))?;
let filter = q
.target
.as_deref()
.map(|t| history_target(t, &all))
.transpose()?;
let since = q.since.as_deref().map(since_cutoff).transpose()?;
let mut outcomes = q.outcomes.clone();
if q.failed {
outcomes.push(Outcome::Failed);
}
let mut records: Vec<HookRecord> = all
.into_iter()
.filter(|r| outcomes.is_empty() || outcomes.contains(&r.outcome))
.filter(|r| q.events.is_empty() || q.events.contains(&r.event))
.filter(|r| since.as_deref().is_none_or(|s| r.at.as_str() >= s))
.filter(|r| filter.as_ref().is_none_or(|f| record_matches(r, f)))
.collect();
if let Some(n) = q.limit {
let drop = records.len().saturating_sub(n);
records.drain(..drop);
}
let cap = crate::config::Config::load(paths)
.ok()
.and_then(|c| c.hook_history)
.unwrap_or(crate::hook_log::DEFAULT_HISTORY);
if cap == 0 {
crate::render::note(
"note: hook history is off (hook-history = 0 in config.toml); \
nothing new is recorded",
);
}
if crate::render::ctx().json {
return emit_json(&HooksHistoryResult {
schema: 1,
action: "hooks-history",
target: q.target.clone(),
records: records
.into_iter()
.map(|r| HistoryRecordJson {
output: (q.show && r.log.is_some()).then(|| read_log(&r)),
record: r,
})
.collect(),
});
}
if records.is_empty() {
if cap > 0 && q.filters() {
println!("no recorded hook results match");
} else if cap > 0 {
println!("no hook results recorded");
}
return Ok(());
}
for r in &records {
println!("{}", history_line(r));
let Some(log) = &r.log else {
continue;
};
if !q.show {
println!(" log: {log}");
continue;
}
match std::fs::File::open(log) {
Ok(file) => {
println!(" log: {log}");
print_log(file);
}
Err(_) => println!(" log: {log} (no longer on disk)"),
}
}
Ok(())
}
fn read_log(r: &HookRecord) -> Option<String> {
let file = std::fs::File::open(r.log.as_deref()?).ok()?;
let mut text = String::new();
for_each_log_line(file, |l| {
text.push_str(l);
text.push('\n');
true
});
Some(text)
}
const SHOW_LINE_MAX: u64 = 128 * 1024;
fn for_each_log_line(file: std::fs::File, mut f: impl FnMut(&str) -> bool) {
use std::io::{BufRead, Read};
let mut reader = std::io::BufReader::new(file);
let mut line = Vec::new();
loop {
line.clear();
match reader
.by_ref()
.take(SHOW_LINE_MAX)
.read_until(b'\n', &mut line)
{
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if line.last() == Some(&b'\n') {
line.pop();
}
if !f(&strip_ansi(&String::from_utf8_lossy(&line))) {
break;
}
}
}
fn print_log(file: std::fs::File) {
use std::io::Write;
let mut out = std::io::stdout().lock();
for_each_log_line(file, |l| writeln!(out, "{l}").is_ok());
}
fn since_cutoff(value: &str) -> Result<String> {
let v = value.trim();
if let Ok(secs) = crate::auto_update::parse_duration(v) {
let now = crate::auto_update::now();
return Ok(crate::hook_log::rfc3339(now.saturating_sub(secs)));
}
let bad = || MindError::BadHistorySince {
value: value.to_string(),
};
let body = v.strip_suffix('Z').unwrap_or(v);
let shape_ok = |s: &str, pattern: &str| {
s.len() == pattern.len()
&& s.bytes().zip(pattern.bytes()).all(|(c, p)| match p {
b'9' => c.is_ascii_digit(),
_ => c == p,
})
};
let ok = ["9999-99-99", "9999-99-99T99:99", "9999-99-99T99:99:99"]
.iter()
.any(|p| shape_ok(body, p));
if !ok {
return Err(bad());
}
let field = |at: usize| body.get(at..at + 2).and_then(|s| s.parse::<u32>().ok());
let in_range = |at: usize, lo: u32, hi: u32| field(at).is_none_or(|n| (lo..=hi).contains(&n));
if !(in_range(5, 1, 12)
&& in_range(8, 1, 31)
&& in_range(11, 0, 23)
&& in_range(14, 0, 59)
&& in_range(17, 0, 59))
{
return Err(bad());
}
Ok(body.to_string())
}
fn history_line(r: &HookRecord) -> String {
let (target, label) = match &r.item {
Some(item) => (format!("{}#{item}", r.source), String::new()),
None => (r.source.clone(), format!(" '{}'", r.label)),
};
let status = if r.outcome.ran() {
format!(" ({})", r.status_text())
} else {
String::new()
};
let commit = r
.commit
.as_deref()
.map(|c| format!(" at {}", short_commit(c)))
.unwrap_or_default();
format!(
"{} {:<11} {:<9} {target}{label}{status}{commit} [{}]",
r.at,
r.outcome.as_str(),
r.event,
r.verb
)
}
fn short_commit(c: &str) -> &str {
c.get(..12).unwrap_or(c)
}
fn history_target(target: &str, records: &[HookRecord]) -> Result<HookTarget> {
let trimmed = target.trim();
if let Some(rest) = trimmed.strip_prefix("source:") {
return Ok(HookTarget::Source(rest.trim().to_string()));
}
if records.iter().any(|r| r.source == trimmed) {
return Ok(HookTarget::Source(trimmed.to_string()));
}
parse_hook_target(trimmed)
}
fn record_matches(r: &HookRecord, target: &HookTarget) -> bool {
match target {
HookTarget::Source(selector) => source_matches_glob(&r.source, selector),
HookTarget::Item(item_ref) => {
let Some(item) = r.item.as_deref() else {
return false;
};
let Some((kind, name)) = item.split_once(':') else {
return false;
};
item_ref
.source
.as_deref()
.is_none_or(|s| source_matches_glob(&r.source, s))
&& item_ref.kind.is_none_or(|k| k.as_str() == kind)
&& name_matches(name, &item_ref.name)
}
}
}
fn name_matches(name: &str, pattern: &str) -> bool {
if crate::resolve::is_glob(pattern) {
glob::Pattern::new(pattern).is_ok_and(|p| p.matches(name))
} else {
name == pattern
}
}
fn last_result<'a>(
history: &'a [HookRecord],
source: &str,
item: Option<&str>,
event: &str,
command: &str,
) -> Option<&'a HookRecord> {
let source = strip_ansi(source);
let command = strip_ansi(command);
history.iter().rev().find(|r| {
r.source == source
&& r.item.as_deref() == item
&& r.event == event
&& (r.command == command || r.replaced.contains(&command))
})
}
fn last_result_text(r: &HookRecord, command: &str) -> String {
let mut s = format!("last: {} ", r.outcome.as_str());
if r.outcome.ran() {
s.push_str(&format!("({}) ", r.status_text()));
}
s.push_str(&r.at);
if r.command != strip_ansi(command) {
s.push_str(&format!(", ran in its place: {}", r.command));
}
if let Some(log) = &r.log {
s.push_str(&format!(", log: {log}"));
}
s
}
fn load_history(paths: &Paths) -> Vec<HookRecord> {
crate::hook_log::read_history(&crate::hook_log::history_file(&paths.mind_home))
.unwrap_or_default()
}
fn pin_description(pin: &Pin) -> String {
crate::commands::pin_description(pin)
}
fn item_hook_target(installed: &InstalledItem) -> String {
format!(
"{}#{}:{}",
strip_ansi(&installed.source),
installed.kind.as_str(),
strip_ansi(&installed.name)
)
}
fn hook_already_ran(
source: &Source,
command: &str,
event: RecordedEvent,
current: Option<&str>,
) -> bool {
current.is_some()
&& source
.install_hooks
.iter()
.any(|r| r.is(command, event) && !r.baseline && r.ran_at.as_deref() == current)
}
fn item_hook_already_ran(installed: &InstalledItem, command: &str, current: Option<&str>) -> bool {
current.is_some()
&& installed
.install_hooks
.iter()
.any(|r| r.command == command && r.ran_at.as_deref() == current)
}
fn record_item_hooks_run(paths: &Paths, key: &str, recorded: &[RecordedHook]) -> Result<()> {
if recorded.is_empty() {
return Ok(());
}
let mut manifest = Manifest::load(paths)?;
if let Some(item) = manifest.items.get_mut(key) {
for r in recorded {
if let Some(existing) = item
.install_hooks
.iter_mut()
.find(|e| e.command == r.command)
{
existing.ran_at = r.ran_at.clone();
} else {
item.install_hooks.push(r.clone());
}
}
manifest.save(paths)?;
}
Ok(())
}
fn record_hook_run(
source: &mut Source,
command: &str,
event: RecordedEvent,
ran_at: Option<String>,
) {
if let Some(r) = source
.install_hooks
.iter_mut()
.find(|r| r.is(command, event))
{
r.ran_at = ran_at;
r.baseline = false;
} else {
let mut rec = RecordedSourceHook::install(command, ran_at);
rec.event = Some(event);
source.install_hooks.push(rec);
}
}
fn source_skip_note(event_name: &str, label: &str, source_name: &str) -> String {
let label = strip_ansi(label);
let source_name = strip_ansi(source_name);
let quoted = crate::error::shell_quote(&source_name);
format!(
"note: skipped {event_name} hook '{label}' for {source_name} (not a terminal); \
re-run unattended with:\n mind hooks run {quoted} --event {event_name} \
--accept-hooks={event_name}"
)
}
fn nothing_pending_note(
event_name: &str,
settled: &[String],
source_name: &str,
current: Option<&str>,
) -> String {
let safe_name = strip_ansi(source_name);
let quoted = crate::error::shell_quote(&safe_name);
let at = match current {
Some(c) => format!("already ran at {}", strip_ansi(c)),
None => "already ran".to_string(),
};
let which: Vec<String> = settled.iter().map(|l| format!("'{l}'")).collect();
format!(
"note: nothing pending for {safe_name}: {event_name} hook(s) {} {at}; \
use --force to run them anyway:\n mind hooks run {quoted} --event {event_name} --force",
which.join(", ")
)
}
fn recorded_event(event: HookEvent) -> Option<RecordedEvent> {
RecordedEvent::of(event)
}
fn event_label(event: HookEventArg) -> &'static str {
match event {
HookEventArg::Install => "install",
HookEventArg::Update => "update",
HookEventArg::Uninstall => "uninstall",
HookEventArg::Build => "build",
}
}
fn install_hook_status(
source: &Source,
command: &str,
event: RecordedEvent,
current: Option<&str>,
) -> String {
match source.install_hooks.iter().find(|r| r.is(command, event)) {
None => "pending (never ran)".to_string(),
Some(rec) => match &rec.ran_at {
None => "pending (never ran)".to_string(),
Some(ran) if rec.baseline => {
if current.is_some_and(|c| c == ran) {
format!("not pending (recorded at meld {ran})")
} else {
format!("pending (never ran; melded at {ran})")
}
}
Some(ran) => {
if current.is_some_and(|c| c == ran) {
format!("ran at {ran}")
} else {
format!("pending (last ran at {ran})")
}
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn since_refuses_out_of_range_fields() {
for good in [
"2026-10-28",
"2026-10-28T14:00",
"2026-10-28T23:59:59Z",
"2026-01-01T00:00:00",
] {
assert!(since_cutoff(good).is_ok(), "{good}");
}
for bad in [
"2026-13-01",
"2026-00-10",
"2026-10-32",
"2026-10-00",
"2026-10-28T24:00",
"2026-10-28T14:60",
"2026-10-28T14:00:60",
"2026-10-28T14",
] {
assert!(since_cutoff(bad).is_err(), "{bad}");
}
}
#[test]
fn history_lines_align_for_every_outcome() {
let rec = |outcome| HookRecord {
at: "2026-10-28T14:03:09Z".into(),
run: "r".into(),
verb: "meld".into(),
event: "install".into(),
label: "setup".into(),
source: "local/a/b".into(),
item: None,
command: "true".into(),
replaced: Vec::new(),
commit: None,
outcome,
exit_code: None,
signal: None,
duration_ms: None,
error: None,
log: None,
};
let columns: Vec<usize> = [
Outcome::Ok,
Outcome::Failed,
Outcome::Skipped,
Outcome::Declined,
Outcome::Aborted,
Outcome::Interrupted,
]
.into_iter()
.map(|o| history_line(&rec(o)).find("install").unwrap())
.collect();
assert!(columns.windows(2).all(|w| w[0] == w[1]), "{columns:?}");
}
#[test]
fn nothing_could_consent_needs_work_no_runs_and_a_consent_failure() {
let t = |existed, ran, skipped_for_consent| {
HookTally {
existed,
ran,
skipped_for_consent,
}
.nothing_could_consent()
};
assert!(t(1, 0, 1));
assert!(t(5, 0, 5));
assert!(!t(0, 0, 0));
assert!(!t(0, 0, 1));
assert!(!t(2, 1, 1));
assert!(!t(1, 0, 0));
}
#[test]
fn offered_accumulates_hook_counts_across_items() {
let mut tally = HookTally::default();
tally.offered(3, true);
tally.offered(2, true);
assert_eq!(
tally,
HookTally {
existed: 5,
ran: 5,
skipped_for_consent: 0,
}
);
assert!(!tally.nothing_could_consent());
}
#[test]
fn offered_zero_hooks_is_a_no_op() {
let mut tally = HookTally::default();
tally.offered(0, true);
tally.offered(0, false);
assert_eq!(tally, HookTally::default());
assert!(!tally.nothing_could_consent());
}
#[test]
fn offered_under_the_bypass_predicts_ran_regardless_of_environment() {
let mut tally = HookTally::default();
tally.offered(4, true);
assert_eq!(tally.ran, 4);
assert_eq!(tally.skipped_for_consent, 0);
assert!(!tally.nothing_could_consent());
}
#[test]
fn event_label_matches_the_cli_event_values() {
assert_eq!(event_label(HookEventArg::Install), "install");
assert_eq!(event_label(HookEventArg::Uninstall), "uninstall");
assert_eq!(event_label(HookEventArg::Build), "build");
for arg in [
HookEventArg::Install,
HookEventArg::Uninstall,
HookEventArg::Build,
] {
let label = event_label(arg);
assert!(
!label.is_empty() && label.chars().all(|c| c.is_ascii_lowercase()),
"{label:?} must be a bare lowercase flag value"
);
}
}
#[test]
fn source_skip_note_shell_quotes_the_identity_in_the_runnable_command() {
let evil = "x'; touch /tmp/mind-skip-note-pwned; echo '";
let note = source_skip_note("install", "setup", evil);
assert!(
note.contains(&crate::error::shell_quote(evil)),
"the note's command must carry the shell-quoted identity: {note}"
);
assert!(
!note.contains("mind hooks run x'; touch"),
"the note must never splice the raw identity into the command: {note}"
);
}
#[test]
fn source_skip_note_command_is_inert_when_pasted_into_a_real_shell() {
use std::process::Command;
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel = std::path::Path::new("/tmp/mind-skip-note-rt-pwned");
let _ = std::fs::remove_file(sentinel);
let evil = "x; touch /tmp/mind-skip-note-rt-pwned; echo hi";
let note = source_skip_note("install", "setup", evil);
let lead = "re-run unattended with:\n";
let start = note.find(lead).expect("note has a runnable command") + lead.len();
let command = note[start..].lines().next().unwrap().trim();
assert!(
command.starts_with("mind hooks run "),
"extracted command must be the mind invocation: {command:?}"
);
let probe = command.replacen("mind hooks run", "printf '%s\\n'", 1);
let out = Command::new("sh")
.arg("-c")
.arg(&probe)
.output()
.expect("sh -c must run");
assert!(
!sentinel.exists(),
"the injected 'touch' must not have executed via the note's command: {probe:?}"
);
let _ = std::fs::remove_file(sentinel);
assert!(
String::from_utf8_lossy(&out.stdout).contains(evil),
"the shell must see the identity back literally, proving the quote \
was not interpreted: stdout {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn source_skip_note_whole_framed_remedy_is_inert_when_pasted_with_the_frame() {
use std::process::Command;
if Command::new("sh").arg("-c").arg("true").status().is_err() {
return;
}
let sentinel = std::path::Path::new("/tmp/mind-skip-note-whole-frame-pwned");
let _ = std::fs::remove_file(sentinel);
let evil = "$(touch /tmp/mind-skip-note-whole-frame-pwned)`touch /tmp/mind-skip-note-whole-frame-pwned`";
let note = source_skip_note("install", "setup", evil);
let lead_at = note
.find("re-run unattended with:")
.expect("note has a remedy frame");
let framed_remedy = ¬e[lead_at..];
let _ = Command::new("sh").arg("-c").arg(framed_remedy).output();
assert!(
!sentinel.exists(),
"pasting the whole framed remedy (frame included) must not execute \
the embedded command substitution: {framed_remedy:?}"
);
let _ = std::fs::remove_file(sentinel);
}
}