1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct HookEvent {
6 pub session_id: String,
7 pub transcript_path: String,
8 pub cwd: String,
9 #[serde(default)]
10 pub permission_mode: Option<String>,
11 pub hook_event_name: String,
12 #[serde(default)]
13 pub tool_name: Option<String>,
14 #[serde(default)]
15 pub tool_input: Option<serde_json::Value>,
16 #[serde(default)]
17 pub tool_response: Option<serde_json::Value>,
18 #[serde(default)]
19 pub tool_use_id: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct HookResponse {
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub r#continue: Option<bool>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub suppress_output: Option<bool>,
28}
29
30impl HookResponse {
31 pub fn allow() -> Self {
32 Self {
33 r#continue: None,
34 suppress_output: Some(true),
35 }
36 }
37}
38
39#[derive(Debug, Error)]
40pub enum HookError {
41 #[error("Failed to parse hook event: {0}")]
42 ParseError(#[from] serde_json::Error),
43 #[error("IO error: {0}")]
44 IoError(#[from] std::io::Error),
45}
46
47pub fn parse_hook_event(json: &str) -> Result<HookEvent, HookError> {
48 Ok(serde_json::from_str(json)?)
49}
50
51impl HookEvent {
52 pub fn file_path(&self) -> Option<String> {
54 self.tool_input
55 .as_ref()?
56 .get("file_path")?
57 .as_str()
58 .map(String::from)
59 }
60
61 pub fn is_file_modification(&self) -> bool {
62 matches!(self.tool_name.as_deref(), Some("Write") | Some("Edit"))
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 fn make_event(tool_name: Option<&str>) -> HookEvent {
71 HookEvent {
72 session_id: "s".into(),
73 transcript_path: "t".into(),
74 cwd: ".".into(),
75 permission_mode: None,
76 hook_event_name: "PostToolUse".into(),
77 tool_name: tool_name.map(String::from),
78 tool_input: None,
79 tool_response: None,
80 tool_use_id: None,
81 }
82 }
83
84 #[test]
85 fn is_file_modification_write() {
86 assert!(make_event(Some("Write")).is_file_modification());
87 }
88
89 #[test]
90 fn is_file_modification_edit() {
91 assert!(make_event(Some("Edit")).is_file_modification());
92 }
93
94 #[test]
95 fn is_file_modification_bash_false() {
96 assert!(!make_event(Some("Bash")).is_file_modification());
97 }
98
99 #[test]
100 fn file_path_returns_none_when_no_input() {
101 assert!(make_event(Some("Write")).file_path().is_none());
102 }
103
104 #[test]
105 fn file_path_returns_none_when_no_file_path_key() {
106 let mut e = make_event(Some("Write"));
107 e.tool_input = Some(serde_json::json!({"content": "hello"}));
108 assert!(e.file_path().is_none());
109 }
110
111 #[test]
112 fn hook_response_allow_serializes() {
113 let resp = HookResponse::allow();
114 let json = serde_json::to_value(&resp).unwrap();
115 assert_eq!(json.get("suppress_output").unwrap(), true);
116 assert!(json.get("continue").is_none());
117 }
118}