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)]
14/// Parsed `hooks.json` file.
15pub struct HooksFile {
16    pub hooks: IndexMap<HookEventName, Vec<HookMatcherGroup>>,
17}
18
19impl HooksFile {
20    /// Parse and validate a hook file from JSON bytes.
21    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)]
77/// Hooks that share one optional matcher for an event.
78pub 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)]
124/// Parsed hook handler with common metadata and backend config.
125pub 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)]
249/// Backend-specific hook handler configuration.
250pub enum HookHandlerConfig {
251    /// Shell command handler.
252    Command(CommandHookConfig),
253    /// HTTP request handler.
254    Http(HttpHookConfig),
255    /// Prompt handler.
256    Prompt(PromptHookConfig),
257    /// Agent handler.
258    Agent(AgentHookConfig),
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262/// Command hook configuration.
263pub struct CommandHookConfig {
264    pub command: String,
265    pub shell: HookShell,
266    pub env: IndexMap<String, String>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270/// HTTP hook configuration.
271pub 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)]
278/// Prompt hook configuration.
279pub struct PromptHookConfig {
280    pub prompt: String,
281    pub model: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
285/// Agent hook configuration.
286pub 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")]
295/// Shell used by command hooks.
296pub enum HookShell {
297    /// Bash.
298    #[default]
299    Bash,
300    /// PowerShell.
301    Pwsh,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, EnumString, IntoStaticStr)]
305#[strum(ascii_case_insensitive)]
306/// Canonical hook event name.
307pub 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    /// Canonical PascalCase event spelling.
340    #[must_use]
341    pub fn canonical_name(self) -> &'static str {
342        // `strum::IntoStaticStr` provides a `From<Self> for &'static str`
343        // impl that returns the variant's PascalCase identifier.
344        self.into()
345    }
346
347    /// Payload field used for event matcher evaluation.
348    #[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    /// Resolve an alias (PascalCase, snake_case, or camelCase) to its canonical
379    /// variant. `strum::EnumString` with `ascii_case_insensitive` handles case
380    /// variants; we strip underscores so `pre_tool_use` normalizes to
381    /// `PreToolUse` without per-variant serde aliases.
382    #[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)]
390/// Non-fatal issue found while loading a hook file.
391pub struct HooksLoadWarning {
392    pub category: String,
393    pub message: String,
394}
395
396impl HooksLoadWarning {
397    /// Build a hook load warning.
398    #[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}