use serde_json::Value;
use crate::error::RippyError;
use crate::mode::{HookType, Mode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileOp {
Read,
Write,
Edit,
}
#[derive(Debug)]
pub struct Payload {
pub mode: Mode,
pub hook_type: HookType,
pub tool_name: String,
pub command: Option<String>,
pub file_path: Option<String>,
pub raw: Value,
}
impl Payload {
pub fn parse(json: &str, forced_mode: Option<Mode>) -> Result<Self, RippyError> {
let raw: Value =
serde_json::from_str(json).map_err(|e| RippyError::Parse(e.to_string()))?;
let tool_name = raw
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let hook_type = detect_hook_type(&raw);
let mode = forced_mode.map_or_else(|| detect_mode(&raw), Ok)?;
let command = extract_command(&raw, mode);
let file_path = extract_file_path(&raw);
Ok(Self {
mode,
hook_type,
tool_name,
command,
file_path,
raw,
})
}
#[must_use]
pub fn is_mcp(&self) -> bool {
self.tool_name.starts_with("mcp__")
}
#[must_use]
pub fn file_operation(&self) -> Option<FileOp> {
match self.tool_name.as_str() {
"Read" | "read_file" | "Glob" | "Grep" => Some(FileOp::Read),
"Write" | "write_file" => Some(FileOp::Write),
"Edit" | "replace" => Some(FileOp::Edit),
_ => None,
}
}
}
fn detect_hook_type(raw: &Value) -> HookType {
if raw.get("tool_result").is_some() {
HookType::PostToolUse
} else {
HookType::PreToolUse
}
}
fn detect_mode(raw: &Value) -> Result<Mode, RippyError> {
if let Some(tool_input) = raw.get("tool_input") {
if tool_input.is_object() && tool_input.get("command").is_some() {
return Ok(Mode::Claude);
}
if tool_input.is_string() {
return Ok(Mode::Gemini);
}
}
if raw.get("command").is_some() && raw.get("tool_input").is_none() {
return Ok(Mode::Cursor);
}
if raw.get("tool_name").is_some() {
return Ok(Mode::Claude);
}
Err(RippyError::UnknownMode(
"could not detect AI tool from payload".into(),
))
}
fn extract_command(raw: &Value, mode: Mode) -> Option<String> {
match mode {
Mode::Claude => raw
.get("tool_input")
.and_then(|ti| ti.get("command"))
.and_then(Value::as_str)
.map(String::from),
Mode::Gemini => raw
.get("tool_input")
.and_then(Value::as_str)
.map(String::from),
Mode::Cursor => raw.get("command").and_then(Value::as_str).map(String::from),
Mode::Codex => raw.get("tool_input").and_then(|ti| {
ti.as_str()
.map(String::from)
.or_else(|| ti.get("command").and_then(Value::as_str).map(String::from))
}),
}
}
fn extract_file_path(raw: &Value) -> Option<String> {
raw.get("tool_input")
.and_then(|ti| ti.get("file_path"))
.and_then(Value::as_str)
.map(String::from)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn claude_auto_detect() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
let payload = Payload::parse(json, None).unwrap();
assert_eq!(payload.mode, Mode::Claude);
assert_eq!(payload.command.as_deref(), Some("git status"));
assert_eq!(payload.tool_name, "Bash");
assert_eq!(payload.hook_type, HookType::PreToolUse);
assert!(payload.file_path.is_none());
}
#[test]
fn gemini_auto_detect() {
let json = r#"{"tool_name":"bash","tool_input":"ls -la"}"#;
let payload = Payload::parse(json, None).unwrap();
assert_eq!(payload.mode, Mode::Gemini);
assert_eq!(payload.command.as_deref(), Some("ls -la"));
}
#[test]
fn cursor_auto_detect() {
let json = r#"{"tool_name":"bash","command":"npm install"}"#;
let payload = Payload::parse(json, None).unwrap();
assert_eq!(payload.mode, Mode::Cursor);
assert_eq!(payload.command.as_deref(), Some("npm install"));
}
#[test]
fn forced_mode_overrides() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#;
let payload = Payload::parse(json, Some(Mode::Gemini)).unwrap();
assert_eq!(payload.mode, Mode::Gemini);
}
#[test]
fn mcp_detection() {
let json = r#"{"tool_name":"mcp__my_server__my_tool","tool_input":{}}"#;
let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
assert!(payload.is_mcp());
}
#[test]
fn post_tool_use_detection() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"},"tool_result":{"output":"file.txt"}}"#;
let payload = Payload::parse(json, None).unwrap();
assert_eq!(payload.hook_type, HookType::PostToolUse);
}
#[test]
fn non_mcp() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
let payload = Payload::parse(json, None).unwrap();
assert!(!payload.is_mcp());
}
#[test]
fn read_tool_extracts_file_path() {
let json = r#"{"tool_name":"Read","tool_input":{"file_path":"/tmp/.env"}}"#;
let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
assert_eq!(payload.file_path.as_deref(), Some("/tmp/.env"));
assert_eq!(payload.file_operation(), Some(FileOp::Read));
assert!(payload.command.is_none());
}
#[test]
fn write_tool_extracts_file_path() {
let json =
r#"{"tool_name":"Write","tool_input":{"file_path":"/tmp/out.txt","content":"hi"}}"#;
let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
assert_eq!(payload.file_path.as_deref(), Some("/tmp/out.txt"));
assert_eq!(payload.file_operation(), Some(FileOp::Write));
}
#[test]
fn edit_tool_extracts_file_path() {
let json = r#"{"tool_name":"Edit","tool_input":{"file_path":"main.rs","old_string":"a","new_string":"b"}}"#;
let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
assert_eq!(payload.file_path.as_deref(), Some("main.rs"));
assert_eq!(payload.file_operation(), Some(FileOp::Edit));
}
#[test]
fn gemini_read_file() {
let json = r#"{"tool_name":"read_file","tool_input":{"file_path":".env"}}"#;
let payload = Payload::parse(json, Some(Mode::Gemini)).unwrap();
assert_eq!(payload.file_operation(), Some(FileOp::Read));
assert_eq!(payload.file_path.as_deref(), Some(".env"));
}
#[test]
fn bash_tool_no_file_operation() {
let json = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
let payload = Payload::parse(json, None).unwrap();
assert_eq!(payload.file_operation(), None);
}
#[test]
fn glob_is_read_operation() {
let json = r#"{"tool_name":"Glob","tool_input":{"pattern":"**/*.rs"}}"#;
let payload = Payload::parse(json, Some(Mode::Claude)).unwrap();
assert_eq!(payload.file_operation(), Some(FileOp::Read));
}
}