1use std::time::Duration;
4
5use anyhow::Context;
6use halter_protocol::HookHandlerType;
7use indexmap::{IndexMap, IndexSet};
8use serde::Deserialize;
9use strum_macros::{EnumString, IntoStaticStr};
10
11use crate::matcher::CompiledMatcher;
12
13#[derive(Debug, Clone, Default)]
14pub struct HooksFile {
15 pub hooks: IndexMap<HookEventName, Vec<HookMatcherGroup>>,
16}
17
18impl HooksFile {
19 pub fn from_json_bytes(bytes: &[u8]) -> anyhow::Result<(Self, Vec<HooksLoadWarning>)> {
20 let raw: HooksFileRaw =
21 serde_json::from_slice(bytes).context("failed to parse hooks.json")?;
22 Self::from_raw(raw)
23 }
24
25 fn from_raw(raw: HooksFileRaw) -> anyhow::Result<(Self, Vec<HooksLoadWarning>)> {
26 let mut hooks = IndexMap::new();
27 let mut warnings = Vec::new();
28 let mut seen = IndexSet::new();
29
30 for (event_alias, matcher_groups) in raw.hooks {
31 let Some(event) = HookEventName::from_alias(&event_alias) else {
32 warnings.push(HooksLoadWarning::new(
33 "unknown_event",
34 format!("unknown hook event '{event_alias}'"),
35 ));
36 continue;
37 };
38 if !seen.insert(event) {
39 warnings.push(HooksLoadWarning::new(
40 "duplicate_alias",
41 format!(
42 "duplicate hook alias '{event_alias}' resolved to '{}'",
43 event.canonical_name()
44 ),
45 ));
46 continue;
47 }
48
49 let mut parsed_groups = Vec::new();
50 for matcher_group in matcher_groups {
51 let group = HookMatcherGroup::from_raw(event, matcher_group, &mut warnings)
52 .with_context(|| {
53 format!(
54 "failed to compile matcher for hook event '{}'",
55 event.canonical_name()
56 )
57 })?;
58 if let Some(group) = group
59 && !group.hooks.is_empty()
60 {
61 parsed_groups.push(group);
62 }
63 }
64
65 if !parsed_groups.is_empty() {
66 hooks.insert(event, parsed_groups);
67 }
68 }
69
70 Ok((Self { hooks }, warnings))
71 }
72}
73
74#[derive(Debug, Clone)]
75pub struct HookMatcherGroup {
76 pub matcher: Option<CompiledMatcher>,
77 pub hooks: Vec<HookHandler>,
78}
79
80impl HookMatcherGroup {
81 fn from_raw(
82 event: HookEventName,
83 raw: HookMatcherGroupRaw,
84 warnings: &mut Vec<HooksLoadWarning>,
85 ) -> anyhow::Result<Option<Self>> {
86 let raw_matcher = raw
87 .matcher
88 .map(|value| value.trim().to_owned())
89 .filter(|value| !value.is_empty());
90
91 let matcher = match raw_matcher {
92 Some(pattern) => {
93 if event.matcher_field().is_none() {
94 anyhow::bail!(
95 "hook event '{}' does not support matcher",
96 event.canonical_name()
97 );
98 }
99 Some(CompiledMatcher::compile_regex(&pattern).with_context(|| {
100 format!(
101 "invalid matcher regex for '{}': {pattern}",
102 event.canonical_name()
103 )
104 })?)
105 }
106 None => None,
107 };
108
109 let mut hooks = Vec::new();
110 for handler in raw.hooks {
111 if let Some(parsed) = HookHandler::from_raw(handler, warnings) {
112 hooks.push(parsed);
113 }
114 }
115
116 Ok(Some(Self { matcher, hooks }))
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct HookHandler {
122 pub handler_type: HookHandlerType,
123 pub timeout: Duration,
124 pub status_message: Option<String>,
125 pub if_condition: Option<String>,
126 pub once: bool,
127 pub config: HookHandlerConfig,
128}
129
130impl HookHandler {
131 fn from_raw(raw: HookHandlerRaw, warnings: &mut Vec<HooksLoadWarning>) -> Option<Self> {
132 if raw.r#async {
133 warnings.push(HooksLoadWarning::new(
134 "reserved_async_flag",
135 "ignoring reserved async=true hook flag in v1".to_owned(),
136 ));
137 }
138
139 let timeout_secs = raw
140 .timeout
141 .or(raw.timeout_sec)
142 .unwrap_or_else(|| default_timeout_secs(raw.handler_type));
143
144 match raw.handler_type {
145 RawHookHandlerType::Command => {
146 let command = raw.command.and_then(trimmed_non_empty).or_else(|| {
147 warnings.push(HooksLoadWarning::new(
148 "missing_field",
149 "command hook is missing the 'command' field".to_owned(),
150 ));
151 None
152 })?;
153 Some(Self {
154 handler_type: HookHandlerType::Command,
155 timeout: Duration::from_secs(timeout_secs),
156 status_message: raw.status_message.and_then(trimmed_non_empty),
157 if_condition: raw.if_condition.and_then(trimmed_non_empty),
158 once: raw.once,
159 config: HookHandlerConfig::Command(CommandHookConfig {
160 command,
161 shell: raw.shell.unwrap_or_default(),
162 env: raw.env,
163 }),
164 })
165 }
166 RawHookHandlerType::Http => {
167 let url = raw.url.and_then(trimmed_non_empty).or_else(|| {
168 warnings.push(HooksLoadWarning::new(
169 "missing_field",
170 "http hook is missing the 'url' field".to_owned(),
171 ));
172 None
173 })?;
174 Some(Self {
175 handler_type: HookHandlerType::Http,
176 timeout: Duration::from_secs(timeout_secs),
177 status_message: raw.status_message.and_then(trimmed_non_empty),
178 if_condition: raw.if_condition.and_then(trimmed_non_empty),
179 once: raw.once,
180 config: HookHandlerConfig::Http(HttpHookConfig {
181 url,
182 headers: raw.headers,
183 allowed_env_vars: raw.allowed_env_vars,
184 }),
185 })
186 }
187 RawHookHandlerType::Prompt => {
188 let prompt = raw.prompt.and_then(trimmed_non_empty).or_else(|| {
189 warnings.push(HooksLoadWarning::new(
190 "missing_field",
191 "prompt hook is missing the 'prompt' field".to_owned(),
192 ));
193 None
194 })?;
195 Some(Self {
196 handler_type: HookHandlerType::Prompt,
197 timeout: Duration::from_secs(timeout_secs),
198 status_message: raw.status_message.and_then(trimmed_non_empty),
199 if_condition: raw.if_condition.and_then(trimmed_non_empty),
200 once: raw.once,
201 config: HookHandlerConfig::Prompt(PromptHookConfig {
202 prompt,
203 model: raw.model.and_then(trimmed_non_empty),
204 }),
205 })
206 }
207 RawHookHandlerType::Agent => {
208 let prompt = raw.prompt.and_then(trimmed_non_empty).or_else(|| {
209 warnings.push(HooksLoadWarning::new(
210 "missing_field",
211 "agent hook is missing the 'prompt' field".to_owned(),
212 ));
213 None
214 })?;
215 Some(Self {
216 handler_type: HookHandlerType::Agent,
217 timeout: Duration::from_secs(timeout_secs),
218 status_message: raw.status_message.and_then(trimmed_non_empty),
219 if_condition: raw.if_condition.and_then(trimmed_non_empty),
220 once: raw.once,
221 config: HookHandlerConfig::Agent(AgentHookConfig {
222 prompt,
223 model: raw.model.and_then(trimmed_non_empty),
224 allowed_tools: raw
225 .allowed_tools
226 .into_iter()
227 .filter_map(trimmed_non_empty)
228 .collect(),
229 max_turns: raw.max_turns,
230 }),
231 })
232 }
233 RawHookHandlerType::Callback | RawHookHandlerType::Function => {
234 warnings.push(HooksLoadWarning::new(
235 "sdk_only_backend",
236 "ignoring sdk-only hook backend in hooks.json".to_owned(),
237 ));
238 None
239 }
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum HookHandlerConfig {
246 Command(CommandHookConfig),
247 Http(HttpHookConfig),
248 Prompt(PromptHookConfig),
249 Agent(AgentHookConfig),
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct CommandHookConfig {
254 pub command: String,
255 pub shell: HookShell,
256 pub env: IndexMap<String, String>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct HttpHookConfig {
261 pub url: String,
262 pub headers: IndexMap<String, String>,
263 pub allowed_env_vars: Vec<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct PromptHookConfig {
268 pub prompt: String,
269 pub model: Option<String>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct AgentHookConfig {
274 pub prompt: String,
275 pub model: Option<String>,
276 pub allowed_tools: Vec<String>,
277 pub max_turns: Option<u32>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
281#[serde(rename_all = "snake_case")]
282pub enum HookShell {
283 #[default]
284 Bash,
285 Pwsh,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, EnumString, IntoStaticStr)]
289#[strum(ascii_case_insensitive)]
290pub enum HookEventName {
291 SessionStart,
292 SessionEnd,
293 UserPromptSubmit,
294 PreToolUse,
295 PostToolUse,
296 PostToolUseFailure,
297 Notification,
298 Stop,
299 SubagentStart,
300 SubagentStop,
301 PreCompact,
302 PostCompact,
303 PermissionRequest,
304 PermissionDenied,
305 Elicitation,
306 ElicitationResult,
307 WorktreeCreate,
308 WorktreeRemove,
309 FileChanged,
310 CwdChanged,
311 InstructionsLoaded,
312 ConfigChange,
313 Setup,
314 TeammateIdle,
315 TaskCreated,
316 TaskCompleted,
317 StopFailure,
318 PostSampling,
319}
320
321impl HookEventName {
322 #[must_use]
323 pub fn canonical_name(self) -> &'static str {
324 self.into()
327 }
328
329 #[must_use]
330 pub fn matcher_field(self) -> Option<&'static str> {
331 match self {
332 Self::PreToolUse | Self::PostToolUse | Self::PostToolUseFailure => Some("tool_name"),
333 Self::SessionStart => Some("source"),
334 Self::SessionEnd => Some("reason"),
335 Self::Notification => Some("notification_type"),
336 Self::SubagentStart | Self::SubagentStop => Some("agent_type"),
337 Self::PreCompact | Self::PostCompact => Some("trigger"),
338 Self::UserPromptSubmit
339 | Self::Stop
340 | Self::PermissionRequest
341 | Self::PermissionDenied
342 | Self::Elicitation
343 | Self::ElicitationResult
344 | Self::WorktreeCreate
345 | Self::WorktreeRemove
346 | Self::FileChanged
347 | Self::CwdChanged
348 | Self::InstructionsLoaded
349 | Self::ConfigChange
350 | Self::Setup
351 | Self::TeammateIdle
352 | Self::TaskCreated
353 | Self::TaskCompleted
354 | Self::StopFailure
355 | Self::PostSampling => None,
356 }
357 }
358
359 #[must_use]
364 pub fn from_alias(alias: &str) -> Option<Self> {
365 let normalized: String = alias.chars().filter(|ch| *ch != '_').collect();
366 normalized.parse().ok()
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct HooksLoadWarning {
372 pub category: String,
373 pub message: String,
374}
375
376impl HooksLoadWarning {
377 #[must_use]
378 pub fn new(category: impl Into<String>, message: String) -> Self {
379 Self {
380 category: category.into(),
381 message,
382 }
383 }
384}
385
386#[derive(Debug, Deserialize)]
387struct HooksFileRaw {
388 #[serde(default)]
389 hooks: IndexMap<String, Vec<HookMatcherGroupRaw>>,
390}
391
392#[derive(Debug, Deserialize)]
393struct HookMatcherGroupRaw {
394 #[serde(default)]
395 matcher: Option<String>,
396 #[serde(default)]
397 hooks: Vec<HookHandlerRaw>,
398}
399
400#[derive(Debug, Clone, Copy, Deserialize)]
401#[serde(rename_all = "snake_case")]
402enum RawHookHandlerType {
403 Command,
404 Http,
405 Prompt,
406 Agent,
407 Callback,
408 Function,
409}
410
411#[derive(Debug, Deserialize)]
412#[serde(rename_all = "snake_case")]
413struct HookHandlerRaw {
414 #[serde(rename = "type")]
415 handler_type: RawHookHandlerType,
416 #[serde(default)]
417 timeout: Option<u64>,
418 #[serde(default, alias = "timeoutSec")]
419 timeout_sec: Option<u64>,
420 #[serde(default, alias = "statusMessage")]
421 status_message: Option<String>,
422 #[serde(default, rename = "if")]
423 if_condition: Option<String>,
424 #[serde(default)]
425 r#async: bool,
426 #[serde(default)]
427 once: bool,
428 #[serde(default)]
429 command: Option<String>,
430 #[serde(default)]
431 url: Option<String>,
432 #[serde(default)]
433 headers: IndexMap<String, String>,
434 #[serde(default, alias = "allowedEnvVars")]
435 allowed_env_vars: Vec<String>,
436 #[serde(default)]
437 prompt: Option<String>,
438 #[serde(default)]
439 model: Option<String>,
440 #[serde(default, alias = "allowedTools")]
441 allowed_tools: Vec<String>,
442 #[serde(default, alias = "maxTurns")]
443 max_turns: Option<u32>,
444 #[serde(default)]
445 shell: Option<HookShell>,
446 #[serde(default)]
447 env: IndexMap<String, String>,
448}
449
450fn default_timeout_secs(handler_type: RawHookHandlerType) -> u64 {
451 match handler_type {
452 RawHookHandlerType::Command | RawHookHandlerType::Http => 600,
453 RawHookHandlerType::Agent => 60,
454 RawHookHandlerType::Prompt => 30,
455 RawHookHandlerType::Callback | RawHookHandlerType::Function => 30,
456 }
457}
458
459fn trimmed_non_empty(value: String) -> Option<String> {
460 let trimmed = value.trim();
461 (!trimmed.is_empty()).then(|| trimmed.to_owned())
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn hooks_file_uses_first_alias_for_canonical_event() {
470 let (parsed, warnings) = HooksFile::from_json_bytes(
471 br#"{
472 "hooks": {
473 "PreToolUse": [
474 {
475 "hooks": [
476 {
477 "type": "command",
478 "command": "echo first"
479 }
480 ]
481 }
482 ],
483 "pre_tool_use": [
484 {
485 "hooks": [
486 {
487 "type": "command",
488 "command": "echo second"
489 }
490 ]
491 }
492 ]
493 }
494 }"#,
495 )
496 .expect("parse hooks");
497
498 let groups = parsed
499 .hooks
500 .get(&HookEventName::PreToolUse)
501 .expect("pre tool use hooks");
502 assert_eq!(groups.len(), 1);
503 assert_eq!(groups[0].hooks.len(), 1);
504 assert_eq!(
505 warnings
506 .iter()
507 .filter(|warning| warning.message.contains("duplicate hook alias"))
508 .count(),
509 1
510 );
511 }
512
513 #[test]
514 fn hooks_file_warns_on_unknown_events() {
515 let (parsed, warnings) = HooksFile::from_json_bytes(
516 br#"{
517 "hooks": {
518 "UnknownEvent": [
519 {
520 "hooks": [
521 {
522 "type": "command",
523 "command": "echo ignored"
524 }
525 ]
526 }
527 ],
528 "Stop": [
529 {
530 "hooks": [
531 {
532 "type": "command",
533 "command": "echo kept"
534 }
535 ]
536 }
537 ]
538 }
539 }"#,
540 )
541 .expect("parse hooks");
542
543 assert!(parsed.hooks.contains_key(&HookEventName::Stop));
544 assert_eq!(parsed.hooks.len(), 1);
545 assert_eq!(warnings.len(), 1);
546 assert!(warnings[0].message.contains("unknown hook event"));
547 }
548
549 #[test]
550 fn hooks_file_rejects_malformed_json() {
551 let error = HooksFile::from_json_bytes(br#"{ "hooks": { "Stop": [ }"#)
552 .expect_err("malformed hooks should fail");
553
554 assert!(error.to_string().contains("failed to parse hooks.json"));
555 }
556
557 #[test]
558 fn hooks_file_warns_on_reserved_async_flag() {
559 let (parsed, warnings) = HooksFile::from_json_bytes(
560 br#"{
561 "hooks": {
562 "Stop": [
563 {
564 "hooks": [
565 {
566 "type": "command",
567 "command": "echo keep",
568 "async": true
569 }
570 ]
571 }
572 ]
573 }
574 }"#,
575 )
576 .expect("parse hooks");
577
578 let groups = parsed.hooks.get(&HookEventName::Stop).expect("stop hooks");
579 assert_eq!(groups.len(), 1);
580 assert_eq!(groups[0].hooks.len(), 1);
581 assert_eq!(warnings.len(), 1);
582 assert!(warnings[0].message.contains("async=true"));
583 }
584
585 #[test]
586 fn hooks_file_ignores_sdk_only_backends() {
587 let (parsed, warnings) = HooksFile::from_json_bytes(
588 br#"{
589 "hooks": {
590 "Stop": [
591 {
592 "hooks": [
593 {
594 "type": "callback"
595 },
596 {
597 "type": "function"
598 }
599 ]
600 }
601 ]
602 }
603 }"#,
604 )
605 .expect("parse hooks");
606
607 assert!(parsed.hooks.is_empty());
608 assert_eq!(warnings.len(), 2);
609 assert!(
610 warnings
611 .iter()
612 .all(|warning| warning.message.contains("sdk-only hook backend"))
613 );
614 }
615
616 #[test]
617 fn hooks_file_accepts_snake_case_and_camel_case_handler_fields() {
618 let (parsed, warnings) = HooksFile::from_json_bytes(
619 br#"{
620 "hooks": {
621 "Stop": [
622 {
623 "hooks": [
624 {
625 "type": "agent",
626 "prompt": "first",
627 "status_message": "snake case",
628 "allowed_tools": ["read"],
629 "max_turns": 2,
630 "timeout_sec": 7
631 },
632 {
633 "type": "agent",
634 "prompt": "second",
635 "statusMessage": "camel case",
636 "allowedTools": ["write"],
637 "maxTurns": 3,
638 "timeoutSec": 9
639 }
640 ]
641 }
642 ]
643 }
644 }"#,
645 )
646 .expect("parse hooks");
647
648 assert!(warnings.is_empty());
649 let groups = parsed.hooks.get(&HookEventName::Stop).expect("stop hooks");
650 assert_eq!(groups.len(), 1);
651 assert_eq!(groups[0].hooks.len(), 2);
652
653 let HookHandlerConfig::Agent(first) = &groups[0].hooks[0].config else {
654 panic!("expected first hook to be an agent");
655 };
656 assert_eq!(
657 groups[0].hooks[0].status_message.as_deref(),
658 Some("snake case")
659 );
660 assert_eq!(groups[0].hooks[0].timeout, Duration::from_secs(7));
661 assert_eq!(first.allowed_tools, vec!["read".to_owned()]);
662 assert_eq!(first.max_turns, Some(2));
663
664 let HookHandlerConfig::Agent(second) = &groups[0].hooks[1].config else {
665 panic!("expected second hook to be an agent");
666 };
667 assert_eq!(
668 groups[0].hooks[1].status_message.as_deref(),
669 Some("camel case")
670 );
671 assert_eq!(groups[0].hooks[1].timeout, Duration::from_secs(9));
672 assert_eq!(second.allowed_tools, vec!["write".to_owned()]);
673 assert_eq!(second.max_turns, Some(3));
674 }
675
676 #[test]
677 fn matcher_on_event_without_matcher_field_is_rejected() {
678 let error = HooksFile::from_json_bytes(
679 br#"{
680 "hooks": {
681 "Stop": [
682 {
683 "matcher": "never",
684 "hooks": [
685 {
686 "type": "prompt",
687 "prompt": "noop"
688 }
689 ]
690 }
691 ]
692 }
693 }"#,
694 )
695 .expect_err("Stop does not support matcher");
696
697 let rendered = format!("{error:#}");
698 assert!(rendered.contains("hook event 'Stop' does not support matcher"));
699 }
700}