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