use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HookEventKind {
PrePrompt,
PreTool,
PostTool,
PermissionRequest,
SessionStart,
SessionEnd,
Compact,
ModeSwitch,
ModelSwitch,
Error,
Other,
}
impl HookEventKind {
pub fn parse(s: &str) -> Option<Self> {
match s.trim() {
"PrePrompt" | "pre_prompt" | "UserPromptSubmit" => Some(Self::PrePrompt),
"PreTool" | "PreToolUse" | "pre_tool" | "preToolUse" => Some(Self::PreTool),
"PostTool" | "PostToolUse" | "post_tool" | "postToolUse" => Some(Self::PostTool),
"PermissionRequest" | "permission_request" => Some(Self::PermissionRequest),
"SessionStart" | "OnSessionStart" | "session_start" | "startup" => {
Some(Self::SessionStart)
}
"SessionEnd" | "OnSessionEnd" | "session_end" => Some(Self::SessionEnd),
"Compact" | "OnCompact" | "PreCompact" | "PostCompact" | "compact" => {
Some(Self::Compact)
}
"ModeSwitch" | "OnModeSwitch" | "mode_switch" => Some(Self::ModeSwitch),
"ModelSwitch" | "OnModelSwitch" | "model_switch" => Some(Self::ModelSwitch),
"Error" | "OnError" | "error" => Some(Self::Error),
"Other" | "other" => Some(Self::Other),
_ => None,
}
}
pub fn default_merge_mode(self) -> MergeMode {
match self {
Self::PreTool => MergeMode::PreTool,
Self::PostTool => MergeMode::PostTool,
Self::PermissionRequest => MergeMode::PermissionRequest,
_ => MergeMode::InjectOnly,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookEvent {
pub kind: HookEventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default)]
pub meta: Value,
}
impl Default for HookEvent {
fn default() -> Self {
Self::unit(HookEventKind::Other)
}
}
impl HookEvent {
pub fn pre_tool(tool: impl Into<String>, args: Value) -> Self {
Self {
kind: HookEventKind::PreTool,
tool: Some(tool.into()),
args: Some(args),
result: None,
meta: Value::Null,
}
}
pub fn post_tool(tool: impl Into<String>, result: impl Into<String>) -> Self {
Self {
kind: HookEventKind::PostTool,
tool: Some(tool.into()),
args: None,
result: Some(result.into()),
meta: Value::Null,
}
}
pub fn permission_request(tool: impl Into<String>, args: Value) -> Self {
Self {
kind: HookEventKind::PermissionRequest,
tool: Some(tool.into()),
args: Some(args),
result: None,
meta: Value::Null,
}
}
pub fn unit(kind: HookEventKind) -> Self {
Self {
kind,
tool: None,
args: None,
result: None,
meta: Value::Null,
}
}
pub fn with_meta(mut self, meta: Value) -> Self {
self.meta = meta;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextChannel {
PrePrompt,
ToolPreface,
UiNotice,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookOutcome {
Continue,
AdditionalContext {
text: String,
#[serde(default = "default_pre_prompt_channel")]
channel: ContextChannel,
},
Deny {
reason: String,
},
MutateArgs {
args: Value,
},
Allow,
Ask,
ReplaceResult {
text: String,
},
}
fn default_pre_prompt_channel() -> ContextChannel {
ContextChannel::PrePrompt
}
impl HookOutcome {
pub fn context(text: impl Into<String>) -> Self {
Self::AdditionalContext {
text: text.into(),
channel: ContextChannel::PrePrompt,
}
}
pub fn ui_notice(text: impl Into<String>) -> Self {
Self::AdditionalContext {
text: text.into(),
channel: ContextChannel::UiNotice,
}
}
pub fn deny(reason: impl Into<String>) -> Self {
Self::Deny {
reason: reason.into(),
}
}
pub fn mutate_args(args: Value) -> Self {
Self::MutateArgs { args }
}
pub fn replace_result(text: impl Into<String>) -> Self {
Self::ReplaceResult { text: text.into() }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MergeMode {
#[default]
PreTool,
PostTool,
PermissionRequest,
InjectOnly,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PreToolEffect {
pub deny: Option<String>,
pub args: Option<Value>,
pub contexts: Vec<(ContextChannel, String)>,
}
impl PreToolEffect {
pub fn is_denied(&self) -> bool {
self.deny.is_some()
}
pub fn final_args<'a>(&'a self, original: &'a Value) -> &'a Value {
self.args.as_ref().unwrap_or(original)
}
pub fn into_final_args(self, original: Value) -> Result<Value, String> {
if let Some(reason) = self.deny {
return Err(reason);
}
Ok(self.args.unwrap_or(original))
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PostToolEffect {
pub replace_result: Option<String>,
pub block_feedback: Option<String>,
pub contexts: Vec<(ContextChannel, String)>,
}
impl PostToolEffect {
pub fn effective_result<'a>(&'a self, original: &'a str) -> &'a str {
if let Some(r) = self.replace_result.as_deref() {
return r;
}
if let Some(b) = self.block_feedback.as_deref() {
return b;
}
original
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum PermissionDecision {
#[default]
Unspecified,
Allow,
Deny(String),
Ask,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PermissionEffect {
pub decision: PermissionDecision,
pub contexts: Vec<(ContextChannel, String)>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct InjectEffect {
pub contexts: Vec<(ContextChannel, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MergedEffect {
PreTool(PreToolEffect),
PostTool(PostToolEffect),
Permission(PermissionEffect),
Inject(InjectEffect),
}
impl MergedEffect {
pub fn contexts(&self) -> &[(ContextChannel, String)] {
match self {
Self::PreTool(e) => &e.contexts,
Self::PostTool(e) => &e.contexts,
Self::Permission(e) => &e.contexts,
Self::Inject(e) => &e.contexts,
}
}
pub fn as_pre_tool(&self) -> Option<&PreToolEffect> {
match self {
Self::PreTool(e) => Some(e),
_ => None,
}
}
pub fn as_post_tool(&self) -> Option<&PostToolEffect> {
match self {
Self::PostTool(e) => Some(e),
_ => None,
}
}
pub fn as_permission(&self) -> Option<&PermissionEffect> {
match self {
Self::Permission(e) => Some(e),
_ => None,
}
}
}
pub fn merge_outcomes(mode: MergeMode, outcomes: &[HookOutcome]) -> MergedEffect {
match mode {
MergeMode::PreTool => MergedEffect::PreTool(merge_pre_tool(outcomes)),
MergeMode::PostTool => MergedEffect::PostTool(merge_post_tool(outcomes)),
MergeMode::PermissionRequest => MergedEffect::Permission(merge_permission(outcomes)),
MergeMode::InjectOnly => MergedEffect::Inject(merge_inject(outcomes)),
}
}
pub fn merge_pre_tool(outcomes: &[HookOutcome]) -> PreToolEffect {
let mut effect = PreToolEffect::default();
for o in outcomes {
match o {
HookOutcome::Deny { reason } => {
effect.deny = Some(reason.clone());
break;
}
HookOutcome::MutateArgs { args } => {
effect.args = Some(args.clone());
}
HookOutcome::AdditionalContext { text, channel } => {
effect.contexts.push((*channel, text.clone()));
}
HookOutcome::Continue
| HookOutcome::Allow
| HookOutcome::Ask
| HookOutcome::ReplaceResult { .. } => {}
}
}
effect
}
pub fn merge_post_tool(outcomes: &[HookOutcome]) -> PostToolEffect {
let mut effect = PostToolEffect::default();
for o in outcomes {
match o {
HookOutcome::Deny { reason } => {
effect.block_feedback = Some(reason.clone());
}
HookOutcome::ReplaceResult { text } => {
effect.replace_result = Some(text.clone());
}
HookOutcome::AdditionalContext { text, channel } => {
effect.contexts.push((*channel, text.clone()));
}
HookOutcome::Continue
| HookOutcome::MutateArgs { .. }
| HookOutcome::Allow
| HookOutcome::Ask => {}
}
}
effect
}
pub fn merge_permission(outcomes: &[HookOutcome]) -> PermissionEffect {
let mut effect = PermissionEffect::default();
let mut saw_allow = false;
let mut saw_ask = false;
for o in outcomes {
match o {
HookOutcome::Deny { reason } => {
effect.decision = PermissionDecision::Deny(reason.clone());
break;
}
HookOutcome::Allow => saw_allow = true,
HookOutcome::Ask => saw_ask = true,
HookOutcome::AdditionalContext { text, channel } => {
effect.contexts.push((*channel, text.clone()));
}
_ => {}
}
}
if matches!(effect.decision, PermissionDecision::Unspecified) {
if saw_allow {
effect.decision = PermissionDecision::Allow;
} else if saw_ask {
effect.decision = PermissionDecision::Ask;
}
}
effect
}
pub fn merge_inject(outcomes: &[HookOutcome]) -> InjectEffect {
let mut effect = InjectEffect::default();
for o in outcomes {
if let HookOutcome::AdditionalContext { text, channel } = o {
effect.contexts.push((*channel, text.clone()));
}
}
effect
}
pub fn apply_pre_tool_args(original: &Value, effect: &PreToolEffect) -> Value {
effect
.args
.clone()
.unwrap_or_else(|| original.clone())
}
pub fn contexts_for_channel(
contexts: &[(ContextChannel, String)],
channel: ContextChannel,
) -> Vec<&str> {
contexts
.iter()
.filter(|(c, _)| *c == channel)
.map(|(_, t)| t.as_str())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_claude_aliases() {
assert_eq!(
HookEventKind::parse("PreToolUse"),
Some(HookEventKind::PreTool)
);
assert_eq!(
HookEventKind::parse("PostToolUse"),
Some(HookEventKind::PostTool)
);
assert_eq!(
HookEventKind::parse("SessionStart"),
Some(HookEventKind::SessionStart)
);
assert_eq!(HookEventKind::parse("UserPromptSubmit"), Some(HookEventKind::PrePrompt));
assert_eq!(HookEventKind::parse("nope"), None);
}
#[test]
fn default_merge_mode_mapping() {
assert_eq!(
HookEventKind::PreTool.default_merge_mode(),
MergeMode::PreTool
);
assert_eq!(
HookEventKind::SessionStart.default_merge_mode(),
MergeMode::InjectOnly
);
}
#[test]
fn pre_tool_deny_short_circuits() {
let outcomes = [
HookOutcome::mutate_args(json!({"command": "echo a"})),
HookOutcome::deny("nope"),
HookOutcome::mutate_args(json!({"command": "echo b"})),
];
let e = merge_pre_tool(&outcomes);
assert_eq!(e.deny.as_deref(), Some("nope"));
assert_eq!(e.args, Some(json!({"command": "echo a"})));
assert!(e.is_denied());
assert!(e.into_final_args(json!({})).is_err());
}
#[test]
fn pre_tool_mutate_chains() {
let outcomes = [
HookOutcome::mutate_args(json!({"command": "git status"})),
HookOutcome::mutate_args(json!({"command": "rtk git status"})),
HookOutcome::context("note"),
];
let e = merge_pre_tool(&outcomes);
assert!(!e.is_denied());
assert_eq!(e.args, Some(json!({"command": "rtk git status"})));
assert_eq!(e.contexts.len(), 1);
assert_eq!(
apply_pre_tool_args(&json!({"command": "raw"}), &e),
json!({"command": "rtk git status"})
);
let owned = e
.clone()
.into_final_args(json!({"command": "raw"}));
assert!(matches!(owned, Ok(v) if v == json!({"command": "rtk git status"})));
}
#[test]
fn pre_tool_no_mutate_keeps_original_ref() {
let e = PreToolEffect::default();
let original = json!({"a": 1});
assert_eq!(e.final_args(&original), &original);
}
#[test]
fn post_tool_replace_last_wins() {
let outcomes = [
HookOutcome::replace_result("first"),
HookOutcome::replace_result("second"),
HookOutcome::context("ctx"),
];
let e = merge_post_tool(&outcomes);
assert_eq!(e.replace_result.as_deref(), Some("second"));
assert_eq!(e.effective_result("orig"), "second");
assert_eq!(e.contexts.len(), 1);
}
#[test]
fn post_tool_deny_becomes_block_feedback() {
let outcomes = [HookOutcome::deny("needs review")];
let e = merge_post_tool(&outcomes);
assert_eq!(e.block_feedback.as_deref(), Some("needs review"));
assert_eq!(e.effective_result("orig"), "needs review");
}
#[test]
fn post_tool_replace_beats_block_feedback() {
let outcomes = [
HookOutcome::deny("block"),
HookOutcome::replace_result("replaced"),
];
let e = merge_post_tool(&outcomes);
assert_eq!(e.effective_result("orig"), "replaced");
}
#[test]
fn permission_deny_beats_allow() {
let outcomes = [
HookOutcome::Allow,
HookOutcome::deny("policy"),
HookOutcome::Ask,
];
let e = merge_permission(&outcomes);
assert_eq!(e.decision, PermissionDecision::Deny("policy".into()));
}
#[test]
fn permission_allow_over_ask() {
let outcomes = [HookOutcome::Ask, HookOutcome::Allow];
let e = merge_permission(&outcomes);
assert_eq!(e.decision, PermissionDecision::Allow);
}
#[test]
fn permission_ask_only() {
let e = merge_permission(&[HookOutcome::Ask]);
assert_eq!(e.decision, PermissionDecision::Ask);
}
#[test]
fn permission_unspecified() {
let e = merge_permission(&[HookOutcome::Continue]);
assert_eq!(e.decision, PermissionDecision::Unspecified);
}
#[test]
fn inject_filters_non_context() {
let e = merge_inject(&[
HookOutcome::Continue,
HookOutcome::deny("x"),
HookOutcome::ui_notice("ui"),
HookOutcome::context("model"),
]);
assert_eq!(e.contexts.len(), 2);
assert_eq!(
contexts_for_channel(&e.contexts, ContextChannel::UiNotice),
vec!["ui"]
);
assert_eq!(
contexts_for_channel(&e.contexts, ContextChannel::PrePrompt),
vec!["model"]
);
}
#[test]
fn merge_outcomes_dispatch() {
let m = merge_outcomes(MergeMode::PreTool, &[HookOutcome::deny("d")]);
assert!(m.as_pre_tool().is_some_and(|e| e.is_denied()));
let m = merge_outcomes(MergeMode::PostTool, &[HookOutcome::replace_result("r")]);
assert!(m.as_post_tool().is_some_and(|e| e.replace_result.as_deref() == Some("r")));
let m = merge_outcomes(MergeMode::PermissionRequest, &[HookOutcome::Allow]);
assert!(m
.as_permission()
.is_some_and(|e| e.decision == PermissionDecision::Allow));
}
#[test]
fn event_builders_and_serde() {
let ev = HookEvent::pre_tool("shell", json!({"command": "ls"}))
.with_meta(json!({"session": "s1"}));
let Ok(s) = serde_json::to_string(&ev) else {
panic!("serialize failed");
};
let Ok(back) = serde_json::from_str::<HookEvent>(&s) else {
panic!("deserialize failed");
};
assert_eq!(back.kind, HookEventKind::PreTool);
assert_eq!(back.tool.as_deref(), Some("shell"));
assert_eq!(back.meta.get("session").and_then(|v| v.as_str()), Some("s1"));
let p = HookEvent::permission_request("shell", json!({}));
assert_eq!(p.kind, HookEventKind::PermissionRequest);
let post = HookEvent::post_tool("shell", "ok");
assert_eq!(post.result.as_deref(), Some("ok"));
}
#[test]
fn outcome_serde_roundtrip() {
let outcomes = [
HookOutcome::Continue,
HookOutcome::context("c"),
HookOutcome::deny("d"),
HookOutcome::mutate_args(json!({"x": 1})),
HookOutcome::Allow,
HookOutcome::Ask,
HookOutcome::replace_result("r"),
];
for o in &outcomes {
let Ok(s) = serde_json::to_string(o) else {
panic!("ser");
};
let Ok(back) = serde_json::from_str::<HookOutcome>(&s) else {
panic!("de {s}");
};
assert_eq!(&back, o);
}
}
}