use crate::domain::{SessionId, ToolName, ToolPermission};
use crate::protocol::Runtime;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HookInput {
pub session_id: SessionId,
#[serde(default)]
pub transcript_path: String,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub permission_mode: String,
pub hook_event_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<ToolName>,
#[serde(alias = "tool_parameters")]
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_input: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_response: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_hook_active: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trigger: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_response: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ClaudePreToolUseOutput {
#[serde(rename = "continue", default = "default_true")]
pub continue_: bool,
#[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
pub stop_reason: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "suppressOutput",
default
)]
pub suppress_output: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
pub system_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "hookSpecificOutput")]
pub hook_specific_output: Option<HookSpecificOutput>,
}
impl Default for ClaudePreToolUseOutput {
fn default() -> Self {
Self {
continue_: true, stop_reason: None,
suppress_output: None,
system_message: None,
hook_specific_output: None,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "hookEventName")]
pub enum HookSpecificOutput {
PreToolUse {
#[serde(rename = "permissionDecision")]
permission_decision: ToolPermission,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "permissionDecisionReason"
)]
permission_decision_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
updated_input: Option<Value>,
},
PostToolUse {
#[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
additional_context: Option<String>,
},
UserPromptSubmit {
#[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
additional_context: Option<String>,
},
SessionStart {
#[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
additional_context: Option<String>,
},
PermissionRequest {
decision: PermissionDecision,
},
Stop {
#[serde(skip_serializing_if = "Option::is_none")]
decision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
SubagentStop {
#[serde(skip_serializing_if = "Option::is_none")]
decision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Notification,
PreCompact,
SessionEnd,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "behavior")]
pub enum PermissionDecision {
#[serde(rename = "allow")]
Allow {
#[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
updated_input: Option<Value>,
},
#[serde(rename = "deny")]
Deny {
message: String,
#[serde(default)]
interrupt: bool,
},
}
impl ClaudePreToolUseOutput {
pub fn pre_tool_use_allow(reason: Option<String>, modified_input: Option<Value>) -> Self {
Self {
continue_: true,
hook_specific_output: Some(HookSpecificOutput::PreToolUse {
permission_decision: ToolPermission::Allow,
permission_decision_reason: reason,
updated_input: modified_input,
}),
..Default::default()
}
}
pub fn pre_tool_use_deny(reason: String) -> Self {
Self {
continue_: true,
hook_specific_output: Some(HookSpecificOutput::PreToolUse {
permission_decision: ToolPermission::Deny,
permission_decision_reason: Some(reason),
updated_input: None,
}),
..Default::default()
}
}
pub fn post_tool_use_allow(additional_context: Option<String>) -> Self {
Self {
continue_: true,
hook_specific_output: Some(HookSpecificOutput::PostToolUse { additional_context }),
..Default::default()
}
}
pub fn block(reason: String) -> Self {
Self {
continue_: false,
stop_reason: Some(reason),
..Default::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StopDecision {
Allow,
Block,
}
#[derive(Debug, Clone, Deserialize)]
pub struct InternalStopHookOutput {
pub decision: StopDecision,
pub reason: Option<String>,
}
impl InternalStopHookOutput {
pub fn to_claude(&self) -> ClaudeStopHookOutput {
match self.decision {
StopDecision::Allow => ClaudeStopHookOutput {
continue_: true,
stop_reason: None,
},
StopDecision::Block => ClaudeStopHookOutput {
continue_: false,
stop_reason: self.reason.clone(),
},
}
}
pub fn to_gemini(&self) -> GeminiStopHookOutput {
match self.decision {
StopDecision::Allow => GeminiStopHookOutput {
decision: GeminiStopDecision::Allow,
reason: None,
continue_: true,
clear_context: None,
system_message: None,
suppress_output: None,
},
StopDecision::Block => GeminiStopHookOutput {
decision: GeminiStopDecision::Deny, reason: self.reason.clone(),
continue_: true,
clear_context: None,
system_message: None,
suppress_output: None,
},
}
}
pub fn to_runtime_json(&self, runtime: &Runtime) -> String {
match runtime {
Runtime::Claude => serde_json::to_string(&self.to_claude())
.unwrap_or_else(|_| r#"{"continue":true}"#.to_string()),
Runtime::Gemini => serde_json::to_string(&self.to_gemini())
.unwrap_or_else(|_| r#"{"decision":"allow"}"#.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ClaudeStopHookOutput {
#[serde(rename = "continue")]
pub continue_: bool,
#[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
pub stop_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GeminiStopDecision {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GeminiStopHookOutput {
pub decision: GeminiStopDecision,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(rename = "continue", default = "default_true")]
pub continue_: bool,
#[serde(skip_serializing_if = "Option::is_none", rename = "clearContext")]
pub clear_context: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
pub system_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "suppressOutput")]
pub suppress_output: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_input_deserialize() {
let json = r#"{
"session_id": "abc123",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/home/user/project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "hello"},
"tool_use_id": "toolu_123"
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.hook_event_name, "PreToolUse");
assert_eq!(input.tool_name.as_ref().map(|t| t.as_str()), Some("Write"));
}
#[test]
fn test_hook_output_serialize() {
let output = ClaudePreToolUseOutput::pre_tool_use_allow(
Some("Allowed by ExoMonad".to_string()),
Some(serde_json::json!({"file_path": "/tmp/safe.txt"})),
);
let json = serde_json::to_string_pretty(&output).unwrap();
assert!(json.contains("permissionDecision"));
assert!(json.contains("allow"));
}
#[test]
fn test_hook_output_pre_tool_use_allow_format() {
let output = ClaudePreToolUseOutput::pre_tool_use_allow(Some("test reason".into()), None);
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["continue"], true);
assert!(
json["stopReason"].is_null() || !json.as_object().unwrap().contains_key("stopReason")
);
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["hookEventName"], "PreToolUse");
assert_eq!(specific["permissionDecision"], "allow");
assert_eq!(specific["permissionDecisionReason"], "test reason");
}
#[test]
fn test_hook_output_pre_tool_use_deny_format() {
let output = ClaudePreToolUseOutput::pre_tool_use_deny("not allowed".into());
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["continue"], true); let specific = &json["hookSpecificOutput"];
assert_eq!(specific["hookEventName"], "PreToolUse");
assert_eq!(specific["permissionDecision"], "deny");
assert_eq!(specific["permissionDecisionReason"], "not allowed");
}
#[test]
fn test_hook_output_pre_tool_use_with_updated_input() {
let modified = serde_json::json!({"file_path": "/safe/path.txt"});
let output = ClaudePreToolUseOutput::pre_tool_use_allow(None, Some(modified.clone()));
let json = serde_json::to_value(&output).unwrap();
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["updatedInput"], modified);
}
#[test]
fn test_hook_output_block_format() {
let output = ClaudePreToolUseOutput::block("session terminated".into());
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["continue"], false);
assert_eq!(json["stopReason"], "session terminated");
}
#[test]
fn test_hook_output_post_tool_use_format() {
let output = ClaudePreToolUseOutput::post_tool_use_allow(Some("additional context".into()));
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["continue"], true);
let specific = &json["hookSpecificOutput"];
assert_eq!(specific["hookEventName"], "PostToolUse");
assert_eq!(specific["additionalContext"], "additional context");
}
#[test]
fn test_hook_output_default() {
let output = ClaudePreToolUseOutput::default();
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["continue"], true);
}
#[test]
fn test_hook_input_minimal() {
let json = r#"{"session_id":"s","hook_event_name":"Stop"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.session_id.as_str(), "s");
assert_eq!(input.hook_event_name, "Stop");
assert!(input.tool_name.is_none());
}
#[test]
fn test_hook_input_with_tool_parameters_alias() {
let json = r#"{"session_id":"s","hook_event_name":"PreToolUse","tool_parameters":{"key":"value"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert!(input.tool_input.is_some());
assert_eq!(input.tool_input.unwrap()["key"], "value");
}
#[test]
fn test_hook_input_extra_fields_ignored() {
let json = r#"{"session_id":"s","hook_event_name":"Stop","unknown_field":"ignored","another":123}"#;
let result: Result<HookInput, _> = serde_json::from_str(json);
assert!(result.is_ok());
}
#[test]
fn test_hook_input_all_fields() {
let json = r#"{
"session_id": "sess-123",
"transcript_path": "/tmp/t.jsonl",
"cwd": "/home/user",
"permission_mode": "plan",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {"file_path": "/x"},
"tool_use_id": "toolu_abc",
"prompt": "user prompt",
"message": "notification",
"stop_hook_active": true
}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.session_id.as_str(), "sess-123");
assert_eq!(input.cwd, "/home/user");
assert_eq!(input.permission_mode, "plan");
assert_eq!(input.tool_name.as_ref().map(|t| t.as_str()), Some("Write"));
assert_eq!(input.prompt, Some("user prompt".into()));
assert_eq!(input.stop_hook_active, Some(true));
}
}