use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{NormalizedHookInput, SanitizedEvent};
pub fn sanitize_event(input: &NormalizedHookInput) -> SanitizedEvent {
let timestamp_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
SanitizedEvent {
session_id: input.session_id.clone(),
hook_event_name: input.hook_event_name.clone(),
tool_name: input.tool_name.clone(),
file_paths: input.file_paths.clone(),
cwd: input.cwd.clone().unwrap_or_default(),
timestamp_ms,
}
}
pub fn write_event(event: &SanitizedEvent) -> Result<PathBuf> {
let dir = crate::paths::events_dir(&event.cwd)
.ok_or_else(|| anyhow::anyhow!("cannot resolve kizu events directory"))?;
crate::paths::ensure_private_dir(&dir)?;
let tool = event.tool_name.as_deref().unwrap_or("unknown");
let uniq = unique_filename_suffix();
let filename = format!("{}-{}-{}.json", event.timestamp_ms, tool, uniq);
let dest = dir.join(&filename);
let json = serde_json::to_string(event).context("serializing event")?;
let tmp_path = dir.join(format!(".{filename}.tmp"));
{
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts
.open(&tmp_path)
.with_context(|| format!("creating temp event file {}", tmp_path.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("writing temp event file {}", tmp_path.display()))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp_path, &dest)
.with_context(|| format!("renaming event file to {}", dest.display()))?;
Ok(dest)
}
fn unique_filename_suffix() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("{pid:x}{nanos:09}")
}
pub fn prune_event_log(root: &Path, ttl: Duration, max_entries: usize) -> Result<usize> {
let dir = match crate::paths::events_dir(root) {
Some(d) if d.is_dir() => d,
_ => return Ok(0),
};
prune_event_log_in(&dir, ttl, max_entries)
}
pub fn prune_event_log_in(dir: &Path, ttl: Duration, max_entries: usize) -> Result<usize> {
if !dir.is_dir() {
return Ok(0);
}
let mut entries: Vec<(PathBuf, u64)> = Vec::new();
for entry in std::fs::read_dir(dir).context("reading events dir")? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if let Some(ts_str) = name_str.split('-').next()
&& let Ok(ts) = ts_str.parse::<u64>()
{
entries.push((entry.path(), ts));
}
}
entries.sort_by_key(|(_, ts)| *ts);
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let ttl_ms = ttl.as_millis() as u64;
let mut removed = 0;
entries.retain(|(path, ts)| {
if now_ms.saturating_sub(*ts) > ttl_ms {
let _ = std::fs::remove_file(path);
removed += 1;
false
} else {
true
}
});
if entries.len() > max_entries {
let excess = entries.len() - max_entries;
for (path, _) in entries.iter().take(excess) {
let _ = std::fs::remove_file(path);
removed += 1;
}
}
Ok(removed)
}