use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
static SCAR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"^\s*(?://|#|--|/\*|<!--)\s*@kizu\[(\w+)\]:\s*(.*)").expect("scar regex")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentKind {
ClaudeCode,
Cursor,
Codex,
QwenCode,
Cline,
}
impl AgentKind {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"claude-code" | "claude" => Some(Self::ClaudeCode),
"cursor" => Some(Self::Cursor),
"codex" => Some(Self::Codex),
"qwen" | "qwen-code" => Some(Self::QwenCode),
"cline" => Some(Self::Cline),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct NormalizedHookInput {
pub session_id: Option<String>,
pub hook_event_name: String,
pub tool_name: Option<String>,
pub file_paths: Vec<PathBuf>,
pub cwd: Option<PathBuf>,
pub stop_hook_active: bool,
}
#[derive(Debug, Deserialize)]
struct RawHookInput {
session_id: Option<String>,
hook_event_name: Option<String>,
tool_name: Option<String>,
tool_input: Option<serde_json::Value>,
cwd: Option<String>,
stop_hook_active: Option<bool>,
}
pub fn parse_hook_input(_agent: AgentKind, reader: impl Read) -> Result<NormalizedHookInput> {
let raw: RawHookInput = serde_json::from_reader(reader).context("parsing hook stdin JSON")?;
let mut file_paths = Vec::new();
if let Some(tool_input) = &raw.tool_input {
let fp = tool_input
.get("file_path")
.or_else(|| tool_input.get("path"))
.or_else(|| tool_input.get("filePath"))
.and_then(|v| v.as_str());
if let Some(fp) = fp {
file_paths.push(PathBuf::from(fp));
}
if let Some(edits) = tool_input.get("edits").and_then(|v| v.as_array()) {
for edit in edits {
let ep = edit
.get("file_path")
.or_else(|| edit.get("path"))
.or_else(|| edit.get("filePath"))
.and_then(|v| v.as_str());
if let Some(ep) = ep {
file_paths.push(PathBuf::from(ep));
}
}
}
file_paths.sort();
file_paths.dedup();
}
Ok(NormalizedHookInput {
session_id: raw.session_id,
hook_event_name: raw.hook_event_name.unwrap_or_default(),
tool_name: raw.tool_name,
file_paths,
cwd: raw.cwd.map(PathBuf::from),
stop_hook_active: raw.stop_hook_active.unwrap_or(false),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScarHit {
pub path: PathBuf,
pub line_number: usize,
pub kind: String,
pub message: String,
}
pub fn scan_scars(paths: &[PathBuf]) -> Vec<ScarHit> {
let mut hits = Vec::new();
for path in paths {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let fence_aware = is_fenced_code_aware(path);
hits.extend(scan_content_for_scars(
&SCAR_RE,
&content,
path,
fence_aware,
));
}
hits
}
pub fn format_additional_context(agent: AgentKind, hits: &[ScarHit]) -> Option<String> {
if hits.is_empty() {
return None;
}
let mut lines = Vec::new();
for hit in hits {
lines.push(format!(
"{}:{} @kizu[{}]: {}",
hit.path.display(),
hit.line_number,
hit.kind,
hit.message,
));
}
let context = lines.join("\n");
let envelope = match agent {
AgentKind::Cursor => serde_json::json!({
"additional_context": context,
}),
AgentKind::Codex => serde_json::json!({
"additionalContext": context,
}),
AgentKind::ClaudeCode | AgentKind::QwenCode | AgentKind::Cline => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": context,
}
}),
};
Some(serde_json::to_string(&envelope).expect("json serialize"))
}
pub fn format_stop_stderr(hits: &[ScarHit]) -> String {
let mut out = String::from("Unresolved kizu scars:\n");
for hit in hits {
out.push_str(&format!(
" {}:{} @kizu[{}]: {}\n",
hit.path.display(),
hit.line_number,
hit.kind,
hit.message,
));
}
out
}
fn is_fenced_code_aware(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "md" | "txt" | "rst" | "adoc"))
}
fn scan_content_for_scars(
re: ®ex::Regex,
content: &str,
path: &Path,
fence_aware: bool,
) -> Vec<ScarHit> {
let mut hits = Vec::new();
let mut in_fence = false;
for (i, line) in content.lines().enumerate() {
if fence_aware {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
}
if let Some(caps) = re.captures(line) {
hits.push(ScarHit {
path: path.to_path_buf(),
line_number: i + 1,
kind: caps[1].to_string(),
message: caps[2].trim().to_string(),
});
}
}
hits
}
pub fn scan_scars_from_index(root: &Path, paths: &[PathBuf]) -> Vec<ScarHit> {
let mut hits = Vec::new();
for path in paths {
let rel = match path.strip_prefix(root) {
Ok(r) => r,
Err(_) => path.as_path(),
};
let output = match std::process::Command::new("git")
.args(["show", &format!(":{}", rel.display())])
.current_dir(root)
.output()
{
Ok(o) if o.status.success() => o,
_ => continue,
};
let content = String::from_utf8_lossy(&output.stdout);
let fence_aware = is_fenced_code_aware(path);
hits.extend(scan_content_for_scars(
&SCAR_RE,
&content,
path,
fence_aware,
));
}
hits
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SanitizedEvent {
pub session_id: Option<String>,
pub hook_event_name: String,
pub tool_name: Option<String>,
pub file_paths: Vec<PathBuf>,
pub cwd: PathBuf,
pub timestamp_ms: u64,
}
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)
}
pub fn enumerate_session_files(root: &Path) -> Result<Vec<PathBuf>> {
use std::process::Command;
let session = crate::session::read_session(root);
let baseline = session
.as_ref()
.filter(|s| crate::session::is_session_alive(s))
.map(|s| s.baseline_sha.as_str());
let mut paths: Vec<PathBuf> = Vec::new();
if let Some(base) = baseline {
let mut any_failed = false;
let diff_base = Command::new("git")
.args(["diff", "--name-only", "-z", &format!("{base}..HEAD"), "--"])
.current_dir(root)
.output()
.context("git diff baseline..HEAD")?;
if diff_base.status.success() {
extend_from_nul_list(&mut paths, root, &diff_base.stdout);
} else {
any_failed = true;
}
let diff_head = Command::new("git")
.args(["diff", "--name-only", "-z", "HEAD", "--"])
.current_dir(root)
.output()
.context("git diff HEAD")?;
if diff_head.status.success() {
extend_from_nul_list(&mut paths, root, &diff_head.stdout);
} else {
any_failed = true;
}
let diff_cached = Command::new("git")
.args(["diff", "--cached", "--name-only", "-z", "--"])
.current_dir(root)
.output()
.context("git diff --cached")?;
if diff_cached.status.success() {
extend_from_nul_list(&mut paths, root, &diff_cached.stdout);
} else {
any_failed = true;
}
if any_failed {
paths.clear();
let ls_output = Command::new("git")
.args(["ls-files", "-z"])
.current_dir(root)
.output()
.context("git ls-files (fallback)")?;
if ls_output.status.success() {
extend_from_nul_list(&mut paths, root, &ls_output.stdout);
}
}
} else {
let ls_output = Command::new("git")
.args(["ls-files", "-z"])
.current_dir(root)
.output()
.context("git ls-files")?;
if ls_output.status.success() {
extend_from_nul_list(&mut paths, root, &ls_output.stdout);
}
}
let status_output = Command::new("git")
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.current_dir(root)
.output()
.context("git status --porcelain")?;
if status_output.status.success() {
for record in status_output.stdout.split(|&b| b == 0) {
if record.len() >= 3 && &record[..3] == b"?? " {
let path_bytes = &record[3..];
let rel = String::from_utf8_lossy(path_bytes);
paths.push(root.join(rel.as_ref()));
}
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn extend_from_nul_list(paths: &mut Vec<PathBuf>, root: &Path, stdout: &[u8]) {
for record in stdout.split(|&b| b == 0) {
if !record.is_empty() {
let rel = String::from_utf8_lossy(record);
paths.push(root.join(rel.as_ref()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn parse_hook_input_extracts_claude_code_post_tool_use() {
let json = r#"{
"session_id": "abc123",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "/tmp/foo.rs", "content": "fn main() {}" },
"tool_response": "ok",
"cwd": "/home/user/project",
"stop_hook_active": false
}"#;
let input = parse_hook_input(AgentKind::ClaudeCode, json.as_bytes()).unwrap();
assert_eq!(input.session_id.as_deref(), Some("abc123"));
assert_eq!(input.hook_event_name, "PostToolUse");
assert_eq!(input.tool_name.as_deref(), Some("Write"));
assert_eq!(input.file_paths, vec![PathBuf::from("/tmp/foo.rs")]);
assert_eq!(input.cwd.as_deref(), Some(Path::new("/home/user/project")));
assert!(!input.stop_hook_active);
}
#[test]
fn parse_hook_input_extracts_cline_path_field() {
let json = r#"{
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "path": "/tmp/cline.py", "content": "print(1)" },
"cwd": "/home/user"
}"#;
let input = parse_hook_input(AgentKind::Cline, json.as_bytes()).unwrap();
assert_eq!(input.file_paths, vec![PathBuf::from("/tmp/cline.py")]);
}
#[test]
fn parse_hook_input_extracts_cursor_file_path_field() {
let json = r#"{
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": { "filePath": "/tmp/cursor.ts", "content": "const x = 1" },
"cwd": "/home/user"
}"#;
let input = parse_hook_input(AgentKind::Cursor, json.as_bytes()).unwrap();
assert_eq!(input.file_paths, vec![PathBuf::from("/tmp/cursor.ts")]);
}
#[test]
fn parse_hook_input_extracts_cursor_multi_file_edits_array() {
let json = r#"{
"hook_event_name": "PostToolUse",
"tool_name": "MultiEdit",
"tool_input": {
"edits": [
{ "filePath": "/tmp/a.ts", "content": "a" },
{ "filePath": "/tmp/b.ts", "content": "b" }
]
},
"cwd": "/home/user"
}"#;
let input = parse_hook_input(AgentKind::Cursor, json.as_bytes()).unwrap();
assert_eq!(
input.file_paths,
vec![PathBuf::from("/tmp/a.ts"), PathBuf::from("/tmp/b.ts")]
);
}
#[test]
fn parse_hook_input_deduplicates_paths_from_scalar_and_edits() {
let json = r#"{
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"filePath": "/tmp/a.ts",
"edits": [
{ "filePath": "/tmp/a.ts", "content": "dup" }
]
},
"cwd": "/home/user"
}"#;
let input = parse_hook_input(AgentKind::Cursor, json.as_bytes()).unwrap();
assert_eq!(input.file_paths, vec![PathBuf::from("/tmp/a.ts")]);
}
#[test]
fn parse_hook_input_extracts_stop_event() {
let json = r#"{
"hook_event_name": "Stop",
"stop_hook_active": true,
"cwd": "/tmp"
}"#;
let input = parse_hook_input(AgentKind::ClaudeCode, json.as_bytes()).unwrap();
assert_eq!(input.hook_event_name, "Stop");
assert!(input.stop_hook_active);
assert!(input.file_paths.is_empty());
}
#[test]
fn scan_scars_finds_ask_reject_and_free() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("a.rs");
fs::write(
&file,
"fn main() {}\n// @kizu[ask]: explain this change\n// @kizu[reject]: revert this change\n// @kizu[free]: custom note\n",
).unwrap();
let hits = scan_scars(std::slice::from_ref(&file));
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].kind, "ask");
assert_eq!(hits[0].message, "explain this change");
assert_eq!(hits[0].line_number, 2);
assert_eq!(hits[1].kind, "reject");
assert_eq!(hits[2].kind, "free");
assert_eq!(hits[2].message, "custom note");
}
#[test]
fn scan_scars_handles_python_comment_syntax() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("x.py");
fs::write(&file, "def f():\n# @kizu[ask]: why?\n pass\n").unwrap();
let hits = scan_scars(&[file]);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, "ask");
assert_eq!(hits[0].message, "why?");
}
#[test]
fn scan_scars_skips_missing_files_without_panic() {
let hits = scan_scars(&[PathBuf::from("/nonexistent/ghost.rs")]);
assert!(hits.is_empty());
}
#[test]
fn scan_scars_returns_empty_when_no_scars_present() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("clean.rs");
fs::write(&file, "fn main() {}\n").unwrap();
let hits = scan_scars(&[file]);
assert!(hits.is_empty());
}
#[test]
fn scan_scars_skips_fenced_code_blocks_in_markdown() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("spec.md");
fs::write(
&file,
"# Spec\n\nExample:\n\n```html\n<!-- @kizu[ask]: example in fence -->\n```\n\nEnd.\n",
)
.unwrap();
let hits = scan_scars(&[file]);
assert!(hits.is_empty(), "fenced code block scar should be ignored");
}
#[test]
fn scan_scars_detects_real_scar_outside_fence_in_markdown() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("notes.md");
fs::write(
&file,
"# Notes\n\n<!-- @kizu[ask]: real scar outside fence -->\n\n```\n<!-- @kizu[ask]: example -->\n```\n",
)
.unwrap();
let hits = scan_scars(&[file]);
assert_eq!(hits.len(), 1, "only the scar outside the fence");
assert_eq!(hits[0].message, "real scar outside fence -->");
assert_eq!(hits[0].line_number, 3);
}
#[test]
fn scan_scars_does_not_skip_fences_in_non_markdown_files() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("a.rs");
fs::write(
&file,
"let s = \"```\";\n// @kizu[ask]: real scar\nlet t = \"```\";\n",
)
.unwrap();
let hits = scan_scars(&[file]);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, "ask");
}
#[test]
fn format_additional_context_claude_code_envelope() {
let hits = vec![ScarHit {
path: PathBuf::from("src/foo.rs"),
line_number: 10,
kind: "ask".into(),
message: "explain this".into(),
}];
let json_str = format_additional_context(AgentKind::ClaudeCode, &hits).expect("non-empty");
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let ctx = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap();
assert!(ctx.contains("src/foo.rs:10"));
assert!(ctx.contains("@kizu[ask]"));
}
#[test]
fn format_additional_context_cursor_envelope() {
let hits = vec![ScarHit {
path: PathBuf::from("src/foo.rs"),
line_number: 5,
kind: "reject".into(),
message: "revert".into(),
}];
let json_str = format_additional_context(AgentKind::Cursor, &hits).expect("non-empty");
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let ctx = parsed["additional_context"].as_str().unwrap();
assert!(ctx.contains("src/foo.rs:5"));
assert!(ctx.contains("@kizu[reject]"));
assert!(parsed.get("hookSpecificOutput").is_none());
}
#[test]
fn format_additional_context_codex_envelope() {
let hits = vec![ScarHit {
path: PathBuf::from("lib.py"),
line_number: 3,
kind: "free".into(),
message: "note".into(),
}];
let json_str = format_additional_context(AgentKind::Codex, &hits).expect("non-empty");
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
let ctx = parsed["additionalContext"].as_str().unwrap();
assert!(ctx.contains("lib.py:3"));
assert!(ctx.contains("@kizu[free]"));
assert!(parsed.get("hookSpecificOutput").is_none());
}
#[test]
fn format_additional_context_qwen_uses_claude_code_envelope() {
let hits = vec![ScarHit {
path: PathBuf::from("a.rs"),
line_number: 1,
kind: "ask".into(),
message: "why".into(),
}];
let json_str = format_additional_context(AgentKind::QwenCode, &hits).expect("non-empty");
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(
parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some()
);
}
#[test]
fn format_additional_context_cline_uses_claude_code_envelope() {
let hits = vec![ScarHit {
path: PathBuf::from("a.rs"),
line_number: 1,
kind: "ask".into(),
message: "why".into(),
}];
let json_str = format_additional_context(AgentKind::Cline, &hits).expect("non-empty");
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(
parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some()
);
}
#[test]
fn format_additional_context_returns_none_when_no_hits() {
assert!(format_additional_context(AgentKind::ClaudeCode, &[]).is_none());
}
#[test]
fn format_stop_stderr_lists_all_hits() {
let hits = vec![
ScarHit {
path: PathBuf::from("a.rs"),
line_number: 1,
kind: "ask".into(),
message: "why".into(),
},
ScarHit {
path: PathBuf::from("b.py"),
line_number: 5,
kind: "reject".into(),
message: "revert".into(),
},
];
let stderr = format_stop_stderr(&hits);
assert!(stderr.contains("a.rs:1"));
assert!(stderr.contains("b.py:5"));
assert!(stderr.contains("Unresolved kizu scars"));
}
#[test]
fn agent_kind_from_str_matches_common_names() {
assert_eq!(
AgentKind::from_str("claude-code"),
Some(AgentKind::ClaudeCode)
);
assert_eq!(AgentKind::from_str("claude"), Some(AgentKind::ClaudeCode));
assert_eq!(AgentKind::from_str("cursor"), Some(AgentKind::Cursor));
assert_eq!(AgentKind::from_str("codex"), Some(AgentKind::Codex));
assert_eq!(AgentKind::from_str("qwen"), Some(AgentKind::QwenCode));
assert_eq!(AgentKind::from_str("cline"), Some(AgentKind::Cline));
assert_eq!(AgentKind::from_str("unknown"), None);
}
#[test]
fn sanitize_event_strips_content_and_adds_timestamp() {
let input = NormalizedHookInput {
session_id: Some("sess-1".to_string()),
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Edit".to_string()),
file_paths: vec![PathBuf::from("/tmp/foo.rs")],
cwd: Some(PathBuf::from("/tmp/project")),
stop_hook_active: false,
};
let event = sanitize_event(&input);
assert_eq!(event.session_id.as_deref(), Some("sess-1"));
assert_eq!(event.hook_event_name, "PostToolUse");
assert_eq!(event.tool_name.as_deref(), Some("Edit"));
assert_eq!(event.file_paths, vec![PathBuf::from("/tmp/foo.rs")]);
assert_eq!(event.cwd, PathBuf::from("/tmp/project"));
assert!(event.timestamp_ms > 0);
}
#[test]
fn sanitize_event_serialized_json_has_no_content_fields() {
let input = NormalizedHookInput {
session_id: Some("sess-2".to_string()),
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Write".to_string()),
file_paths: vec![PathBuf::from("/tmp/bar.rs")],
cwd: Some(PathBuf::from("/tmp")),
stop_hook_active: false,
};
let event = sanitize_event(&input);
let json = serde_json::to_string(&event).unwrap();
assert!(!json.contains("\"content\""));
assert!(!json.contains("\"new_string\""));
assert!(!json.contains("\"old_string\""));
assert!(!json.contains("\"output\""));
assert!(!json.contains("\"prompt\""));
assert!(json.contains("\"session_id\""));
assert!(json.contains("\"tool_name\""));
assert!(json.contains("\"file_paths\""));
assert!(json.contains("\"timestamp_ms\""));
}
#[test]
fn write_event_creates_file_with_correct_content() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("KIZU_STATE_DIR", tmp.path().to_str().unwrap()) };
let event = SanitizedEvent {
session_id: Some("test-sess".to_string()),
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Edit".to_string()),
file_paths: vec![PathBuf::from("/tmp/foo.rs")],
cwd: PathBuf::from("/tmp"),
timestamp_ms: 1700000000000,
};
let path = write_event(&event).unwrap();
unsafe { std::env::remove_var("KIZU_STATE_DIR") };
assert!(path.exists());
let name = path.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.starts_with("1700000000000-Edit-"),
"filename must begin with `<ms>-<tool>-` and carry a uniqueness suffix, got {name}"
);
assert!(
name.ends_with(".json"),
"filename must end with `.json`, got {name}"
);
let content = std::fs::read_to_string(&path).unwrap();
let parsed: SanitizedEvent = serde_json::from_str(&content).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn write_event_same_millisecond_produces_distinct_files() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("KIZU_STATE_DIR", tmp.path().to_str().unwrap()) };
let event_a = SanitizedEvent {
session_id: None,
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Edit".to_string()),
file_paths: vec![PathBuf::from("/tmp/a.rs")],
cwd: PathBuf::from("/tmp"),
timestamp_ms: 1_800_000_000_000,
};
let event_b = SanitizedEvent {
session_id: None,
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Edit".to_string()),
file_paths: vec![PathBuf::from("/tmp/b.rs")],
cwd: PathBuf::from("/tmp"),
timestamp_ms: 1_800_000_000_000, };
let path_a = write_event(&event_a).unwrap();
let path_b = write_event(&event_b).unwrap();
unsafe { std::env::remove_var("KIZU_STATE_DIR") };
assert_ne!(
path_a, path_b,
"same-millisecond writes must land in distinct files, got {path_a:?} vs {path_b:?}"
);
assert!(path_a.exists(), "first event file missing: {path_a:?}");
assert!(path_b.exists(), "second event file missing: {path_b:?}");
}
#[test]
fn write_event_sets_0600_permissions() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("KIZU_STATE_DIR", tmp.path().to_str().unwrap()) };
let event = SanitizedEvent {
session_id: None,
hook_event_name: "PostToolUse".to_string(),
tool_name: Some("Write".to_string()),
file_paths: vec![],
cwd: PathBuf::from("/tmp"),
timestamp_ms: 1700000000001,
};
let path = write_event(&event).unwrap();
unsafe { std::env::remove_var("KIZU_STATE_DIR") };
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn prune_removes_old_entries() {
let tmp = tempfile::tempdir().unwrap();
let events_dir = tmp.path().join("events");
std::fs::create_dir_all(&events_dir).unwrap();
let old_file = events_dir.join("1000-Edit.json");
std::fs::write(&old_file, "{}").unwrap();
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let new_file = events_dir.join(format!("{now_ms}-Write.json"));
std::fs::write(&new_file, "{}").unwrap();
let removed = prune_event_log_in(&events_dir, Duration::from_secs(3600), 1000).unwrap();
assert_eq!(removed, 1);
assert!(!old_file.exists());
assert!(new_file.exists());
}
#[test]
fn prune_enforces_max_entries() {
let tmp = tempfile::tempdir().unwrap();
let events_dir = tmp.path().join("events");
std::fs::create_dir_all(&events_dir).unwrap();
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
for i in 0..5 {
let file = events_dir.join(format!("{}-Edit.json", now_ms + i));
std::fs::write(&file, "{}").unwrap();
}
let removed = prune_event_log_in(&events_dir, Duration::from_secs(86400), 3).unwrap();
assert_eq!(removed, 2);
let remaining: Vec<_> = std::fs::read_dir(&events_dir)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(remaining.len(), 3);
}
#[test]
fn prune_returns_zero_when_events_dir_missing() {
let removed = prune_event_log_in(
Path::new("/nonexistent/path"),
Duration::from_secs(3600),
1000,
)
.unwrap();
assert_eq!(removed, 0);
}
#[test]
fn scan_scars_from_index_reads_staged_blob_not_worktree() {
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(root)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(root)
.output()
.unwrap();
let file = root.join("a.rs");
fs::write(&file, "fn main() {}\n// @kizu[ask]: staged scar\n").unwrap();
Command::new("git")
.args(["add", "a.rs"])
.current_dir(root)
.output()
.unwrap();
fs::write(&file, "fn main() {}\n").unwrap();
let worktree_hits = scan_scars(std::slice::from_ref(&file));
assert!(worktree_hits.is_empty(), "worktree should be clean");
let index_hits = scan_scars_from_index(root, &[file]);
assert_eq!(index_hits.len(), 1);
assert_eq!(index_hits[0].kind, "ask");
assert_eq!(index_hits[0].message, "staged scar");
}
}