use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
SessionStart,
UserPromptSubmit,
PreToolUse,
PostToolUse,
PreCompact,
}
#[derive(Debug, Clone)]
pub struct HookPayload {
pub cwd: Option<PathBuf>,
pub candidate_source: Value,
}
pub fn parse_hook_payload(event: HookEvent, input: &str) -> Option<HookPayload> {
let value: Value = serde_json::from_str(input).ok()?;
let obj = value.as_object()?;
if event == HookEvent::SessionStart {
return None;
}
let cwd = match obj.get("cwd") {
Some(Value::String(s)) => Some(PathBuf::from(s)),
_ => None,
};
let candidate_source = match event {
HookEvent::UserPromptSubmit => obj.get("prompt").cloned().unwrap_or(Value::Null),
HookEvent::PreToolUse => obj.get("tool_input").cloned().unwrap_or(Value::Null),
HookEvent::PostToolUse => obj.get("tool_response").cloned().unwrap_or(Value::Null),
HookEvent::PreCompact => obj.get("conversation").cloned().unwrap_or(Value::Null),
HookEvent::SessionStart => Value::Null,
};
Some(HookPayload {
cwd,
candidate_source,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event_payload(event: HookEvent, json: &str) -> Option<HookPayload> {
parse_hook_payload(event, json)
}
#[test]
fn parse_user_prompt_submit_extracts_prompt_and_cwd() {
let p = make_event_payload(
HookEvent::UserPromptSubmit,
r#"{"prompt": "hello world", "cwd": "/tmp/proj", "session_id": "x"}"#,
)
.expect("should parse");
assert_eq!(
p.cwd,
Some(std::path::PathBuf::from("/tmp/proj")),
"cwd must be extracted"
);
assert_eq!(
p.candidate_source,
Value::String("hello world".into()),
"prompt field must be the candidate source"
);
}
#[test]
fn parse_pre_tool_use_extracts_tool_input_object() {
let p = make_event_payload(
HookEvent::PreToolUse,
r#"{"tool_name": "Bash", "tool_input": {"command": "ls -la", "timeout": 30}, "cwd": "/tmp/proj"}"#,
)
.expect("should parse");
assert!(
p.candidate_source.is_object(),
"tool_input may be an object; the extractor must handle it"
);
assert_eq!(
p.candidate_source["command"],
Value::String("ls -la".into())
);
}
#[test]
fn parse_post_tool_use_extracts_tool_response() {
let p = make_event_payload(
HookEvent::PostToolUse,
r#"{"tool_response": {"stdout": "ok\n"}, "cwd": "/tmp/proj"}"#,
)
.expect("should parse");
assert!(p.candidate_source.is_object());
}
#[test]
fn parse_pre_compact_extracts_conversation() {
let p = make_event_payload(
HookEvent::PreCompact,
r#"{"conversation": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}], "cwd": "/tmp/proj"}"#,
)
.expect("should parse");
assert!(p.candidate_source.is_array());
}
#[test]
fn parse_session_start_returns_none() {
let p = make_event_payload(
HookEvent::SessionStart,
r#"{"session_id": "x", "cwd": "/tmp/proj"}"#,
);
assert!(
p.is_none(),
"SessionStart is a no-op — no payload extraction"
);
}
#[test]
fn parse_missing_cwd_yields_none_cwd() {
let p = make_event_payload(HookEvent::UserPromptSubmit, r#"{"prompt": "hi"}"#)
.expect("should parse");
assert!(
p.cwd.is_none(),
"missing cwd → None (caller silently drops the candidate)"
);
assert_eq!(p.candidate_source, Value::String("hi".into()));
}
#[test]
fn parse_missing_candidate_field_yields_null() {
let p = make_event_payload(HookEvent::UserPromptSubmit, r#"{"cwd": "/tmp/proj"}"#)
.expect("should parse");
assert_eq!(
p.candidate_source,
Value::Null,
"missing prompt field → Value::Null"
);
}
#[test]
fn parse_non_object_json_returns_none() {
assert!(parse_hook_payload(HookEvent::UserPromptSubmit, "[1,2,3]").is_none());
assert!(parse_hook_payload(HookEvent::UserPromptSubmit, "not json").is_none());
}
}