use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::wire::HOOK_SCHEMA_VERSION;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
#[default]
PreToolUse,
PostToolUse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookCall {
pub agent_id: String,
pub tool_name: String,
pub tool_call_id: String,
pub input_json: String,
}
impl HookCall {
pub fn new(
agent_id: impl Into<String>,
tool_name: impl Into<String>,
tool_call_id: impl Into<String>,
input_json: impl Into<String>,
) -> Self {
Self {
agent_id: agent_id.into(),
tool_name: tool_name.into(),
tool_call_id: tool_call_id.into(),
input_json: input_json.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookRequest {
pub hook_schema: u32,
pub event: HookEvent,
pub workspace: PathBuf,
pub agent_id: String,
pub tool_call_id: String,
pub tool_name: String,
pub input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
impl HookRequest {
pub fn from_call(event: HookEvent, workspace: &Path, call: &HookCall) -> Self {
Self {
hook_schema: HOOK_SCHEMA_VERSION,
event,
workspace: workspace.to_path_buf(),
agent_id: call.agent_id.clone(),
tool_call_id: call.tool_call_id.clone(),
tool_name: call.tool_name.clone(),
input: serde_json::from_str(&call.input_json)
.unwrap_or_else(|_| Value::String(call.input_json.clone())),
output: None,
is_error: None,
}
}
pub fn from_result(workspace: &Path, call: &HookCall, output: Value, is_error: bool) -> Self {
Self {
output: Some(output),
is_error: Some(is_error),
..Self::from_call(HookEvent::PostToolUse, workspace, call)
}
}
pub fn with_input(self, input: Value) -> Self {
Self { input, ..self }
}
pub fn with_output(self, output: Value, is_error: bool) -> Self {
Self {
output: Some(output),
is_error: Some(is_error),
..self
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookOutcome {
Allow,
Deny(String),
Modify {
input: Value,
reason: Option<String>,
},
Replace {
output: Value,
is_error: bool,
reason: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn call(input_json: &str) -> HookCall {
HookCall::new("agent-1", "shell", "call-1", input_json)
}
#[test]
fn a_request_carries_the_schema_version_first() {
let request = HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&call(r#"{"command":"ls"}"#),
);
let json = serde_json::to_value(&request).expect("serializes");
assert_eq!(json["hook_schema"], HOOK_SCHEMA_VERSION);
assert_eq!(json["event"], "pre_tool_use");
assert_eq!(json["workspace"], "/repo");
assert_eq!(json["tool_name"], "shell");
assert_eq!(json["tool_call_id"], "call-1");
assert_eq!(json["input"]["command"], "ls");
}
#[test]
fn input_that_is_not_json_is_carried_as_the_raw_string() {
let request =
HookRequest::from_call(HookEvent::PreToolUse, Path::new("/repo"), &call("ls -l"));
assert_eq!(request.input, json!("ls -l"));
}
#[test]
fn a_request_can_be_rebuilt_around_a_new_input() {
let original = HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&call(r#"{"command":"rm -rf /"}"#),
);
let next = original.clone().with_input(json!({"command": "ls"}));
assert_eq!(
original.input,
json!({"command": "rm -rf /"}),
"the original must be untouched"
);
assert_eq!(next.input, json!({"command": "ls"}));
assert_eq!(next.tool_call_id, original.tool_call_id);
}
#[test]
fn a_result_request_carries_the_call_that_produced_it() {
let request = HookRequest::from_result(
Path::new("/repo"),
&call(r#"{"command":"cat .env"}"#),
json!("TOKEN=hunter2"),
false,
);
let json = serde_json::to_value(&request).expect("serializes");
assert_eq!(json["hook_schema"], HOOK_SCHEMA_VERSION);
assert_eq!(json["event"], "post_tool_use");
assert_eq!(json["tool_call_id"], "call-1");
assert_eq!(json["input"]["command"], "cat .env");
assert_eq!(json["output"], "TOKEN=hunter2");
assert_eq!(json["is_error"], false);
}
#[test]
fn a_call_that_has_not_run_carries_no_result_at_all() {
let request = HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&call(r#"{"command":"ls"}"#),
);
let json = serde_json::to_value(&request).expect("serializes");
assert_eq!(request.output, None);
assert_eq!(request.is_error, None);
assert!(json.get("output").is_none(), "got {json}");
assert!(json.get("is_error").is_none(), "got {json}");
}
#[test]
fn a_request_can_be_rebuilt_around_a_new_result() {
let original = HookRequest::from_result(
Path::new("/repo"),
&call("{}"),
json!("TOKEN=hunter2"),
false,
);
let next = original.clone().with_output(json!("[redacted]"), true);
assert_eq!(
original.output,
Some(json!("TOKEN=hunter2")),
"the original must be untouched"
);
assert_eq!(original.is_error, Some(false));
assert_eq!(next.output, Some(json!("[redacted]")));
assert_eq!(next.is_error, Some(true));
assert_eq!(next.tool_call_id, original.tool_call_id);
}
}