Skip to main content

tracevault_core/
hooks.rs

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    /// Extract file path from tool_input if this is a Write or Edit event
53    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}