difflore_cli/hooks/
claude_code.rs1use serde::{Deserialize, Serialize};
37use serde_json::{Value, json};
38
39use super::synth;
40use super::types::{HookEvent, HookResult};
41use super::{PayloadAdapter, PlatformAdapter};
42
43pub struct ClaudeCodeAdapter;
46
47#[derive(Debug, Clone, Deserialize, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub(crate) struct ClaudeHookPayload {
55 #[serde(default)]
56 hook_event_name: Option<String>,
57 #[serde(default)]
58 session_id: Option<String>,
59 #[serde(default)]
60 cwd: Option<String>,
61 #[serde(default)]
62 tool_name: Option<String>,
63 #[serde(default)]
64 tool_input: Option<Value>,
65 #[serde(default)]
66 tool_response: Option<Value>,
67 #[serde(default)]
68 transcript_path: Option<String>,
69 #[serde(default)]
71 prompt: Option<String>,
72}
73
74impl ClaudeHookPayload {
75 fn into_canonical(self) -> Result<HookEvent, String> {
80 let event_name = self
81 .hook_event_name
82 .as_deref()
83 .ok_or_else(|| "missing hook_event_name".to_owned())?;
84 match event_name {
85 "PreToolUse" => {
86 let tool_name = self.tool_name.clone().unwrap_or_default();
91 if tool_name != "Read" {
92 return Err(format!(
93 "PreToolUse for `{tool_name}` not wired — Read only",
94 ));
95 }
96 let file_path = self
97 .tool_input
98 .as_ref()
99 .and_then(|v| v.get("file_path"))
100 .and_then(|v| v.as_str())
101 .map(String::from)
102 .ok_or_else(|| "PreToolUse:Read missing tool_input.file_path".to_owned())?;
103 Ok(HookEvent::PreToolUseRead {
104 file_path,
105 session_id: self.session_id.clone(),
106 })
107 }
108 "PostToolUse" => {
109 let tool_name = self.tool_name.clone().unwrap_or_default();
110 let file_path = self
116 .tool_input
117 .as_ref()
118 .and_then(|v| v.get("file_path"))
119 .and_then(|v| v.as_str())
120 .map(String::from);
121 let diff = synthesise_diff(self.tool_input.as_ref(), self.tool_response.as_ref());
122 let (old_text, new_text) = synth::extract_edit_strings(self.tool_input.as_ref());
123 Ok(HookEvent::PostToolUse {
124 tool_name,
125 file_path,
126 diff,
127 session_id: self.session_id.clone(),
128 new_text,
129 old_text,
130 })
131 }
132 "SessionStart" => Ok(HookEvent::SessionStart {
133 cwd: self.cwd.unwrap_or_default(),
134 session_id: self.session_id.clone(),
135 }),
136 "UserPromptSubmit" => Ok(HookEvent::UserPromptSubmit {
137 prompt: self.prompt.unwrap_or_default(),
138 session_id: self.session_id.clone(),
139 }),
140 "Stop" => Ok(HookEvent::Stop {
141 session_id: self.session_id.clone(),
142 transcript_path: self.transcript_path.clone(),
143 cwd: self.cwd.clone(),
144 }),
145 "SessionEnd" => Ok(HookEvent::SessionEnd {
146 session_id: self.session_id.clone(),
147 transcript_path: self.transcript_path.clone(),
148 cwd: self.cwd.clone(),
149 }),
150 other => Err(format!("unsupported Claude Code hook event: {other}")),
151 }
152 }
153}
154
155fn synthesise_diff(tool_input: Option<&Value>, _tool_response: Option<&Value>) -> Option<String> {
162 let input = tool_input?;
163 if let (Some(old), Some(new)) = (
164 input.get("old_string").and_then(|v| v.as_str()),
165 input.get("new_string").and_then(|v| v.as_str()),
166 ) {
167 return Some(synth::diff_old_new(old, new));
168 }
169 if let Some(content) = input.get("content").and_then(|v| v.as_str()) {
170 return Some(synth::diff_content(content));
171 }
172 None
173}
174
175impl PayloadAdapter for ClaudeCodeAdapter {
176 type Raw = ClaudeHookPayload;
177 const PARSE_LABEL: &'static str = "Claude Code";
178
179 fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
180 raw.into_canonical()
181 }
182}
183
184impl PlatformAdapter for ClaudeCodeAdapter {
185 fn name(&self) -> &'static str {
186 "claude-code"
187 }
188
189 fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
190 Self::parse_stdin_default(raw)
191 }
192
193 fn format_output(&self, result: HookResult) -> String {
194 let mut obj = json!({
203 "continue": result.continue_,
204 });
205 if let Some(msg) = result.system_message {
206 obj["systemMessage"] = Value::String(msg);
207 }
208 if let Some(ctx) = result.additional_context {
209 let event_name = result.event_name.as_deref().unwrap_or("PostToolUse");
210 obj["hookSpecificOutput"] = json!({
211 "hookEventName": event_name,
212 "additionalContext": ctx,
213 });
214 }
215 crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn parse_post_tool_use_edit_extracts_file_path_and_diff() {
225 let adapter = ClaudeCodeAdapter;
230 let raw = r#"{
231 "hook_event_name": "PostToolUse",
232 "session_id": "abc",
233 "cwd": "/home/user/proj",
234 "tool_name": "Edit",
235 "tool_input": {
236 "file_path": "src/foo.rs",
237 "old_string": "let x = 1;",
238 "new_string": "let x = 2;"
239 },
240 "tool_response": {}
241 }"#;
242 let event = adapter.parse_stdin(raw).expect("parse ok");
243 match event {
244 HookEvent::PostToolUse {
245 tool_name,
246 file_path,
247 diff,
248 ..
249 } => {
250 assert_eq!(tool_name, "Edit");
251 assert_eq!(file_path.as_deref(), Some("src/foo.rs"));
252 let diff = diff.expect("Edit events always carry a synthesised diff");
253 assert!(
254 diff.contains("-let x = 1;"),
255 "diff missing old line: {diff}"
256 );
257 assert!(
258 diff.contains("+let x = 2;"),
259 "diff missing new line: {diff}"
260 );
261 }
262 other => panic!("expected PostToolUse, got {other:?}"),
263 }
264 }
265
266 #[test]
267 fn parse_write_event_synthesises_diff_from_content() {
268 let adapter = ClaudeCodeAdapter;
272 let raw = r#"{
273 "hook_event_name": "PostToolUse",
274 "tool_name": "Write",
275 "tool_input": {
276 "file_path": "new.rs",
277 "content": "fn main() {}\n"
278 }
279 }"#;
280 let event = adapter.parse_stdin(raw).expect("parse ok");
281 if let HookEvent::PostToolUse { diff, .. } = event {
282 let diff = diff.expect("Write must synthesise a diff");
283 assert!(diff.contains("+fn main() {}"), "got: {diff}");
284 } else {
285 panic!("expected PostToolUse");
286 }
287 }
288
289 #[test]
290 fn parse_unsupported_event_errors_without_panicking() {
291 let adapter = ClaudeCodeAdapter;
295 let raw = r#"{"hook_event_name":"SomeFutureEventWeHaventHeardOf"}"#;
296 let err = adapter.parse_stdin(raw).unwrap_err();
297 assert!(err.contains("unsupported"), "got: {err}");
298 }
299
300 #[test]
301 fn parse_missing_event_name_errors() {
302 let adapter = ClaudeCodeAdapter;
305 let raw = r#"{"session_id":"abc"}"#;
306 let err = adapter.parse_stdin(raw).unwrap_err();
307 assert!(err.contains("missing"), "got: {err}");
308 }
309
310 #[test]
311 fn format_output_noop_emits_continue_true_only() {
312 let adapter = ClaudeCodeAdapter;
316 let out = adapter.format_output(HookResult::noop());
317 let v: Value = serde_json::from_str(&out).unwrap();
318 assert_eq!(v["continue"], true);
319 assert!(v.get("systemMessage").is_none());
320 assert!(v.get("hookSpecificOutput").is_none());
321 }
322
323 #[test]
324 fn format_output_with_context_nests_additional_context() {
325 let adapter = ClaudeCodeAdapter;
329 let out = adapter.format_output(HookResult::with_context("Rule 1: X"));
330 let v: Value = serde_json::from_str(&out).unwrap();
331 assert_eq!(v["continue"], true);
332 assert_eq!(v["hookSpecificOutput"]["additionalContext"], "Rule 1: X");
333 }
334
335 #[test]
336 fn format_output_echoes_event_name_so_pretooluse_injection_lands() {
337 let adapter = ClaudeCodeAdapter;
343 let mut r = HookResult::with_context("Rule 1: cap log volume");
344 r.event_name = Some("PreToolUse".into());
345 let out = adapter.format_output(r);
346 let v: Value = serde_json::from_str(&out).unwrap();
347 assert_eq!(
348 v["hookSpecificOutput"]["hookEventName"], "PreToolUse",
349 "PreToolUse responses must echo the firing event name, not the legacy PostToolUse default; got: {out}"
350 );
351
352 let r2 = HookResult::with_context("legacy");
356 let v2: Value = serde_json::from_str(&adapter.format_output(r2)).unwrap();
357 assert_eq!(v2["hookSpecificOutput"]["hookEventName"], "PostToolUse");
358 }
359}