Skip to main content

halter_hooks/
config.rs

1// pattern: Functional Core
2
3use 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        // `strum::IntoStaticStr` provides a `From<Self> for &'static str`
325        // impl that returns the variant's PascalCase identifier.
326        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    /// Resolve an alias (PascalCase, snake_case, or camelCase) to its canonical
360    /// variant. `strum::EnumString` with `ascii_case_insensitive` handles case
361    /// variants; we strip underscores so `pre_tool_use` normalizes to
362    /// `PreToolUse` without per-variant serde aliases.
363    #[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}