use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::sanitize::strip_ansi;
pub const DEFAULT_HISTORY: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum Outcome {
Ok,
Failed,
Skipped,
Declined,
Aborted,
Interrupted,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Outcome::Ok => "ok",
Outcome::Failed => "failed",
Outcome::Skipped => "skipped",
Outcome::Declined => "declined",
Outcome::Aborted => "aborted",
Outcome::Interrupted => "interrupted",
}
}
pub fn ran(self) -> bool {
matches!(self, Outcome::Ok | Outcome::Failed | Outcome::Interrupted)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum LogKeep {
#[default]
All,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookRecord {
pub at: String,
pub run: String,
pub verb: String,
pub event: String,
pub label: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item: Option<String>,
pub command: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub replaced: Vec<String>,
#[serde(default)]
pub commit: Option<String>,
pub outcome: Outcome,
#[serde(default)]
pub exit_code: Option<i32>,
#[serde(default)]
pub signal: Option<i32>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub log: Option<String>,
}
impl HookRecord {
pub fn status_text(&self) -> String {
let secs = self
.duration_ms
.map(|ms| {
if ms < 1000 {
format!(", {ms}ms")
} else {
format!(", {:.1}s", ms as f64 / 1000.0)
}
})
.unwrap_or_default();
match (self.exit_code, self.signal, &self.error) {
(Some(code), _, _) => format!("exit {code}{secs}"),
(None, Some(sig), _) => format!("signal {sig}{secs}"),
(None, None, Some(e)) => format!("not started: {e}"),
(None, None, None) => self.outcome.as_str().to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Site<'a> {
pub item: Option<&'a str>,
pub commit: Option<&'a str>,
pub replaced: &'a [String],
}
#[derive(Debug, Clone)]
pub struct RunConfig {
pub verb: String,
pub history: Option<(PathBuf, usize)>,
pub logs: Option<PathBuf>,
pub keep: LogKeep,
pub owned_logs: PathBuf,
}
pub fn anchor(dir: &Path) -> PathBuf {
let abs = if dir.is_absolute() {
dir.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(dir))
.unwrap_or_else(|_| dir.to_path_buf())
};
let mut out = PathBuf::new();
for c in abs.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
#[derive(Default)]
struct Run {
configured: bool,
id: String,
verb: String,
history: Option<(PathBuf, usize)>,
logs: Option<PathBuf>,
keep: LogKeep,
owned_logs: PathBuf,
records: Vec<HookRecord>,
seq: usize,
warned: bool,
}
static RUN: Mutex<Option<Run>> = Mutex::new(None);
fn with_run<T>(f: impl FnOnce(&mut Run) -> T) -> T {
let mut guard = RUN.lock().unwrap_or_else(|e| e.into_inner());
let run = guard.get_or_insert_with(|| Run {
id: run_id(),
..Run::default()
});
f(run)
}
pub fn configure(cfg: RunConfig) {
with_run(|run| {
run.configured = true;
run.verb = cfg.verb;
run.history = cfg.history;
run.logs = cfg.logs;
run.keep = cfg.keep;
run.owned_logs = cfg.owned_logs;
});
}
pub fn config_logs(
setting: Option<&crate::config::HookLogSetting>,
owned: &Path,
mind_home: &Path,
) -> Option<PathBuf> {
use crate::config::HookLogSetting;
match setting {
Some(HookLogSetting::Enabled(true)) => Some(owned.to_path_buf()),
Some(HookLogSetting::Dir(dir)) if dir.trim().is_empty() => Some(owned.to_path_buf()),
Some(HookLogSetting::Dir(dir)) => {
Some(anchor(&mind_home.join(crate::commands::expand_tilde(dir))))
}
Some(HookLogSetting::Enabled(false)) | None => None,
}
}
pub fn logging() -> Option<(Option<PathBuf>, LogKeep, PathBuf)> {
with_run(|run| {
run.configured
.then(|| (run.logs.clone(), run.keep, run.owned_logs.clone()))
})
}
pub fn keeps_failed_only() -> bool {
with_run(|run| run.keep == LogKeep::Failed)
}
pub fn discard_log(path: &Path) {
let _ = std::fs::remove_file(path);
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir(parent);
}
}
pub fn records() -> Vec<HookRecord> {
with_run(|run| run.records.clone())
}
pub fn not_run(source: &str, site: Site, event: &str, label: &str, command: &str) {
let outcome = if crate::hook::is_tty() {
Outcome::Declined
} else {
Outcome::Skipped
};
file(base(source, site, event, label, command, outcome));
}
pub fn aborted(source: &str, site: Site, event: &str, label: &str, command: &str) {
file(base(source, site, event, label, command, Outcome::Aborted));
}
pub fn base(
source: &str,
site: Site,
event: &str,
label: &str,
command: &str,
outcome: Outcome,
) -> HookRecord {
let (run, verb) = with_run(|run| (run.id.clone(), run.verb.clone()));
HookRecord {
at: String::new(),
run,
verb,
event: event.to_string(),
label: strip_ansi(label),
source: strip_ansi(source),
item: site.item.map(strip_ansi),
command: strip_ansi(command),
replaced: site.replaced.iter().map(|c| strip_ansi(c)).collect(),
commit: site.commit.filter(|c| !c.is_empty()).map(strip_ansi),
outcome,
exit_code: None,
signal: None,
duration_ms: None,
error: None,
log: None,
}
}
pub fn file(mut rec: HookRecord) {
rec.at = rfc3339(now_secs());
let history = with_run(|run| {
if !run.configured {
return None;
}
run.records.push(rec.clone());
run.history.clone()
});
let Some((path, cap)) = history else {
return;
};
let owned_root = owned_logs_dir_for(&path);
if let Err(e) = append_history(&path, cap, &rec, &owned_root) {
let first = with_run(|run| !std::mem::replace(&mut run.warned, true));
if first {
eprintln!(
"warning: could not record hook result in {}: {e}",
path.display()
);
}
}
}
fn owned_logs_dir_for(history: &Path) -> PathBuf {
history
.parent()
.map(|p| p.join("logs"))
.unwrap_or_else(|| PathBuf::from("logs"))
}
pub fn history_file(mind_home: &Path) -> PathBuf {
mind_home.join("hooks").join("history.jsonl")
}
pub fn owned_logs_dir(mind_home: &Path) -> PathBuf {
mind_home.join("hooks").join("logs")
}
pub fn read_history(path: &Path) -> std::io::Result<Vec<HookRecord>> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
Ok(text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect())
}
fn append_history(
path: &Path,
cap: usize,
rec: &HookRecord,
owned_root: &Path,
) -> std::io::Result<()> {
let mut records = read_history(path)?;
records.push(rec.clone());
let evicted: Vec<HookRecord> = if records.len() > cap {
records.drain(..records.len() - cap).collect()
} else {
Vec::new()
};
let mut out = String::new();
for r in &records {
out.push_str(&serde_json::to_string(r).map_err(std::io::Error::other)?);
out.push('\n');
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
crate::paths::Paths::atomic_write_private(path, out.as_bytes())
.map_err(|e| std::io::Error::other(e.to_string()))?;
for r in evicted {
if let Some(log) = r.log.as_deref().map(Path::new)
&& log.starts_with(owned_root)
&& log.extension().is_some_and(|e| e == "log")
&& !log
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
let _ = std::fs::remove_file(log);
if let Some(parent) = log.parent()
&& parent != owned_root
{
let _ = std::fs::remove_dir(parent);
}
}
}
Ok(())
}
pub struct Log {
pub path: PathBuf,
pub file: std::fs::File,
}
pub fn open_log(source: &str, site: Site, event: &str, label: &str, command: &str) -> Option<Log> {
let (dir, run_id, seq, prune) = with_run(|run| {
let dir = run.logs.clone()?;
run.seq += 1;
let prune = (run.seq == 1 && run.history.is_none() && dir.starts_with(&run.owned_logs))
.then(|| run.owned_logs.clone());
Some((dir, run.id.clone(), run.seq, prune))
})?;
if let Some(owned) = prune {
let cutoff = std::time::SystemTime::now().checked_sub(OWNED_LOG_MAX_AGE);
if let Some(cutoff) = cutoff {
prune_stale_logs(&owned, &owned, cutoff);
}
}
let name = match site.item {
Some(item) => slug(&format!("{source}-{item}")),
None => format!("{}-{}", slug(source), slug(label)),
};
let run_dir = dir.join(&run_id);
let path = run_dir.join(format!("{seq:03}-{event}-{name}.log"));
let opened = std::fs::create_dir_all(&run_dir).and_then(|_| {
let mut file = create_log(&path)?;
let (what, named) = match site.item {
Some(item) => (
format!("{}#{}", strip_ansi(source), strip_ansi(item)),
String::new(),
),
None => (strip_ansi(source), format!(" '{}'", strip_ansi(label))),
};
writeln!(
file,
"# {event} hook{named} for {what}\n# command: {}",
strip_ansi(command)
)?;
Ok(file)
});
match opened {
Ok(file) => Some(Log { path, file }),
Err(e) => {
eprintln!(
"warning: could not open hook log {}: {e}; the hook's output is not saved",
path.display()
);
None
}
}
}
const OWNED_LOG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(30 * 86_400);
fn prune_stale_logs(root: &Path, dir: &Path, cutoff: std::time::SystemTime) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
if meta.is_dir() {
prune_stale_logs(root, &path, cutoff);
} else if meta.is_file()
&& path.extension().is_some_and(|e| e == "log")
&& meta.modified().is_ok_and(|m| m < cutoff)
{
let _ = std::fs::remove_file(&path);
}
}
if dir != root {
let _ = std::fs::remove_dir(dir);
}
}
fn create_log(path: &Path) -> std::io::Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
opts.open(path)
}
fn slug(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
out.truncate(80);
out
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn run_id() -> String {
let (y, mo, d, h, mi, s) = civil(now_secs());
format!(
"{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z-{}",
std::process::id()
)
}
pub fn rfc3339(secs: u64) -> String {
let (y, mo, d, h, mi, s) = civil(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn civil(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (h, mi, s) = (
(rem / 3600) as u32,
((rem % 3600) / 60) as u32,
(rem % 60) as u32,
);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(mo <= 2);
(y, mo, d, h, mi, s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!(
"mind-hook-log-test-{}-{tag}-{n}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("create temp dir");
TempDir(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn rec(n: usize, log: Option<String>) -> HookRecord {
HookRecord {
at: format!("t{n}"),
run: "r".into(),
verb: "meld".into(),
event: "install".into(),
label: format!("h{n}"),
source: "local/a/b".into(),
item: None,
command: "true".into(),
replaced: Vec::new(),
commit: None,
outcome: Outcome::Ok,
exit_code: Some(0),
signal: None,
duration_ms: Some(1),
error: None,
log,
}
}
#[test]
fn history_keeps_the_newest_cap_records() {
let dir = TempDir::new("hook-history-cap");
let path = dir.path().join("hooks").join("history.jsonl");
let owned = dir.path().join("hooks").join("logs");
for n in 0..5 {
append_history(&path, 3, &rec(n, None), &owned).unwrap();
}
let labels: Vec<String> = read_history(&path)
.unwrap()
.into_iter()
.map(|r| r.label)
.collect();
assert_eq!(labels, vec!["h2", "h3", "h4"]);
}
#[test]
fn eviction_deletes_owned_logs_but_not_user_logs() {
let dir = TempDir::new("hook-history-evict");
let path = dir.path().join("hooks").join("history.jsonl");
let owned = dir.path().join("hooks").join("logs");
let run_dir = owned.join("run1");
std::fs::create_dir_all(&run_dir).unwrap();
let owned_log = run_dir.join("001-install-x.log");
std::fs::write(&owned_log, "x").unwrap();
let user_dir = dir.path().join("elsewhere");
std::fs::create_dir_all(&user_dir).unwrap();
let user_log = user_dir.join("001-install-y.log");
std::fs::write(&user_log, "y").unwrap();
append_history(
&path,
2,
&rec(0, Some(owned_log.display().to_string())),
&owned,
)
.unwrap();
append_history(
&path,
2,
&rec(1, Some(user_log.display().to_string())),
&owned,
)
.unwrap();
append_history(&path, 2, &rec(2, None), &owned).unwrap();
assert!(!owned_log.exists(), "an evicted owned log must be deleted");
assert!(!run_dir.exists(), "an emptied run dir must be removed");
append_history(&path, 2, &rec(3, None), &owned).unwrap();
assert!(
user_log.exists(),
"a log outside the owned dir is never deleted"
);
}
#[test]
fn eviction_refuses_a_log_path_that_escapes_the_owned_dir() {
let dir = TempDir::new("hook-history-escape");
let path = dir.path().join("hooks").join("history.jsonl");
let owned = dir.path().join("hooks").join("logs");
std::fs::create_dir_all(&owned).unwrap();
let victim = dir.path().join("sources.json");
std::fs::write(&victim, "{}").unwrap();
let sneaky = owned.join("..").join("..").join("sources.json");
let not_a_log = owned.join("keep.json");
std::fs::write(¬_a_log, "{}").unwrap();
append_history(
&path,
1,
&rec(0, Some(sneaky.display().to_string())),
&owned,
)
.unwrap();
append_history(
&path,
1,
&rec(1, Some(not_a_log.display().to_string())),
&owned,
)
.unwrap();
append_history(&path, 1, &rec(2, None), &owned).unwrap();
assert!(victim.exists(), "a `..` path must not be deleted");
assert!(not_a_log.exists(), "only `.log` files are deleted");
}
#[test]
fn anchor_makes_absolute_and_resolves_dot_dot() {
let got = anchor(Path::new("/a/hooks/logs/../../x/./y"));
assert_eq!(got, PathBuf::from("/a/x/y"));
let rel = anchor(Path::new("rel"));
assert!(rel.is_absolute() && rel.ends_with("rel"), "{rel:?}");
}
#[test]
fn unparseable_history_lines_are_dropped() {
let dir = TempDir::new("hook-history-junk");
let path = dir.path().join("history.jsonl");
let good = serde_json::to_string(&rec(1, None)).unwrap();
std::fs::write(&path, format!("not json\n{good}\n\n")).unwrap();
let got = read_history(&path).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].label, "h1");
}
#[test]
fn rfc3339_formats_known_instants() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(rfc3339(1_793_196_189), "2026-10-28T14:03:09Z");
}
#[test]
fn status_text_names_exit_signal_or_spawn_error() {
let mut r = rec(0, None);
r.duration_ms = Some(2100);
assert_eq!(r.status_text(), "exit 0, 2.1s");
r.exit_code = None;
r.signal = Some(9);
assert_eq!(r.status_text(), "signal 9, 2.1s");
r.duration_ms = Some(12);
assert_eq!(r.status_text(), "signal 9, 12ms");
r.signal = None;
r.duration_ms = None;
r.error = Some("no such dir".into());
assert_eq!(r.status_text(), "not started: no such dir");
r.error = None;
r.outcome = Outcome::Skipped;
assert_eq!(r.status_text(), "skipped");
}
#[cfg(unix)]
#[test]
fn the_history_file_is_private() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new("hook-history-mode");
let path = dir.path().join("hooks").join("history.jsonl");
let owned = dir.path().join("hooks").join("logs");
append_history(&path, 3, &rec(0, None), &owned).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_log_is_created_new_and_private() {
let dir = TempDir::new("hook-log-create");
let path = dir.path().join("001-install-x.log");
create_log(&path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
assert!(create_log(&path).is_err(), "an existing file is not reused");
#[cfg(unix)]
{
let victim = dir.path().join("victim");
std::fs::write(&victim, "keep me").unwrap();
let planted = dir.path().join("002-install-x.log");
std::os::unix::fs::symlink(&victim, &planted).unwrap();
assert!(
create_log(&planted).is_err(),
"a planted symlink is refused"
);
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep me");
}
}
#[test]
fn slug_keeps_safe_characters_only() {
assert_eq!(slug("local/a/b-skill:x y"), "local_a_b-skill_x_y");
}
}