Skip to main content

opendev_hooks/
models.rs

1//! Hook data models: event types, matchers, commands, and configuration.
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fmt;
7
8/// Lifecycle events that can trigger hooks.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub enum HookEvent {
11    /// Fired when a new session starts.
12    SessionStart,
13    /// Fired when the user submits a prompt.
14    UserPromptSubmit,
15    /// Fired before a tool is executed.
16    PreToolUse,
17    /// Fired after a tool executes successfully.
18    PostToolUse,
19    /// Fired after a tool execution fails.
20    PostToolUseFailure,
21    /// Fired when a subagent is spawned.
22    SubagentStart,
23    /// Fired when a subagent finishes.
24    SubagentStop,
25    /// Fired when the agent decides to stop.
26    Stop,
27    /// Fired before context compaction.
28    PreCompact,
29    /// Fired when the session ends.
30    SessionEnd,
31}
32
33impl HookEvent {
34    /// The string name used in config files (e.g., "PreToolUse").
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Self::SessionStart => "SessionStart",
38            Self::UserPromptSubmit => "UserPromptSubmit",
39            Self::PreToolUse => "PreToolUse",
40            Self::PostToolUse => "PostToolUse",
41            Self::PostToolUseFailure => "PostToolUseFailure",
42            Self::SubagentStart => "SubagentStart",
43            Self::SubagentStop => "SubagentStop",
44            Self::Stop => "Stop",
45            Self::PreCompact => "PreCompact",
46            Self::SessionEnd => "SessionEnd",
47        }
48    }
49
50    /// Parse from the string name used in config files.
51    pub fn from_config_str(s: &str) -> Option<Self> {
52        match s {
53            "SessionStart" => Some(Self::SessionStart),
54            "UserPromptSubmit" => Some(Self::UserPromptSubmit),
55            "PreToolUse" => Some(Self::PreToolUse),
56            "PostToolUse" => Some(Self::PostToolUse),
57            "PostToolUseFailure" => Some(Self::PostToolUseFailure),
58            "SubagentStart" => Some(Self::SubagentStart),
59            "SubagentStop" => Some(Self::SubagentStop),
60            "Stop" => Some(Self::Stop),
61            "PreCompact" => Some(Self::PreCompact),
62            "SessionEnd" => Some(Self::SessionEnd),
63            _ => None,
64        }
65    }
66
67    /// Whether this is a tool-related event.
68    pub fn is_tool_event(&self) -> bool {
69        matches!(
70            self,
71            Self::PreToolUse | Self::PostToolUse | Self::PostToolUseFailure
72        )
73    }
74
75    /// Whether this is a subagent-related event.
76    pub fn is_subagent_event(&self) -> bool {
77        matches!(self, Self::SubagentStart | Self::SubagentStop)
78    }
79
80    /// All valid event variants.
81    pub const ALL: &'static [HookEvent] = &[
82        Self::SessionStart,
83        Self::UserPromptSubmit,
84        Self::PreToolUse,
85        Self::PostToolUse,
86        Self::PostToolUseFailure,
87        Self::SubagentStart,
88        Self::SubagentStop,
89        Self::Stop,
90        Self::PreCompact,
91        Self::SessionEnd,
92    ];
93}
94
95impl fmt::Display for HookEvent {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// A single hook command to execute as a subprocess.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct HookCommand {
104    /// Command type — currently always "command".
105    #[serde(default = "default_command_type")]
106    pub r#type: String,
107
108    /// The shell command to execute.
109    pub command: String,
110
111    /// Timeout in seconds (clamped to 1..=600).
112    #[serde(default = "default_timeout")]
113    pub timeout: u32,
114}
115
116fn default_command_type() -> String {
117    "command".to_string()
118}
119
120fn default_timeout() -> u32 {
121    60
122}
123
124impl HookCommand {
125    /// Create a new hook command with defaults.
126    pub fn new(command: impl Into<String>) -> Self {
127        Self {
128            r#type: "command".to_string(),
129            command: command.into(),
130            timeout: 60,
131        }
132    }
133
134    /// Create a new hook command with a custom timeout.
135    pub fn with_timeout(command: impl Into<String>, timeout: u32) -> Self {
136        Self {
137            r#type: "command".to_string(),
138            command: command.into(),
139            timeout: timeout.clamp(1, 600),
140        }
141    }
142
143    /// The effective timeout, clamped to the valid range.
144    pub fn effective_timeout(&self) -> u32 {
145        self.timeout.clamp(1, 600)
146    }
147}
148
149/// A matcher that filters when hooks fire, with associated commands.
150///
151/// If `matcher` is `None`, the hooks fire for every occurrence of the event.
152/// If `matcher` is `Some(pattern)`, it is compiled as a regex and tested
153/// against the match value (e.g., tool name).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct HookMatcher {
156    /// Optional regex pattern to filter events (e.g., tool name pattern).
157    pub matcher: Option<String>,
158
159    /// Commands to execute when the matcher matches.
160    pub hooks: Vec<HookCommand>,
161
162    /// Compiled regex (not serialized).
163    #[serde(skip)]
164    compiled_regex: Option<CompiledRegex>,
165}
166
167/// Wrapper to hold a compiled regex (Regex doesn't implement Debug well for our needs).
168#[derive(Clone)]
169struct CompiledRegex(Regex);
170
171impl fmt::Debug for CompiledRegex {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(f, "Regex({})", self.0.as_str())
174    }
175}
176
177impl HookMatcher {
178    /// Create a matcher that matches everything.
179    pub fn catch_all(hooks: Vec<HookCommand>) -> Self {
180        Self {
181            matcher: None,
182            hooks,
183            compiled_regex: None,
184        }
185    }
186
187    /// Create a matcher with a regex pattern.
188    pub fn with_pattern(pattern: impl Into<String>, hooks: Vec<HookCommand>) -> Self {
189        let pattern = pattern.into();
190        let compiled = Regex::new(&pattern).ok().map(CompiledRegex);
191        Self {
192            matcher: Some(pattern),
193            hooks,
194            compiled_regex: compiled,
195        }
196    }
197
198    /// Compile (or recompile) the regex from the matcher pattern.
199    ///
200    /// Call this after deserialization to populate the compiled regex.
201    pub fn compile(&mut self) {
202        if let Some(ref pattern) = self.matcher {
203            self.compiled_regex = Regex::new(pattern).ok().map(CompiledRegex);
204        }
205    }
206
207    /// Check if this matcher matches the given value.
208    ///
209    /// - If `matcher` is `None`, matches everything.
210    /// - If `value` is `None`, matches everything.
211    /// - Otherwise, tests the compiled regex (or falls back to exact string match).
212    pub fn matches(&self, value: Option<&str>) -> bool {
213        let pattern = match &self.matcher {
214            None => return true,
215            Some(p) => p,
216        };
217
218        let value = match value {
219            None => return true,
220            Some(v) => v,
221        };
222
223        match &self.compiled_regex {
224            Some(compiled) => compiled.0.is_match(value),
225            None => pattern == value,
226        }
227    }
228}
229
230/// Top-level hooks configuration, typically loaded from settings.json.
231///
232/// The keys in the `hooks` map are event names (e.g., "PreToolUse").
233/// Unknown event names are silently dropped for forward compatibility.
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct HookConfig {
236    /// Map of event name to list of matchers.
237    #[serde(default)]
238    pub hooks: HashMap<String, Vec<HookMatcher>>,
239}
240
241impl HookConfig {
242    /// Create an empty hook configuration.
243    pub fn empty() -> Self {
244        Self {
245            hooks: HashMap::new(),
246        }
247    }
248
249    /// Compile all regex patterns in all matchers.
250    ///
251    /// Call this after deserialization.
252    pub fn compile_all(&mut self) {
253        for matchers in self.hooks.values_mut() {
254            for matcher in matchers.iter_mut() {
255                matcher.compile();
256            }
257        }
258    }
259
260    /// Get matchers for a given event. Returns an empty slice if none.
261    pub fn get_matchers(&self, event: HookEvent) -> &[HookMatcher] {
262        self.hooks
263            .get(event.as_str())
264            .map(|v| v.as_slice())
265            .unwrap_or(&[])
266    }
267
268    /// Fast check: are there any matchers registered for this event?
269    pub fn has_hooks_for(&self, event: HookEvent) -> bool {
270        self.hooks
271            .get(event.as_str())
272            .map(|v| !v.is_empty())
273            .unwrap_or(false)
274    }
275
276    /// Remove any event keys that are not recognized.
277    /// This provides forward compatibility — unknown events are silently dropped.
278    pub fn strip_unknown_events(&mut self) {
279        let valid: std::collections::HashSet<&str> =
280            HookEvent::ALL.iter().map(|e| e.as_str()).collect();
281        self.hooks.retain(|key, _| valid.contains(key.as_str()));
282    }
283
284    /// Register hooks for an event programmatically.
285    pub fn add_matcher(&mut self, event: HookEvent, matcher: HookMatcher) {
286        self.hooks
287            .entry(event.as_str().to_string())
288            .or_default()
289            .push(matcher);
290    }
291}
292
293#[cfg(test)]
294#[path = "models_tests.rs"]
295mod tests;