vtcode_config/
hooks.rs

1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
6#[derive(Debug, Clone, Deserialize, Serialize, Default)]
7pub struct HooksConfig {
8    #[serde(default)]
9    pub lifecycle: LifecycleHooksConfig,
10}
11
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Deserialize, Serialize, Default)]
14pub struct LifecycleHooksConfig {
15    #[serde(default)]
16    pub session_start: Vec<HookGroupConfig>,
17    #[serde(default)]
18    pub session_end: Vec<HookGroupConfig>,
19    #[serde(default)]
20    pub user_prompt_submit: Vec<HookGroupConfig>,
21    #[serde(default)]
22    pub pre_tool_use: Vec<HookGroupConfig>,
23    #[serde(default)]
24    pub post_tool_use: Vec<HookGroupConfig>,
25}
26
27impl LifecycleHooksConfig {
28    pub fn is_empty(&self) -> bool {
29        self.session_start.is_empty()
30            && self.session_end.is_empty()
31            && self.user_prompt_submit.is_empty()
32            && self.pre_tool_use.is_empty()
33            && self.post_tool_use.is_empty()
34    }
35}
36
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[derive(Debug, Clone, Deserialize, Serialize, Default)]
39pub struct HookGroupConfig {
40    #[serde(default)]
41    pub matcher: Option<String>,
42    #[serde(default)]
43    pub hooks: Vec<HookCommandConfig>,
44}
45
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case")]
49pub enum HookCommandKind {
50    Command,
51}
52
53impl Default for HookCommandKind {
54    fn default() -> Self {
55        Self::Command
56    }
57}
58
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, Deserialize, Serialize, Default)]
61pub struct HookCommandConfig {
62    #[serde(default)]
63    #[serde(rename = "type")]
64    pub kind: HookCommandKind,
65    #[serde(default)]
66    pub command: String,
67    #[serde(default)]
68    pub timeout_seconds: Option<u64>,
69}
70
71impl HooksConfig {
72    pub fn validate(&self) -> Result<()> {
73        self.lifecycle
74            .validate()
75            .context("Invalid lifecycle hooks configuration")
76    }
77}
78
79impl LifecycleHooksConfig {
80    pub fn validate(&self) -> Result<()> {
81        validate_groups(&self.session_start, "session_start")?;
82        validate_groups(&self.session_end, "session_end")?;
83        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
84        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
85        validate_groups(&self.post_tool_use, "post_tool_use")?;
86        Ok(())
87    }
88}
89
90fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
91    for (index, group) in groups.iter().enumerate() {
92        if let Some(pattern) = group.matcher.as_ref() {
93            validate_matcher(pattern).with_context(|| {
94                format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
95            })?;
96        }
97
98        ensure!(
99            !group.hooks.is_empty(),
100            "hooks.{context_name}[{index}] must define at least one hook command"
101        );
102
103        for (hook_index, hook) in group.hooks.iter().enumerate() {
104            ensure!(
105                matches!(hook.kind, HookCommandKind::Command),
106                "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
107            );
108
109            ensure!(
110                !hook.command.trim().is_empty(),
111                "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
112            );
113
114            if let Some(timeout) = hook.timeout_seconds {
115                ensure!(
116                    timeout > 0,
117                    "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
118                );
119            }
120        }
121    }
122
123    Ok(())
124}
125
126fn validate_matcher(pattern: &str) -> Result<()> {
127    let trimmed = pattern.trim();
128    if trimmed.is_empty() || trimmed == "*" {
129        return Ok(());
130    }
131
132    let regex_pattern = format!("^(?:{})$", trimmed);
133    Regex::new(&regex_pattern)
134        .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
135    Ok(())
136}