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    #[serde(default)]
26    pub task_completion: Vec<HookGroupConfig>,
27}
28
29impl LifecycleHooksConfig {
30    pub fn is_empty(&self) -> bool {
31        self.session_start.is_empty()
32            && self.session_end.is_empty()
33            && self.user_prompt_submit.is_empty()
34            && self.pre_tool_use.is_empty()
35            && self.post_tool_use.is_empty()
36            && self.task_completion.is_empty()
37    }
38}
39
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[derive(Debug, Clone, Deserialize, Serialize, Default)]
42pub struct HookGroupConfig {
43    #[serde(default)]
44    pub matcher: Option<String>,
45    #[serde(default)]
46    pub hooks: Vec<HookCommandConfig>,
47}
48
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52#[derive(Default)]
53pub enum HookCommandKind {
54    #[default]
55    Command,
56}
57
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Deserialize, Serialize, Default)]
60pub struct HookCommandConfig {
61    #[serde(default)]
62    #[serde(rename = "type")]
63    pub kind: HookCommandKind,
64    #[serde(default)]
65    pub command: String,
66    #[serde(default)]
67    pub timeout_seconds: Option<u64>,
68}
69
70impl HooksConfig {
71    pub fn validate(&self) -> Result<()> {
72        self.lifecycle
73            .validate()
74            .context("Invalid lifecycle hooks configuration")
75    }
76}
77
78impl LifecycleHooksConfig {
79    pub fn validate(&self) -> Result<()> {
80        validate_groups(&self.session_start, "session_start")?;
81        validate_groups(&self.session_end, "session_end")?;
82        validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
83        validate_groups(&self.pre_tool_use, "pre_tool_use")?;
84        validate_groups(&self.post_tool_use, "post_tool_use")?;
85        validate_groups(&self.task_completion, "task_completion")?;
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}