use serde::Deserialize;
use std::path::PathBuf;
use silence_core::Lines;
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct HookStdin {
args: Option<HookArgs>,
tool_input: Option<HookArgs>,
tool_response: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct HookArgs {
#[serde(alias = "filePath", alias = "path", alias = "filename", alias = "file")]
file_path: Option<String>,
#[serde(alias = "patchText")]
patch: Option<String>,
diff: Option<String>,
input: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct ToolResponse {
#[serde(alias = "filePath")]
file_path: Option<String>,
#[serde(rename = "type")]
kind: Option<String>,
#[serde(rename = "structuredPatch")]
structured_patch: Option<Vec<Hunk>>,
}
#[derive(Debug, Deserialize)]
struct Hunk {
#[serde(rename = "newStart")]
new_start: usize,
lines: Vec<String>,
}
#[derive(Debug)]
pub struct HookJob {
pub path: PathBuf,
pub lines: Option<Lines>,
}
pub fn jobs_from_stdin(input: &str) -> Result<Vec<HookJob>, String> {
if input.trim().is_empty() {
return Ok(Vec::new());
}
let event: HookStdin = serde_json::from_str(input).map_err(|e| e.to_string())?;
Ok(event.into_jobs())
}
impl HookStdin {
fn into_jobs(self) -> Vec<HookJob> {
let mut paths = Vec::new();
if let Some(args) = self.args.or(self.tool_input) {
args.push_paths(&mut paths);
}
let mut jobs: Vec<HookJob> = paths
.into_iter()
.map(|path| HookJob { path, lines: None })
.collect();
let patched = self
.tool_response
.and_then(|raw| serde_json::from_value::<ToolResponse>(raw).ok())
.and_then(ToolResponse::into_patched);
if let Some((path, lines)) = patched {
jobs.push(HookJob {
path,
lines: Some(lines),
});
}
jobs
}
}
impl ToolResponse {
fn into_patched(self) -> Option<(PathBuf, Lines)> {
let hunks = self.structured_patch?;
let lines = if self.kind.as_deref() == Some("create") {
Lines::All
} else {
Lines::Ranges(added_ranges(&hunks))
};
Some((PathBuf::from(self.file_path?), lines))
}
}
fn added_ranges(hunks: &[Hunk]) -> Vec<(usize, usize)> {
let mut out: Vec<(usize, usize)> = Vec::new();
for hunk in hunks {
let mut line = hunk.new_start;
for raw in &hunk.lines {
match raw.chars().next() {
Some('-' | '\\') => {}
Some('+') => {
match out.last_mut() {
Some(last) if last.1.saturating_add(1) == line => last.1 = line,
_ => out.push((line, line)),
}
line = line.saturating_add(1);
}
_ => line = line.saturating_add(1),
}
}
}
out
}
impl HookArgs {
fn push_paths(&self, out: &mut Vec<PathBuf>) {
if let Some(path) = &self.file_path {
out.push(PathBuf::from(path));
}
if let Some(patch) = self.patch_text() {
paths_from_patch(patch, out);
}
}
fn patch_text(&self) -> Option<&str> {
self.patch
.as_deref()
.or(self.diff.as_deref())
.or(self.input.as_deref())
}
}
fn paths_from_patch(patch: &str, out: &mut Vec<PathBuf>) {
for raw in patch.lines() {
let line = raw.trim_start();
for prefix in ["*** Update File: ", "*** Add File: ", "*** Move to: "] {
if let Some(rest) = line.strip_prefix(prefix) {
out.push(PathBuf::from(rest.trim()));
}
}
if let Some(rest) = raw.strip_prefix("+++ b/") {
out.push(PathBuf::from(rest.trim()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn patched_job(input: &str) -> Result<Option<(PathBuf, Lines)>, String> {
Ok(jobs_from_stdin(input)?
.into_iter()
.find_map(|j| j.lines.map(|lines| (j.path, lines))))
}
fn paths_from_stdin(input: &str) -> Result<Vec<PathBuf>, String> {
Ok(jobs_from_stdin(input)?
.into_iter()
.map(|j| j.path)
.collect())
}
#[test]
fn claude_tool_input_file_path() -> TestResult {
let paths = paths_from_stdin(
r#"{"hook_event_name":"PostToolUse","tool_input":{"file_path":"/tmp/a.rs"}}"#,
)?;
assert_eq!(paths, vec![PathBuf::from("/tmp/a.rs")]);
Ok(())
}
#[test]
fn opencode_args_patch_text() -> TestResult {
let paths =
paths_from_stdin(r#"{"args":{"patchText":"*** Update File: src/a.rs\n+// x\n"}}"#)?;
assert_eq!(paths, vec![PathBuf::from("src/a.rs")]);
Ok(())
}
#[test]
fn codex_tool_input_patch_in_input_field() -> TestResult {
let paths = paths_from_stdin(
r#"{"tool_input":{"input":"*** Update File: b.rs\n*** End Patch\n"}}"#,
)?;
assert_eq!(paths, vec![PathBuf::from("b.rs")]);
Ok(())
}
#[test]
fn empty_stdin_is_ok() -> TestResult {
assert!(paths_from_stdin(" ")?.is_empty());
Ok(())
}
#[test]
fn invalid_json_fails() {
assert!(paths_from_stdin("{").is_err());
}
#[test]
fn no_tool_response_is_unknown() -> TestResult {
let jobs = jobs_from_stdin(r#"{"tool_input":{"file_path":"/tmp/a.rs"}}"#)?;
assert_eq!(jobs[0].lines, None);
Ok(())
}
#[test]
fn edit_patch_yields_added_lines_only() -> TestResult {
let patched = patched_job(
r#"{"tool_input":{"file_path":"/tmp/a.py"},
"tool_response":{"filePath":"/tmp/a.py","structuredPatch":[
{"newStart":10,"newLines":4,"lines":[
" def a():","- return 1","+ # new","+ return 2"]}]}}"#,
)?;
assert_eq!(
patched,
Some((PathBuf::from("/tmp/a.py"), Lines::Ranges(vec![(11, 12)])))
);
Ok(())
}
#[test]
fn deleted_lines_do_not_advance_the_counter() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_response":{"filePath":"a.rs","structuredPatch":[
{"newStart":1,"newLines":2,"lines":["-a","-b","+c"," d","+e"]}]}}"#,
)?;
assert_eq!(jobs[0].lines, Some(Lines::Ranges(vec![(1, 1), (3, 3)])));
Ok(())
}
#[test]
fn no_newline_marker_does_not_shift_ranges() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_response":{"filePath":"a.rs","structuredPatch":[
{"newStart":1,"newLines":2,"lines":[
"-old","\\ No newline at end of file","+agent"," human"]}]}}"#,
)?;
assert_eq!(jobs[0].lines, Some(Lines::Ranges(vec![(1, 1)])));
Ok(())
}
#[test]
fn a_degenerate_new_start_does_not_overflow() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_response":{"filePath":"a.rs","structuredPatch":[
{"newStart":18446744073709551615,"lines":["+x","+y"]}]}}"#,
)?;
assert_eq!(
jobs[0].lines,
Some(Lines::Ranges(vec![(usize::MAX, usize::MAX)]))
);
Ok(())
}
#[test]
fn write_create_covers_the_whole_file() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_response":{"type":"create","filePath":"/tmp/n.rs","structuredPatch":[]}}"#,
)?;
assert_eq!(jobs[0].lines, Some(Lines::All));
Ok(())
}
#[test]
fn write_update_that_changed_nothing_is_empty() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_response":{"type":"update","filePath":"/tmp/n.rs","structuredPatch":[]}}"#,
)?;
assert_eq!(jobs[0].lines, Some(Lines::Ranges(Vec::new())));
Ok(())
}
#[test]
fn patch_scope_does_not_leak_to_other_paths() -> TestResult {
let jobs = jobs_from_stdin(
r#"{"tool_input":{"input":"*** Update File: b.rs\n"},
"tool_response":{"filePath":"a.rs","structuredPatch":[
{"newStart":1,"newLines":1,"lines":["+x"]}]}}"#,
)?;
let b = jobs
.iter()
.find(|j| j.path == Path::new("b.rs"))
.ok_or("b.rs missing from jobs")?;
assert_eq!(b.lines, None);
Ok(())
}
}