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")]
49#[derive(Default)]
50pub enum HookCommandKind {
51    #[default]
52    Command,
53}
54
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[derive(Debug, Clone, Deserialize, Serialize, Default)]
57pub struct HookCommandConfig {
58    #[serde(default)]
59    #[serde(rename = "type")]
60    pub kind: HookCommandKind,
61    #[serde(default)]
62    pub command: String,
63    #[serde(default)]
64    pub timeout_seconds: Option<u64>,
65}
66
67impl HooksConfig {
68    pub fn validate(&self) -> Result<()> {
69        self.lifecycle
70            .validate()
71            .context("Invalid lifecycle hooks configuration")
72    }
73}
74
75impl LifecycleHooksConfig {
76    pub fn validate(&self) -> Result<()> {
77        validate_groups(&self.session_start, "session_start")?;
78        validate_groups(&self.session_end, "session_end")?;
79        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
80        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
81        validate_groups(&self.post_tool_use, "post_tool_use")?;
82        Ok(())
83    }
84}
85
86fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
87    for (index, group) in groups.iter().enumerate() {
88        if let Some(pattern) = group.matcher.as_ref() {
89            validate_matcher(pattern).with_context(|| {
90                format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
91            })?;
92        }
93
94        ensure!(
95            !group.hooks.is_empty(),
96            "hooks.{context_name}[{index}] must define at least one hook command"
97        );
98
99        for (hook_index, hook) in group.hooks.iter().enumerate() {
100            ensure!(
101                matches!(hook.kind, HookCommandKind::Command),
102                "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
103            );
104
105            ensure!(
106                !hook.command.trim().is_empty(),
107                "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
108            );
109
110            if let Some(timeout) = hook.timeout_seconds {
111                ensure!(
112                    timeout > 0,
113                    "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
114                );
115            }
116        }
117    }
118
119    Ok(())
120}
121
122fn validate_matcher(pattern: &str) -> Result<()> {
123    let trimmed = pattern.trim();
124    if trimmed.is_empty() || trimmed == "*" {
125        return Ok(());
126    }
127
128    let regex_pattern = format!("^(?:{})$", trimmed);
129    Regex::new(&regex_pattern)
130        .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
131    Ok(())
132}