Skip to main content

rippy_cli/
payload.rs

1use serde_json::Value;
2
3use crate::error::RippyError;
4use crate::mode::{HookType, Mode};
5
6/// Type of file operation detected from the tool name.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum FileOp {
9    Read,
10    Write,
11    Edit,
12}
13
14/// Parsed input from stdin — the hook payload from an AI coding tool.
15#[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    /// Parse a JSON payload, auto-detecting the mode if not forced.
27    ///
28    /// # Errors
29    ///
30    /// Returns `RippyError::MissingField` if required fields are absent, or
31    /// `RippyError::UnknownMode` if the mode cannot be determined.
32    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    /// Whether this is an MCP tool invocation.
58    #[must_use]
59    pub fn is_mcp(&self) -> bool {
60        self.tool_name.starts_with("mcp__")
61    }
62
63    /// Determine the file operation type from the tool name, if applicable.
64    #[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
75/// Detect hook type from the payload.
76fn detect_hook_type(raw: &Value) -> HookType {
77    // PostToolUse payloads typically contain tool_result
78    if raw.get("tool_result").is_some() {
79        HookType::PostToolUse
80    } else {
81        HookType::PreToolUse
82    }
83}
84
85/// Auto-detect the AI tool mode from the JSON structure.
86fn detect_mode(raw: &Value) -> Result<Mode, RippyError> {
87    // Claude: tool_input is an object with "command" key
88    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        // Gemini: tool_input is a string
93        if tool_input.is_string() {
94            return Ok(Mode::Gemini);
95        }
96    }
97
98    // Cursor: has "command" at top level (not inside tool_input)
99    if raw.get("command").is_some() && raw.get("tool_input").is_none() {
100        return Ok(Mode::Cursor);
101    }
102
103    // Fallback: try Claude if tool_name looks like Claude's format
104    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
113/// Extract the shell command string from the payload based on mode.
114fn 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            // Codex may use either format
128            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
135/// Extract a file path from the `tool_input`, if present.
136fn 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}