use anyhow::{Context, Result, ensure};
use regex::Regex;
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct HooksConfig {
#[serde(default)]
pub lifecycle: LifecycleHooksConfig,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct LifecycleHooksConfig {
#[serde(default)]
pub quiet_success_output: bool,
#[serde(default)]
pub session_start: Vec<HookGroupConfig>,
#[serde(default)]
pub session_end: Vec<HookGroupConfig>,
#[serde(default)]
pub subagent_start: Vec<HookGroupConfig>,
#[serde(default)]
pub subagent_stop: Vec<HookGroupConfig>,
#[serde(default)]
pub user_prompt_submit: Vec<HookGroupConfig>,
#[serde(default)]
pub pre_tool_use: Vec<HookGroupConfig>,
#[serde(default)]
pub post_tool_use: Vec<HookGroupConfig>,
#[serde(default)]
pub permission_request: Vec<HookGroupConfig>,
#[serde(default)]
pub pre_compact: Vec<HookGroupConfig>,
#[serde(default)]
pub stop: Vec<HookGroupConfig>,
#[serde(default)]
pub task_completion: Vec<HookGroupConfig>,
#[serde(default)]
pub task_completed: Vec<HookGroupConfig>,
#[serde(default)]
pub notification: Vec<HookGroupConfig>,
}
impl LifecycleHooksConfig {
pub fn is_empty(&self) -> bool {
self.session_start.is_empty()
&& self.session_end.is_empty()
&& self.subagent_start.is_empty()
&& self.subagent_stop.is_empty()
&& self.user_prompt_submit.is_empty()
&& self.pre_tool_use.is_empty()
&& self.post_tool_use.is_empty()
&& self.permission_request.is_empty()
&& self.pre_compact.is_empty()
&& self.stop.is_empty()
&& self.task_completion.is_empty()
&& self.task_completed.is_empty()
&& self.notification.is_empty()
}
pub fn normalized(&self) -> Self {
let mut normalized = self.clone();
normalized.stop.extend(self.task_completion.clone());
normalized.stop.extend(self.task_completed.clone());
normalized.task_completion.clear();
normalized.task_completed.clear();
normalized
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct HookGroupConfig {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookCommandConfig>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum HookCommandKind {
#[default]
Command,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct HookCommandConfig {
#[serde(default)]
#[serde(rename = "type")]
pub kind: HookCommandKind,
#[serde(default)]
pub command: String,
#[serde(default)]
pub timeout_seconds: Option<u64>,
}
impl HooksConfig {
pub fn validate(&self) -> Result<()> {
self.lifecycle
.validate()
.context("Invalid lifecycle hooks configuration")
}
}
impl LifecycleHooksConfig {
pub fn validate(&self) -> Result<()> {
validate_groups(&self.session_start, "session_start")?;
validate_groups(&self.session_end, "session_end")?;
validate_groups(&self.subagent_start, "subagent_start")?;
validate_groups(&self.subagent_stop, "subagent_stop")?;
validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
validate_groups(&self.pre_tool_use, "pre_tool_use")?;
validate_groups(&self.post_tool_use, "post_tool_use")?;
validate_groups(&self.permission_request, "permission_request")?;
validate_groups(&self.pre_compact, "pre_compact")?;
validate_groups(&self.stop, "stop")?;
validate_groups(&self.task_completion, "task_completion")?;
validate_groups(&self.task_completed, "task_completed")?;
validate_groups(&self.notification, "notification")?;
Ok(())
}
}
fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
for (index, group) in groups.iter().enumerate() {
if let Some(pattern) = group.matcher.as_ref() {
validate_matcher(pattern).with_context(|| {
format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
})?;
}
ensure!(
!group.hooks.is_empty(),
"hooks.{context_name}[{index}] must define at least one hook command"
);
for (hook_index, hook) in group.hooks.iter().enumerate() {
ensure!(
matches!(hook.kind, HookCommandKind::Command),
"hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
);
ensure!(
!hook.command.trim().is_empty(),
"hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
);
if let Some(timeout) = hook.timeout_seconds {
ensure!(
timeout > 0,
"hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
);
}
}
}
Ok(())
}
fn validate_matcher(pattern: &str) -> Result<()> {
let trimmed = pattern.trim();
if trimmed.is_empty() || trimmed == "*" {
return Ok(());
}
let regex_pattern = format!("^(?:{})$", trimmed);
Regex::new(®ex_pattern)
.with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_group() -> HookGroupConfig {
HookGroupConfig {
matcher: None,
hooks: vec![HookCommandConfig {
kind: HookCommandKind::Command,
command: "echo ok".to_string(),
timeout_seconds: None,
}],
}
}
#[test]
fn permission_request_and_stop_validate() {
let config = LifecycleHooksConfig {
permission_request: vec![sample_group()],
stop: vec![sample_group()],
..Default::default()
};
config.validate().expect("hooks validate");
}
#[test]
fn task_aliases_normalize_into_stop() {
let config = LifecycleHooksConfig {
task_completion: vec![sample_group()],
task_completed: vec![sample_group()],
..Default::default()
};
let normalized = config.normalized();
assert_eq!(normalized.stop.len(), 2);
assert!(normalized.task_completion.is_empty());
assert!(normalized.task_completed.is_empty());
}
}