use crate::core::file_error::{FileOperation, FileResultExt};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HookEvent {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "PostToolUse")]
PostToolUse,
#[serde(rename = "Notification")]
Notification,
#[serde(rename = "UserPromptSubmit")]
UserPromptSubmit,
#[serde(rename = "Stop")]
Stop,
#[serde(rename = "SubagentStop")]
SubagentStop,
#[serde(rename = "PreCompact")]
PreCompact,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "SessionEnd")]
SessionEnd,
#[serde(untagged)]
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub events: Vec<HookEvent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
#[serde(rename = "type")]
pub hook_type: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookCommand {
#[serde(rename = "type")]
pub hook_type: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
pub agpm_metadata: Option<AgpmHookMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgpmHookMetadata {
pub managed: bool,
pub dependency_name: String,
pub source: String,
pub version: String,
pub installed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatcherGroup {
pub matcher: String,
pub hooks: Vec<HookCommand>,
}
pub fn load_hook_configs(hooks_dir: &Path) -> Result<HashMap<String, HookConfig>> {
let mut configs = HashMap::new();
if !hooks_dir.exists() {
return Ok(configs);
}
for entry in std::fs::read_dir(hooks_dir).with_file_context(
FileOperation::Read,
hooks_dir,
"reading hooks directory",
"hooks_module",
)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("Invalid hook filename"))?
.to_string();
let content = std::fs::read_to_string(&path).with_file_context(
FileOperation::Read,
&path,
"reading hook file",
"hooks_module",
)?;
let config: HookConfig = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse hook config: {}", path.display()))?;
configs.insert(name, config);
}
}
Ok(configs)
}
fn convert_to_claude_format(
hook_configs: HashMap<String, HookConfig>,
) -> Result<serde_json::Value> {
use serde_json::{Map, Value, json};
let mut events_map: Map<String, Value> = Map::new();
for (_name, config) in hook_configs {
for event in &config.events {
let event_name = event_to_string(event);
let mut hook_obj = serde_json::Map::new();
hook_obj.insert("type".to_string(), json!(config.hook_type));
hook_obj.insert("command".to_string(), json!(config.command));
if let Some(timeout) = config.timeout {
hook_obj.insert("timeout".to_string(), json!(timeout));
}
let hook_obj = Value::Object(hook_obj);
let event_array = events_map.entry(event_name).or_insert_with(|| json!([]));
let event_vec = event_array.as_array_mut().unwrap();
if let Some(ref matcher) = config.matcher {
let mut found_group = false;
for group in event_vec.iter_mut() {
if let Some(group_matcher) = group.get("matcher").and_then(|m| m.as_str())
&& group_matcher == matcher
{
if let Some(hooks_array) =
group.get_mut("hooks").and_then(|h| h.as_array_mut())
{
hooks_array.push(hook_obj.clone());
found_group = true;
break;
}
}
}
if !found_group {
event_vec.push(json!({
"matcher": matcher,
"hooks": [hook_obj]
}));
}
} else {
if let Some(first_group) = event_vec.first_mut() {
if first_group.as_object().unwrap().contains_key("matcher") {
event_vec.push(json!({
"hooks": [hook_obj]
}));
} else if let Some(hooks_array) =
first_group.get_mut("hooks").and_then(|h| h.as_array_mut())
{
let hook_exists = hooks_array.iter().any(|existing_hook| {
existing_hook.get("command") == hook_obj.get("command")
&& existing_hook.get("type") == hook_obj.get("type")
});
if !hook_exists {
hooks_array.push(hook_obj);
}
}
} else {
event_vec.push(json!({
"hooks": [hook_obj]
}));
}
}
}
}
Ok(Value::Object(events_map))
}
fn event_to_string(event: &HookEvent) -> String {
match event {
HookEvent::PreToolUse => "PreToolUse".to_string(),
HookEvent::PostToolUse => "PostToolUse".to_string(),
HookEvent::Notification => "Notification".to_string(),
HookEvent::UserPromptSubmit => "UserPromptSubmit".to_string(),
HookEvent::Stop => "Stop".to_string(),
HookEvent::SubagentStop => "SubagentStop".to_string(),
HookEvent::PreCompact => "PreCompact".to_string(),
HookEvent::SessionStart => "SessionStart".to_string(),
HookEvent::SessionEnd => "SessionEnd".to_string(),
HookEvent::Other(event_name) => event_name.clone(),
}
}
pub async fn install_hooks(
lockfile: &crate::lockfile::LockFile,
project_root: &Path,
cache: &crate::cache::Cache,
) -> Result<usize> {
if lockfile.hooks.is_empty() {
return Ok(0);
}
let claude_dir = project_root.join(".claude");
let settings_path = claude_dir.join("settings.local.json");
crate::utils::fs::ensure_dir(&claude_dir)?;
let mut hook_configs = HashMap::new();
for entry in &lockfile.hooks {
let source_path = if let Some(source_name) = &entry.source {
let url = entry
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Hook {} has no URL", entry.name))?;
let is_local_source = entry.is_local();
if is_local_source {
std::path::PathBuf::from(url).join(&entry.path)
} else {
let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
anyhow::anyhow!("Hook {} missing resolved commit SHA", entry.name)
})?;
let worktree = cache
.get_or_create_worktree_for_sha(source_name, url, sha, Some(&entry.name))
.await?;
worktree.join(&entry.path)
}
} else {
let candidate = Path::new(&entry.path);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
project_root.join(candidate)
}
};
let content = tokio::fs::read_to_string(&source_path).await.with_file_context(
FileOperation::Read,
&source_path,
"reading hook file",
"hooks_module",
)?;
let config: HookConfig = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse hook config: {}", source_path.display()))?;
hook_configs.insert(entry.name.clone(), config);
}
let mut settings = crate::mcp::ClaudeSettings::load_or_default(&settings_path)?;
let claude_hooks = convert_to_claude_format(hook_configs)?;
let hooks_changed = match &settings.hooks {
Some(existing_hooks) => existing_hooks != &claude_hooks,
None => claude_hooks.as_object().is_none_or(|obj| !obj.is_empty()),
};
if hooks_changed {
let configured_count = claude_hooks.as_object().map_or(0, |events| {
events
.values()
.filter_map(|event_groups| event_groups.as_array())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("hooks")?.as_array())
.map(std::vec::Vec::len)
.sum::<usize>()
})
.sum::<usize>()
});
settings.hooks = Some(claude_hooks);
settings.save(&settings_path)?;
Ok(configured_count)
} else {
Ok(0)
}
}
pub fn validate_hook_config(config: &HookConfig, script_path: &Path) -> Result<()> {
if config.events.is_empty() {
return Err(anyhow::anyhow!("Hook must specify at least one event"));
}
if let Some(ref matcher) = config.matcher {
regex::Regex::new(matcher)
.with_context(|| format!("Invalid regex pattern in matcher: {matcher}"))?;
}
if config.hook_type != "command" {
return Err(anyhow::anyhow!("Only 'command' hook type is currently supported"));
}
let script_full_path = if config.command.starts_with(".claude/scripts/") {
script_path
.parent() .and_then(|p| p.parent()) .map(|p| p.join(&config.command))
} else {
None
};
if let Some(path) = script_full_path
&& !path.exists()
{
return Err(anyhow::anyhow!("Hook references non-existent script: {}", config.command));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_hook_event_serialization() {
let events = vec![
(HookEvent::PreToolUse, r#""PreToolUse""#),
(HookEvent::PostToolUse, r#""PostToolUse""#),
(HookEvent::Notification, r#""Notification""#),
(HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
(HookEvent::Stop, r#""Stop""#),
(HookEvent::SubagentStop, r#""SubagentStop""#),
(HookEvent::PreCompact, r#""PreCompact""#),
(HookEvent::SessionStart, r#""SessionStart""#),
(HookEvent::SessionEnd, r#""SessionEnd""#),
(HookEvent::Other("CustomEvent".to_string()), r#""CustomEvent""#),
];
for (event, expected) in events {
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, expected);
let parsed: HookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
}
#[test]
fn test_hook_config_serialization() {
let config = HookConfig {
events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
matcher: Some("Bash|Write".to_string()),
hook_type: "command".to_string(),
command: ".claude/scripts/security-check.sh".to_string(),
timeout: Some(5000),
description: Some("Security validation".to_string()),
};
let json = serde_json::to_string_pretty(&config).unwrap();
let parsed: HookConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.events.len(), 2);
assert_eq!(parsed.matcher, Some("Bash|Write".to_string()));
assert_eq!(parsed.timeout, Some(5000));
assert_eq!(parsed.description, Some("Security validation".to_string()));
}
#[test]
fn test_hook_config_minimal() {
let config = HookConfig {
events: vec![HookEvent::UserPromptSubmit],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: "echo 'test'".to_string(),
timeout: None,
description: None,
};
let json = serde_json::to_string(&config).unwrap();
assert!(!json.contains("timeout"));
assert!(!json.contains("description"));
}
#[test]
fn test_hook_command_serialization() {
let metadata = AgpmHookMetadata {
managed: true,
dependency_name: "test-hook".to_string(),
source: "community".to_string(),
version: "v1.0.0".to_string(),
installed_at: "2024-01-01T00:00:00Z".to_string(),
};
let command = HookCommand {
hook_type: "command".to_string(),
command: "test.sh".to_string(),
timeout: Some(3000),
agpm_metadata: Some(metadata.clone()),
};
let json = serde_json::to_string(&command).unwrap();
let parsed: HookCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hook_type, "command");
assert_eq!(parsed.command, "test.sh");
assert_eq!(parsed.timeout, Some(3000));
assert!(parsed.agpm_metadata.is_some());
let meta = parsed.agpm_metadata.unwrap();
assert!(meta.managed);
assert_eq!(meta.dependency_name, "test-hook");
}
#[test]
fn test_matcher_group_serialization() {
let command = HookCommand {
hook_type: "command".to_string(),
command: "test.sh".to_string(),
timeout: None,
agpm_metadata: None,
};
let group = MatcherGroup {
matcher: "Bash.*".to_string(),
hooks: vec![command.clone(), command.clone()],
};
let json = serde_json::to_string(&group).unwrap();
let parsed: MatcherGroup = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.matcher, "Bash.*");
assert_eq!(parsed.hooks.len(), 2);
}
#[test]
fn test_load_hook_configs() {
let temp = tempdir().unwrap();
let hooks_dir = temp.path().join("hooks");
std::fs::create_dir(&hooks_dir).unwrap();
let config1 = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: "test1.sh".to_string(),
timeout: None,
description: None,
};
let config2 = HookConfig {
events: vec![HookEvent::PostToolUse],
matcher: Some("Write".to_string()),
hook_type: "command".to_string(),
command: "test2.sh".to_string(),
timeout: Some(1000),
description: Some("Test hook 2".to_string()),
};
fs::write(hooks_dir.join("test-hook1.json"), serde_json::to_string(&config1).unwrap())
.unwrap();
fs::write(hooks_dir.join("test-hook2.json"), serde_json::to_string(&config2).unwrap())
.unwrap();
fs::write(hooks_dir.join("readme.txt"), "This is not a hook").unwrap();
let configs = load_hook_configs(&hooks_dir).unwrap();
assert_eq!(configs.len(), 2);
assert!(configs.contains_key("test-hook1"));
assert!(configs.contains_key("test-hook2"));
let hook1 = &configs["test-hook1"];
assert_eq!(hook1.events.len(), 1);
assert_eq!(hook1.command, "test1.sh");
let hook2 = &configs["test-hook2"];
assert_eq!(hook2.timeout, Some(1000));
}
#[test]
fn test_load_hook_configs_empty_dir() {
let temp = tempdir().unwrap();
let hooks_dir = temp.path().join("empty_hooks");
let configs = load_hook_configs(&hooks_dir).unwrap();
assert_eq!(configs.len(), 0);
}
#[test]
fn test_load_hook_configs_invalid_json() {
let temp = tempdir().unwrap();
let hooks_dir = temp.path().join("hooks");
fs::create_dir(&hooks_dir).unwrap();
fs::write(hooks_dir.join("invalid.json"), "{ not valid json").unwrap();
let result = load_hook_configs(&hooks_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse hook config"));
}
#[test]
fn test_validate_hook_config_empty_events() {
let temp = tempdir().unwrap();
let config = HookConfig {
events: vec![], matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: "test.sh".to_string(),
timeout: None,
description: None,
};
let result = validate_hook_config(&config, temp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("at least one event"));
}
#[test]
fn test_validate_hook_config_invalid_regex() {
let temp = tempdir().unwrap();
let config = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("[invalid regex".to_string()), hook_type: "command".to_string(),
command: "test.sh".to_string(),
timeout: None,
description: None,
};
let result = validate_hook_config(&config, temp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
}
#[test]
fn test_validate_hook_config_unsupported_type() {
let temp = tempdir().unwrap();
let config = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some(".*".to_string()),
hook_type: "webhook".to_string(), command: "test.sh".to_string(),
timeout: None,
description: None,
};
let result = validate_hook_config(&config, temp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Only 'command' hook type"));
}
#[test]
fn test_validate_hook_config_script_exists() {
let temp = tempdir().unwrap();
let claude_dir = temp.path().join(".claude");
let scripts_dir = claude_dir.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script_path = scripts_dir.join("test.sh");
fs::write(&script_path, "#!/bin/bash\necho test").unwrap();
let config = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: ".claude/scripts/test.sh".to_string(),
timeout: None,
description: None,
};
let settings_path = temp.path().join(".claude").join("settings.local.json");
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
let result = validate_hook_config(&config, &settings_path);
assert!(result.is_ok(), "Expected validation to succeed, but got: {:?}", result);
}
#[test]
fn test_validate_hook_config_script_not_exists() {
let temp = tempdir().unwrap();
let config = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: ".claude/scripts/nonexistent.sh".to_string(),
timeout: None,
description: None,
};
let hook_path = temp.path().join(".claude").join("agpm").join("hooks").join("test.json");
let result = validate_hook_config(&config, &hook_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-existent script"));
}
#[test]
fn test_validate_hook_config_non_claude_path() -> anyhow::Result<()> {
let temp = tempdir().unwrap();
let config = HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: "/usr/bin/echo".to_string(), timeout: None,
description: None,
};
let result = validate_hook_config(&config, temp.path());
result?;
Ok(())
}
#[test]
fn test_convert_to_claude_format_session_start() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"session-hook".to_string(),
HookConfig {
events: vec![HookEvent::SessionStart],
matcher: None, hook_type: "command".to_string(),
command: "echo 'session started'".to_string(),
timeout: Some(1000),
description: Some("Session start hook".to_string()),
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let expected = serde_json::json!({
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo 'session started'",
"timeout": 1000
}
]
}
]
});
assert_eq!(result, expected);
}
#[test]
fn test_convert_to_claude_format_with_matcher() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"tool-hook".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("Bash|Write".to_string()),
hook_type: "command".to_string(),
command: "echo 'before tool use'".to_string(),
timeout: None,
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let expected = serde_json::json!({
"PreToolUse": [
{
"matcher": "Bash|Write",
"hooks": [
{
"type": "command",
"command": "echo 'before tool use'"
}
]
}
]
});
assert_eq!(result, expected);
}
#[test]
fn test_convert_to_claude_format_multiple_events() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"multi-event-hook".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse, HookEvent::PostToolUse],
matcher: Some(".*".to_string()),
hook_type: "command".to_string(),
command: "echo 'tool event'".to_string(),
timeout: Some(5000),
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
assert!(result.get("PreToolUse").is_some());
assert!(result.get("PostToolUse").is_some());
let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
let post_tool = result.get("PostToolUse").unwrap().as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
assert_eq!(post_tool.len(), 1);
assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*");
}
#[test]
fn test_convert_to_claude_format_deduplication() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"hook1".to_string(),
HookConfig {
events: vec![HookEvent::SessionStart],
matcher: None,
hook_type: "command".to_string(),
command: "agpm update".to_string(),
timeout: None,
description: None,
},
);
hook_configs.insert(
"hook2".to_string(),
HookConfig {
events: vec![HookEvent::SessionStart],
matcher: None,
hook_type: "command".to_string(),
command: "agpm update".to_string(), timeout: None,
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let session_start = result.get("SessionStart").unwrap().as_array().unwrap();
assert_eq!(session_start.len(), 1);
let hooks = session_start[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0].get("command").unwrap().as_str().unwrap(), "agpm update");
}
#[test]
fn test_convert_to_claude_format_different_matchers() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"bash-hook".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("Bash".to_string()),
hook_type: "command".to_string(),
command: "echo 'bash tool'".to_string(),
timeout: None,
description: None,
},
);
hook_configs.insert(
"write-hook".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("Write".to_string()),
hook_type: "command".to_string(),
command: "echo 'write tool'".to_string(),
timeout: None,
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
assert_eq!(pre_tool.len(), 2);
let bash_group = pre_tool
.iter()
.find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Bash"))
.unwrap();
let write_group = pre_tool
.iter()
.find(|g| g.get("matcher").and_then(|m| m.as_str()) == Some("Write"))
.unwrap();
assert!(bash_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
assert!(write_group.get("hooks").unwrap().as_array().unwrap().len() == 1);
}
#[test]
fn test_convert_to_claude_format_same_matcher() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"hook1".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("Bash".to_string()),
hook_type: "command".to_string(),
command: "echo 'first'".to_string(),
timeout: None,
description: None,
},
);
hook_configs.insert(
"hook2".to_string(),
HookConfig {
events: vec![HookEvent::PreToolUse],
matcher: Some("Bash".to_string()), hook_type: "command".to_string(),
command: "echo 'second'".to_string(),
timeout: None,
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let pre_tool = result.get("PreToolUse").unwrap().as_array().unwrap();
assert_eq!(pre_tool.len(), 1);
let hooks = pre_tool[0].get("hooks").unwrap().as_array().unwrap();
assert_eq!(hooks.len(), 2);
assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), "Bash");
}
#[test]
fn test_convert_to_claude_format_empty() {
let hook_configs = HashMap::new();
let result = convert_to_claude_format(hook_configs).unwrap();
assert_eq!(result.as_object().unwrap().len(), 0);
}
#[test]
fn test_convert_to_claude_format_other_event() {
let mut hook_configs = HashMap::new();
hook_configs.insert(
"future-hook".to_string(),
HookConfig {
events: vec![HookEvent::Other("FutureEvent".to_string())],
matcher: None,
hook_type: "command".to_string(),
command: "echo 'future event'".to_string(),
timeout: None,
description: None,
},
);
let result = convert_to_claude_format(hook_configs).unwrap();
let expected = serde_json::json!({
"FutureEvent": [
{
"hooks": [
{
"type": "command",
"command": "echo 'future event'"
}
]
}
]
});
assert_eq!(result, expected);
}
#[test]
fn test_hook_event_other_serialization() {
let other_event = HookEvent::Other("CustomEvent".to_string());
let json = serde_json::to_string(&other_event).unwrap();
assert_eq!(json, r#""CustomEvent""#);
let parsed: HookEvent = serde_json::from_str(&json).unwrap();
if let HookEvent::Other(event_name) = parsed {
assert_eq!(event_name, "CustomEvent");
} else {
panic!("Expected Other variant");
}
}
}