1use serde_json::Value;
2
3use crate::error::RippyError;
4use crate::mode::{HookType, Mode};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum FileOp {
9 Read,
10 Write,
11 Edit,
12}
13
14#[derive(Debug)]
16pub struct Payload {
17 pub mode: Mode,
18 pub hook_type: HookType,
19 pub tool_name: String,
20 pub command: Option<String>,
21 pub file_path: Option<String>,
22 pub raw: Value,
23}
24
25impl Payload {
26 pub fn parse(json: &str, forced_mode: Option<Mode>) -> Result<Self, RippyError> {
33 let raw: Value =
34 serde_json::from_str(json).map_err(|e| RippyError::Parse(e.to_string()))?;
35
36 let tool_name = raw
37 .get("tool_name")
38 .and_then(Value::as_str)
39 .unwrap_or_default()
40 .to_owned();
41
42 let hook_type = detect_hook_type(&raw);
43 let mode = forced_mode.map_or_else(|| detect_mode(&raw), Ok)?;
44 let command = extract_command(&raw, mode);
45 let file_path = extract_file_path(&raw);
46
47 Ok(Self {
48 mode,
49 hook_type,
50 tool_name,
51 command,
52 file_path,
53 raw,
54 })
55 }
56
57 #[must_use]
59 pub fn is_mcp(&self) -> bool {
60 self.tool_name.starts_with("mcp__")
61 }
62
63 #[must_use]
65 pub fn file_operation(&self) -> Option<FileOp> {
66 match self.tool_name.as_str() {
67 "Read" | "read_file" | "Glob" | "Grep" => Some(FileOp::Read),
68 "Write" | "write_file" => Some(FileOp::Write),
69 "Edit" | "replace" => Some(FileOp::Edit),
70 _ => None,
71 }
72 }
73}
74
75fn detect_hook_type(raw: &Value) -> HookType {
77 if raw.get("tool_result").is_some() {
79 HookType::PostToolUse
80 } else {
81 HookType::PreToolUse
82 }
83}
84
85fn detect_mode(raw: &Value) -> Result<Mode, RippyError> {
87 if let Some(tool_input) = raw.get("tool_input") {
89 if tool_input.is_object() && tool_input.get("command").is_some() {
90 return Ok(Mode::Claude);
91 }
92 if tool_input.is_string() {
94 return Ok(Mode::Gemini);
95 }
96 }
97
98 if raw.get("command").is_some() && raw.get("tool_input").is_none() {
100 return Ok(Mode::Cursor);
101 }
102
103 if raw.get("tool_name").is_some() {
105 return Ok(Mode::Claude);
106 }
107
108 Err(RippyError::UnknownMode(
109 "could not detect AI tool from payload".into(),
110 ))
111}
112
113fn extract_command(raw: &Value, mode: Mode) -> Option<String> {
115 match mode {
116 Mode::Claude => raw
117 .get("tool_input")
118 .and_then(|ti| ti.get("command"))
119 .and_then(Value::as_str)
120 .map(String::from),
121 Mode::Gemini => raw
122 .get("tool_input")
123 .and_then(Value::as_str)
124 .map(String::from),
125 Mode::Cursor => raw.get("command").and_then(Value::as_str).map(String::from),
126 Mode::Codex => raw.get("tool_input").and_then(|ti| {
127 ti.as_str()
129 .map(String::from)
130 .or_else(|| ti.get("command").and_then(Value::as_str).map(String::from))
131 }),
132 }
133}
134
135fn extract_file_path(raw: &Value) -> Option<String> {
137 raw.get("tool_input")
138 .and_then(|ti| ti.get("file_path"))
139 .and_then(Value::as_str)
140 .map(String::from)
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn claude_auto_detect() {
150 let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
151 let payload = Payload::parse(json, None).unwrap();
152 assert_eq!(payload.mode, Mode::Claude);
153 assert_eq!(payload.command.as_deref(), Some("git status"));
154 assert_eq!(payload.tool_name, "Bash");
155 assert_eq!(payload.hook_type, HookType::PreToolUse);
156 assert!(payload.file_path.is_none());
157 }
158
159 #[test]
160 fn gemini_auto_detect() {
161 let json = r#"{"tool_name":"bash","tool_input":"ls -la"}"#;
162 let payload = Payload::parse(json, None).unwrap();
163 assert_eq!(payload.mode, Mode::Gemini);
164 assert_eq!(payload.command.as_deref(), Some("ls -la"));
165 }
166
167 #[test]
168 fn cursor_auto_detect() {
169 let json = r#"{"tool_name":"bash","command":"npm install"}"#;
170 let payload = Payload::parse(json, None).unwrap();
171 assert_eq!(payload.mode, Mode::Cursor);
172 assert_eq!(payload.command.as_deref(), Some("npm install"));
173 }
174
175 #[test]
176 fn forced_mode_overrides() {
177 let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
178 let payload = Payload::parse(json, Some(Mode::Gemini)).unwrap();
179 assert_eq!(payload.mode, Mode::Gemini);
180 }
181
182 #[test]
183 fn mcp_detection() {
184 let json = r#"{"tool_name":"mcp__my_server__my_tool","tool_input":{}}"#;
185 let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
186 assert!(payload.is_mcp());
187 }
188
189 #[test]
190 fn post_tool_use_detection() {
191 let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"},"tool_result":{"output":"file.txt"}}"#;
192 let payload = Payload::parse(json, None).unwrap();
193 assert_eq!(payload.hook_type, HookType::PostToolUse);
194 }
195
196 #[test]
197 fn non_mcp() {
198 let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
199 let payload = Payload::parse(json, None).unwrap();
200 assert!(!payload.is_mcp());
201 }
202
203 #[test]
204 fn read_tool_extracts_file_path() {
205 let json = r#"{"tool_name":"Read","tool_input":{"file_path":"/tmp/.env"}}"#;
206 let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
207 assert_eq!(payload.file_path.as_deref(), Some("/tmp/.env"));
208 assert_eq!(payload.file_operation(), Some(FileOp::Read));
209 assert!(payload.command.is_none());
210 }
211
212 #[test]
213 fn write_tool_extracts_file_path() {
214 let json =
215 r#"{"tool_name":"Write","tool_input":{"file_path":"/tmp/out.txt","content":"hi"}}"#;
216 let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
217 assert_eq!(payload.file_path.as_deref(), Some("/tmp/out.txt"));
218 assert_eq!(payload.file_operation(), Some(FileOp::Write));
219 }
220
221 #[test]
222 fn edit_tool_extracts_file_path() {
223 let json = r#"{"tool_name":"Edit","tool_input":{"file_path":"main.rs","old_string":"a","new_string":"b"}}"#;
224 let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
225 assert_eq!(payload.file_path.as_deref(), Some("main.rs"));
226 assert_eq!(payload.file_operation(), Some(FileOp::Edit));
227 }
228
229 #[test]
230 fn gemini_read_file() {
231 let json = r#"{"tool_name":"read_file","tool_input":{"file_path":".env"}}"#;
232 let payload = Payload::parse(json, Some(Mode::Gemini)).unwrap();
233 assert_eq!(payload.file_operation(), Some(FileOp::Read));
234 assert_eq!(payload.file_path.as_deref(), Some(".env"));
235 }
236
237 #[test]
238 fn bash_tool_no_file_operation() {
239 let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
240 let payload = Payload::parse(json, None).unwrap();
241 assert_eq!(payload.file_operation(), None);
242 }
243
244 #[test]
245 fn glob_is_read_operation() {
246 let json = r#"{"tool_name":"Glob","tool_input":{"pattern":"**/*.rs"}}"#;
247 let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
248 assert_eq!(payload.file_operation(), Some(FileOp::Read));
249 }
250}