pub mod builtin;
pub mod context;
pub mod executor;
pub use executor::HookExecutor;
use std::fmt;
use context::{
CompactResult, PostCompactContext, PostToolUseContext, PreCompactContext, PreToolUseContext,
SessionEndContext, SessionStartContext,
};
pub trait Hook: Send + Sync {
fn name(&self) -> &str;
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
None
}
fn on_pre_compact(&self, _ctx: &PreCompactContext) -> Option<CompactResult> {
None
}
fn on_post_tool_use(&self, _ctx: &PostToolUseContext) {}
fn on_post_compact(&self, _ctx: &PostCompactContext) {}
fn on_session_start(&self, _ctx: &SessionStartContext) {}
fn on_session_end(&self, _ctx: &SessionEndContext) {}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Interactivity {
#[default]
Headless,
Interactive,
}
#[derive(Debug, Clone)]
pub enum HookAction {
Allow,
Block {
reason: String,
},
Ask {
message: String,
},
}
impl fmt::Display for HookAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Allow => write!(f, "Allow"),
Self::Block { reason } => write!(f, "Block({reason})"),
Self::Ask { message } => write!(f, "Ask({message})"),
}
}
}
impl HookAction {
pub fn block(reason: impl Into<String>) -> Self {
Self::Block {
reason: reason.into(),
}
}
pub fn ask(message: impl Into<String>) -> Self {
Self::Ask {
message: message.into(),
}
}
#[must_use]
pub fn is_allow(&self) -> bool {
matches!(self, Self::Allow)
}
#[must_use]
pub fn is_block(&self) -> bool {
matches!(self, Self::Block { .. })
}
#[must_use]
pub fn is_ask(&self) -> bool {
matches!(self, Self::Ask { .. })
}
#[must_use]
pub fn block_reason(&self) -> Option<&str> {
match self {
Self::Block { reason } => Some(reason),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_action_allow_is_allow() {
assert!(HookAction::Allow.is_allow());
assert!(!HookAction::Allow.is_block());
}
#[test]
fn hook_action_block_factory() {
let action = HookAction::block("dangerous");
assert!(action.is_block());
assert!(!action.is_allow());
match action {
HookAction::Block { reason } => assert_eq!(reason, "dangerous"),
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn hook_action_ask_factory() {
let action = HookAction::ask("confirm?");
match action {
HookAction::Ask { message } => assert_eq!(message, "confirm?"),
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn hook_action_display() {
assert_eq!(HookAction::Allow.to_string(), "Allow");
assert_eq!(HookAction::block("nope").to_string(), "Block(nope)");
assert_eq!(HookAction::ask("y/n").to_string(), "Ask(y/n)");
}
#[test]
fn block_reason_returns_none_for_allow() {
assert_eq!(HookAction::Allow.block_reason(), None);
}
#[test]
fn block_reason_returns_none_for_ask() {
assert_eq!(
HookAction::Ask {
message: "ok?".into()
}
.block_reason(),
None
);
}
#[test]
fn block_reason_returns_some_for_block() {
assert_eq!(
HookAction::Block { reason: "x".into() }.block_reason(),
Some("x")
);
}
#[test]
fn is_allow_returns_false_for_block() {
assert!(!HookAction::Block { reason: "x".into() }.is_allow());
}
#[test]
fn is_allow_returns_false_for_ask() {
assert!(
!HookAction::Ask {
message: "x".into()
}
.is_allow()
);
}
#[test]
fn is_block_returns_false_for_allow() {
assert!(!HookAction::Allow.is_block());
}
#[test]
fn is_block_returns_false_for_ask() {
assert!(
!HookAction::Ask {
message: "x".into()
}
.is_block()
);
}
#[test]
fn is_ask_true_for_ask() {
let action = HookAction::ask("proceed?");
assert!(action.is_ask());
}
#[test]
fn is_ask_false_for_block() {
let action = HookAction::block("nope");
assert!(!action.is_ask());
}
#[test]
fn is_ask_false_for_allow() {
assert!(!HookAction::Allow.is_ask());
}
#[test]
fn interactivity_default_is_headless() {
assert_eq!(Interactivity::default(), Interactivity::Headless);
}
#[test]
fn interactivity_eq_semantics() {
assert_eq!(Interactivity::Headless, Interactivity::Headless);
assert_eq!(Interactivity::Interactive, Interactivity::Interactive);
assert_ne!(Interactivity::Headless, Interactivity::Interactive);
}
}