use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::hooks::Hook;
use crate::hooks::HookAction;
use crate::hooks::Interactivity;
use crate::hooks::context::{
CompactResult, PostCompactContext, PostToolUseContext, PreCompactContext, PreToolUseContext,
SessionEndContext, SessionStartContext,
};
pub struct HookExecutor {
hooks: Vec<Arc<dyn Hook>>,
interactivity: Interactivity,
}
impl Default for HookExecutor {
fn default() -> Self {
Self::new()
}
}
impl HookExecutor {
#[must_use]
pub fn new() -> Self {
Self {
hooks: Vec::new(),
interactivity: Interactivity::Headless,
}
}
#[must_use]
pub fn with_interactivity(mut self, interactivity: Interactivity) -> Self {
self.interactivity = interactivity;
self
}
#[must_use]
pub fn with_hook(mut self, hook: Arc<dyn Hook>) -> Self {
self.hooks.push(hook);
self
}
pub fn register(&mut self, hook: Arc<dyn Hook>) {
self.hooks.push(hook);
}
fn apply_interactivity(&self, action: HookAction) -> HookAction {
match (&self.interactivity, action) {
(Interactivity::Headless, HookAction::Ask { message }) => HookAction::block(format!(
"Hook requested confirmation ({message}) but the session is not interactive"
)),
(_, action) => action,
}
}
#[must_use]
pub fn hook_count(&self) -> usize {
self.hooks.len()
}
#[must_use]
pub fn check_pre_tool_use(&self, ctx: &PreToolUseContext) -> HookAction {
for hook in &self.hooks {
if let Some(action) = hook.on_pre_tool_use(ctx) {
match action {
HookAction::Allow => {}
other => return self.apply_interactivity(other),
}
}
}
HookAction::Allow
}
#[must_use]
pub fn check_pre_tool_use_async(
&self,
ctx: &PreToolUseContext,
) -> Pin<Box<dyn Future<Output = HookAction> + Send + '_>> {
let action = self.check_pre_tool_use(ctx);
Box::pin(async move { action })
}
#[must_use]
pub fn check_pre_compact(&self, ctx: &PreCompactContext) -> CompactResult {
let mut result = CompactResult::allow();
for hook in &self.hooks {
if let Some(hook_result) = hook.on_pre_compact(ctx) {
if hook_result.abort {
return hook_result;
}
if let Some(instr) = hook_result.new_instructions {
result.new_instructions = Some(instr);
}
result
.additional_context
.extend(hook_result.additional_context);
}
}
result
}
#[must_use]
pub fn check_pre_compact_async(
&self,
ctx: &PreCompactContext,
) -> Pin<Box<dyn Future<Output = CompactResult> + Send + '_>> {
let result = self.check_pre_compact(ctx);
Box::pin(async move { result })
}
pub fn notify_post_tool_use(&self, ctx: &PostToolUseContext) {
for hook in &self.hooks {
hook.on_post_tool_use(ctx);
}
}
#[must_use]
pub fn notify_post_tool_use_async(
&self,
ctx: &PostToolUseContext,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
self.notify_post_tool_use(ctx);
Box::pin(async {})
}
pub fn notify_post_compact(&self, ctx: &PostCompactContext) {
for hook in &self.hooks {
hook.on_post_compact(ctx);
}
}
pub fn notify_session_start(&self, ctx: &SessionStartContext) {
for hook in &self.hooks {
hook.on_session_start(ctx);
}
}
pub fn notify_session_end(&self, ctx: &SessionEndContext) {
for hook in &self.hooks {
hook.on_session_end(ctx);
}
}
#[must_use]
pub fn notify_post_compact_async(
&self,
ctx: &PostCompactContext,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
self.notify_post_compact(ctx);
Box::pin(async {})
}
#[must_use]
pub fn notify_session_start_async(
&self,
ctx: &SessionStartContext,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
self.notify_session_start(ctx);
Box::pin(async {})
}
#[must_use]
pub fn notify_session_end_async(
&self,
ctx: &SessionEndContext,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
self.notify_session_end(ctx);
Box::pin(async {})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::context::{CompactTrigger, SessionEndReason};
use serde_json::json;
struct AllowHook;
impl Hook for AllowHook {
fn name(&self) -> &'static str {
"allow"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
None
}
}
struct BlockHook {
reason: String,
}
impl Hook for BlockHook {
fn name(&self) -> &'static str {
"block"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::block(&self.reason))
}
}
struct PostRecorder {
called: std::sync::atomic::AtomicBool,
}
impl PostRecorder {
fn new() -> Self {
Self {
called: std::sync::atomic::AtomicBool::new(false),
}
}
fn was_called(&self) -> bool {
self.called.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl Hook for PostRecorder {
fn name(&self) -> &'static str {
"post_recorder"
}
fn on_post_tool_use(&self, _ctx: &PostToolUseContext) {
self.called
.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
fn dummy_pre_ctx() -> PreToolUseContext {
PreToolUseContext {
tool_name: "test_tool".to_string(),
input: json!({}),
session_id: uuid::Uuid::nil(),
turn_number: 0,
}
}
#[test]
fn empty_executor_allows() {
let executor = HookExecutor::new();
let ctx = dummy_pre_ctx();
assert!(matches!(
executor.check_pre_tool_use(&ctx),
HookAction::Allow
));
}
#[test]
fn allow_hook_passes_through() {
let executor = HookExecutor::new().with_hook(Arc::new(AllowHook));
let ctx = dummy_pre_ctx();
assert!(matches!(
executor.check_pre_tool_use(&ctx),
HookAction::Allow
));
}
#[test]
fn block_hook_short_circuits() {
let executor = HookExecutor::new().with_hook(Arc::new(BlockHook {
reason: "blocked".to_string(),
}));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
match action {
HookAction::Block { reason } => assert_eq!(reason, "blocked"),
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn first_block_wins() {
let executor = HookExecutor::new()
.with_hook(Arc::new(BlockHook {
reason: "first".to_string(),
}))
.with_hook(Arc::new(BlockHook {
reason: "second".to_string(),
}));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
match action {
HookAction::Block { reason } => assert_eq!(reason, "first"),
other => panic!("expected first Block, got {other:?}"),
}
}
#[test]
fn post_hooks_all_run() {
let recorder = Arc::new(PostRecorder::new());
let executor = HookExecutor::new()
.with_hook(recorder.clone())
.with_hook(recorder.clone());
let ctx = PostToolUseContext {
tool_name: "test".to_string(),
input: json!({}),
output: "ok".to_string(),
is_error: false,
duration_ms: 10,
session_id: uuid::Uuid::nil(),
turn_number: 0,
};
executor.notify_post_tool_use(&ctx);
assert!(recorder.was_called());
}
#[test]
fn hook_count_tracks_registrations() {
let executor = HookExecutor::new();
assert_eq!(executor.hook_count(), 0);
let executor = executor.with_hook(Arc::new(AllowHook));
assert_eq!(executor.hook_count(), 1);
}
#[test]
fn check_pre_compact_merges_instructions() {
struct InstructionHook;
impl Hook for InstructionHook {
fn name(&self) -> &'static str {
"instruction"
}
fn on_pre_compact(&self, _ctx: &PreCompactContext) -> Option<CompactResult> {
Some(
CompactResult::allow()
.with_instructions("focus on tests")
.with_context("keep test suite"),
)
}
}
let executor = HookExecutor::new().with_hook(Arc::new(InstructionHook));
let ctx = PreCompactContext {
trigger: CompactTrigger::Auto,
custom_instructions: None,
message_count: 10,
tokens_before: 5000,
context_window: 200_000,
session_id: uuid::Uuid::nil(),
};
let result = executor.check_pre_compact(&ctx);
assert!(!result.abort);
assert_eq!(result.new_instructions.as_deref(), Some("focus on tests"));
assert_eq!(result.additional_context.len(), 1);
}
#[test]
fn check_pre_compact_abort_takes_priority() {
struct AbortHook;
impl Hook for AbortHook {
fn name(&self) -> &'static str {
"abort"
}
fn on_pre_compact(&self, _ctx: &PreCompactContext) -> Option<CompactResult> {
Some(CompactResult::abort("too risky"))
}
}
struct InstructionHook;
impl Hook for InstructionHook {
fn name(&self) -> &'static str {
"instruction"
}
fn on_pre_compact(&self, _ctx: &PreCompactContext) -> Option<CompactResult> {
Some(CompactResult::allow().with_instructions("override"))
}
}
let executor = HookExecutor::new()
.with_hook(Arc::new(AbortHook))
.with_hook(Arc::new(InstructionHook));
let ctx = PreCompactContext {
trigger: CompactTrigger::Auto,
custom_instructions: None,
message_count: 10,
tokens_before: 5000,
context_window: 200_000,
session_id: uuid::Uuid::nil(),
};
let result = executor.check_pre_compact(&ctx);
assert!(result.abort);
assert_eq!(result.abort_reason.as_deref(), Some("too risky"));
assert!(result.new_instructions.is_none());
}
#[test]
fn session_start_end_notify_all() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct CounterHook {
starts: AtomicUsize,
ends: AtomicUsize,
}
impl Hook for CounterHook {
fn name(&self) -> &'static str {
"counter"
}
fn on_session_start(&self, _ctx: &SessionStartContext) {
self.starts.fetch_add(1, Ordering::Relaxed);
}
fn on_session_end(&self, _ctx: &SessionEndContext) {
self.ends.fetch_add(1, Ordering::Relaxed);
}
}
let counter = Arc::new(CounterHook {
starts: AtomicUsize::new(0),
ends: AtomicUsize::new(0),
});
let executor = HookExecutor::new()
.with_hook(counter.clone())
.with_hook(counter.clone());
executor.notify_session_start(&SessionStartContext {
session_id: uuid::Uuid::nil(),
model: "test".to_string(),
working_directory: "/tmp".to_string(),
});
assert_eq!(counter.starts.load(Ordering::Relaxed), 2);
executor.notify_session_end(&SessionEndContext {
session_id: uuid::Uuid::nil(),
reason: SessionEndReason::Complete,
total_turns: 5,
total_tokens: 1000,
duration_secs: 30,
});
assert_eq!(counter.ends.load(Ordering::Relaxed), 2);
}
#[test]
fn register_adds_hook() {
let mut executor = HookExecutor::new();
assert_eq!(executor.hook_count(), 0);
executor.register(Arc::new(AllowHook));
assert_eq!(executor.hook_count(), 1);
executor.register(Arc::new(BlockHook {
reason: "x".to_string(),
}));
assert_eq!(executor.hook_count(), 2);
}
#[test]
fn notify_post_tool_use_empty_executor() {
let executor = HookExecutor::new();
let ctx = PostToolUseContext {
tool_name: "test".to_string(),
input: json!({}),
output: "ok".to_string(),
is_error: false,
duration_ms: 10,
session_id: uuid::Uuid::nil(),
turn_number: 0,
};
executor.notify_post_tool_use(&ctx);
}
#[test]
fn notify_post_compact_runs_all_hooks() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct CompactRecorder {
count: AtomicUsize,
}
impl Hook for CompactRecorder {
fn name(&self) -> &'static str {
"compact_recorder"
}
fn on_post_compact(&self, _ctx: &PostCompactContext) {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
let recorder = Arc::new(CompactRecorder {
count: AtomicUsize::new(0),
});
let executor = HookExecutor::new()
.with_hook(recorder.clone())
.with_hook(recorder.clone());
let ctx = PostCompactContext {
trigger: CompactTrigger::Auto,
messages_compacted: 5,
tokens_saved: 3000,
tokens_after: 2000,
duration_ms: 100,
session_id: uuid::Uuid::nil(),
};
executor.notify_post_compact(&ctx);
assert_eq!(recorder.count.load(Ordering::Relaxed), 2);
}
#[test]
fn notify_session_start_empty_executor() {
let executor = HookExecutor::new();
executor.notify_session_start(&SessionStartContext {
session_id: uuid::Uuid::nil(),
model: "test".to_string(),
working_directory: "/tmp".to_string(),
});
}
#[test]
fn notify_session_end_empty_executor() {
let executor = HookExecutor::new();
executor.notify_session_end(&SessionEndContext {
session_id: uuid::Uuid::nil(),
reason: SessionEndReason::Complete,
total_turns: 0,
total_tokens: 0,
duration_secs: 0,
});
}
#[test]
fn check_pre_tool_use_headless_downgrades_ask_to_block() {
struct AskHook;
impl Hook for AskHook {
fn name(&self) -> &'static str {
"ask"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::ask("confirm?"))
}
}
let executor = HookExecutor::new().with_hook(Arc::new(AskHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
assert!(
action.is_block(),
"expected Block in Headless mode, got {action:?}"
);
}
#[test]
fn check_pre_tool_use_interactive_passes_ask_through() {
struct AskHook;
impl Hook for AskHook {
fn name(&self) -> &'static str {
"ask"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::ask("confirm?"))
}
}
let executor = HookExecutor::new()
.with_interactivity(Interactivity::Interactive)
.with_hook(Arc::new(AskHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
match action {
HookAction::Ask { message } => assert_eq!(message, "confirm?"),
other => panic!("expected Ask, got {other:?}"),
}
}
#[test]
fn check_pre_tool_use_interactivity_builder() {
struct AskHook;
impl Hook for AskHook {
fn name(&self) -> &'static str {
"ask"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::ask("ok?"))
}
}
let executor = HookExecutor::new()
.with_interactivity(Interactivity::Interactive)
.with_hook(Arc::new(AskHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
assert!(
action.is_ask(),
"expected Ask in Interactive mode, got {action:?}"
);
}
#[test]
fn default_is_same_as_new() {
let default_exec = HookExecutor::default();
let new_exec = HookExecutor::new();
assert_eq!(default_exec.hook_count(), 0);
assert_eq!(new_exec.hook_count(), 0);
assert_eq!(default_exec.hook_count(), new_exec.hook_count());
}
#[test]
fn headless_block_passes_through_unchanged() {
struct BlockOnlyHook;
impl Hook for BlockOnlyHook {
fn name(&self) -> &'static str {
"block_only"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::block("forbidden"))
}
}
let executor = HookExecutor::new().with_hook(Arc::new(BlockOnlyHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
assert!(action.is_block());
assert_eq!(action.block_reason(), Some("forbidden"));
}
#[test]
fn headless_downgrade_preserves_original_message() {
struct AskHook;
impl Hook for AskHook {
fn name(&self) -> &'static str {
"ask"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::ask("please confirm deployment"))
}
}
let executor = HookExecutor::new().with_hook(Arc::new(AskHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
let reason = action
.block_reason()
.expect("downgraded action should be a Block with a reason");
assert!(
reason.contains("please confirm deployment"),
"Block reason should contain the original Ask message, got: {reason}"
);
assert!(
reason.contains("not interactive"),
"Block reason should explain the downgrade, got: {reason}"
);
}
#[test]
fn with_interactivity_constructor_sets_mode() {
struct AskHook;
impl Hook for AskHook {
fn name(&self) -> &'static str {
"ask"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::ask("ok?"))
}
}
let headless = HookExecutor::new()
.with_interactivity(Interactivity::Headless)
.with_hook(Arc::new(AskHook));
assert!(
headless.check_pre_tool_use(&dummy_pre_ctx()).is_block(),
"Headless via with_interactivity should downgrade Ask"
);
}
#[test]
fn interactive_block_passes_through_unchanged() {
struct BlockOnlyHook;
impl Hook for BlockOnlyHook {
fn name(&self) -> &'static str {
"block_only"
}
fn on_pre_tool_use(&self, _ctx: &PreToolUseContext) -> Option<HookAction> {
Some(HookAction::block("forbidden"))
}
}
let executor = HookExecutor::new()
.with_interactivity(Interactivity::Interactive)
.with_hook(Arc::new(BlockOnlyHook));
let ctx = dummy_pre_ctx();
let action = executor.check_pre_tool_use(&ctx);
assert!(action.is_block());
assert_eq!(action.block_reason(), Some("forbidden"));
}
#[test]
fn no_hooks_returns_allow_in_headless() {
let executor = HookExecutor::new();
let ctx = dummy_pre_ctx();
assert!(executor.check_pre_tool_use(&ctx).is_allow());
}
#[test]
fn no_hooks_returns_allow_in_interactive() {
let executor = HookExecutor::new().with_interactivity(Interactivity::Interactive);
let ctx = dummy_pre_ctx();
assert!(executor.check_pre_tool_use(&ctx).is_allow());
}
}