use crate::home::Registry;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::time::Duration;
const LEDGER_TIMEOUT: Duration = Duration::from_millis(400);
#[derive(Debug, Deserialize)]
pub struct Event {
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub tool_name: String,
#[serde(default)]
pub tool_input: ToolInput,
}
#[derive(Debug, Default, Deserialize)]
pub struct ToolInput {
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub offset: Option<i64>,
#[serde(default)]
pub limit: Option<i64>,
}
#[derive(Debug, PartialEq)]
pub enum Decision {
Quiet,
Nudge(String),
Refuse(String),
}
fn covered(path: &Path) -> Option<(String, PathBuf)> {
let registry = Registry::load().ok()?;
let absolute = crate::canonical(path);
let (name, _) = registry.covering(&absolute)?;
Some((name.to_string(), absolute))
}
fn line(target: &str, whole_file: bool) -> String {
if whole_file {
format!(
"semlith indexes {target}. `semlith_brief` answers a question about it in one call \
under a token budget, and `semlith_read` returns one span or one symbol — either \
costs a fraction of this file read whole."
)
} else {
format!(
"semlith indexes {target}. `semlith_search` finds the spans this pattern is looking \
for, ranked, without reading every file that matches it."
)
}
}
pub fn decide(
event: &Event,
strict: bool,
first_of_session: bool,
covers: impl Fn(&Path) -> Option<(String, PathBuf)>,
) -> (Decision, Option<PathBuf>) {
let (target, whole_file) = match event.tool_name.as_str() {
"Read" if event.tool_input.offset.is_some() || event.tool_input.limit.is_some() => {
return (Decision::Quiet, None);
}
"Read" => match &event.tool_input.file_path {
Some(p) => (PathBuf::from(p), true),
None => return (Decision::Quiet, None),
},
"Grep" => {
let at = event.tool_input.path.clone().unwrap_or_else(|| ".".into());
(PathBuf::from(at), false)
}
_ => return (Decision::Quiet, None),
};
let Some((_store, absolute)) = covers(&target) else {
return (Decision::Quiet, None);
};
let said = line(&display(&absolute), whole_file);
let decision = if strict && first_of_session {
Decision::Refuse(said)
} else {
Decision::Nudge(said)
};
(decision, whole_file.then_some(absolute))
}
fn display(path: &Path) -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(cwd).ok())
.unwrap_or(path)
.display()
.to_string()
}
fn answer(decision: &Decision) -> Option<String> {
let body = match decision {
Decision::Quiet => return None,
Decision::Nudge(said) => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": said,
}
}),
Decision::Refuse(said) => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": said,
}
}),
};
Some(body.to_string())
}
pub fn run(input: &str, strict: bool, client: &str) -> String {
let Ok(event) = serde_json::from_str::<Event>(input) else {
return String::new();
};
let first = strict && !seen(&event.session_id);
let (decision, refund) = decide(&event, strict, first, covered);
if let Decision::Refuse(_) = decision {
mark(&event.session_id);
}
if let Some(path) = refund {
record(&path, client, &event.session_id);
}
answer(&decision).unwrap_or_default()
}
fn sessions_dir() -> Option<PathBuf> {
crate::home::home_or_error()
.ok()
.map(|h| h.join("hook-sessions"))
}
fn seen(session: &str) -> bool {
match (sessions_dir(), session.is_empty()) {
(_, true) => true,
(Some(dir), false) => dir.join(sanitised(session)).exists(),
(None, _) => true,
}
}
fn mark(session: &str) {
let Some(dir) = sessions_dir() else { return };
if session.is_empty() {
return;
}
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join(sanitised(session)), b"");
prune(&dir);
}
fn prune(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let week = Duration::from_secs(7 * 24 * 60 * 60);
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|m| m.modified())
.map(|t| t.elapsed().unwrap_or_default() > week)
.unwrap_or(false);
if old {
let _ = std::fs::remove_file(entry.path());
}
}
}
fn sanitised(session: &str) -> String {
session
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.take(64)
.collect()
}
fn record(path: &Path, client: &str, session: &str) {
if !crate::ledger::enabled() {
return;
}
let Some(store_dir) = store_dir_for(path) else {
return;
};
let Some(upstream) = crate::proxy::find(&[store_dir]) else {
return;
};
let _ = upstream.raw_read(&path.display().to_string(), client, session, LEDGER_TIMEOUT);
}
fn store_dir_for(path: &Path) -> Option<PathBuf> {
let registry = Registry::load().ok()?;
let (name, _) = registry.covering(path)?;
Registry::dir_of(name).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn event(tool: &str, input: serde_json::Value) -> Event {
serde_json::from_value(serde_json::json!({
"session_id": "s1",
"tool_name": tool,
"tool_input": input,
}))
.unwrap()
}
fn held(_: &Path) -> Option<(String, PathBuf)> {
Some(("proj".into(), PathBuf::from("/proj/src/store.rs")))
}
fn unheld(_: &Path) -> Option<(String, PathBuf)> {
None
}
#[test]
fn a_whole_file_read_of_an_indexed_file_is_nudged_and_refunded() {
let e = event(
"Read",
serde_json::json!({ "file_path": "/proj/src/store.rs" }),
);
let (decision, refund) = decide(&e, false, false, held);
let Decision::Nudge(said) = decision else {
panic!("a held file must be nudged: {decision:?}");
};
assert!(
said.contains("semlith_brief"),
"the line names no call: {said}"
);
assert_eq!(refund, Some(PathBuf::from("/proj/src/store.rs")));
}
#[test]
fn a_file_no_store_holds_is_silent() {
let e = event(
"Read",
serde_json::json!({ "file_path": "/elsewhere/x.rs" }),
);
assert_eq!(decide(&e, false, false, unheld).0, Decision::Quiet);
}
#[test]
fn a_bounded_read_is_silent() {
for bound in ["offset", "limit"] {
let e = event(
"Read",
serde_json::json!({ "file_path": "/proj/src/store.rs", bound: 10 }),
);
assert_eq!(decide(&e, false, false, held).0, Decision::Quiet, "{bound}");
}
}
#[test]
fn a_grep_is_nudged_but_never_refunded() {
let e = event("Grep", serde_json::json!({ "pattern": "fn main" }));
let (decision, refund) = decide(&e, false, false, held);
assert!(matches!(decision, Decision::Nudge(_)), "{decision:?}");
assert_eq!(refund, None, "a grep read no file whole");
}
#[test]
fn another_tool_is_silent() {
let e = event("Bash", serde_json::json!({ "command": "ls" }));
assert_eq!(decide(&e, false, false, held).0, Decision::Quiet);
}
#[test]
fn strict_refuses_once_and_then_nudges() {
let e = event(
"Read",
serde_json::json!({ "file_path": "/proj/src/store.rs" }),
);
assert!(matches!(
decide(&e, true, true, held).0,
Decision::Refuse(_)
));
assert!(matches!(
decide(&e, true, false, held).0,
Decision::Nudge(_)
));
}
#[test]
fn a_nudge_decides_nothing_and_a_refusal_only_denies() {
let nudge = answer(&Decision::Nudge("x".into())).unwrap();
assert!(
!nudge.contains("permissionDecision"),
"a nudge must not decide: {nudge}"
);
assert!(nudge.contains("additionalContext"), "{nudge}");
let refusal = answer(&Decision::Refuse("x".into())).unwrap();
assert!(
refusal.contains("\"permissionDecision\":\"deny\""),
"{refusal}"
);
assert_eq!(answer(&Decision::Quiet), None);
}
#[test]
fn an_unreadable_payload_is_silence() {
assert_eq!(run("not json", false, "claude-code"), "");
assert_eq!(run("{}", false, "claude-code"), "");
}
}