use std::path::Path;
use serde::Deserialize;
use crate::diagnostics::{
Diagnostic, Severity, H001, H002, H003, H004, H005, H006, H007, H008, H009, H010, H011,
};
const VALID_EVENTS: &[&str] = &[
"PreToolUse",
"PostToolUse",
"Stop",
"SubagentStop",
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreCompact",
"Notification",
];
const VALID_HOOK_TYPES: &[&str] = &["command", "prompt"];
const OPTIMAL_PROMPT_EVENTS: &[&str] = &["Stop", "SubagentStop", "UserPromptSubmit", "PreToolUse"];
#[derive(Debug, Deserialize)]
pub struct HookDefinition {
#[serde(rename = "type")]
pub hook_type: Option<String>,
pub command: Option<String>,
pub prompt: Option<String>,
pub timeout: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct HookEntry {
pub matcher: Option<String>,
pub hooks: Option<Vec<HookDefinition>>,
}
#[must_use]
pub fn validate_hooks(path: &Path) -> Vec<Diagnostic> {
let mut diags = Vec::new();
let content = match crate::parser::read_file_checked(path) {
Ok(c) => c,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
H001,
format!("cannot read hooks.json: {e}"),
));
return diags;
}
};
let raw: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
H001,
format!("invalid JSON syntax: {e}"),
));
return diags;
}
};
let events: std::collections::HashMap<String, Vec<HookEntry>> =
match serde_json::from_value(raw) {
Ok(m) => m,
Err(e) => {
diags.push(Diagnostic::new(
Severity::Error,
H002,
format!("invalid hooks structure: {e}"),
));
return diags;
}
};
for (event_name, entries) in &events {
if !VALID_EVENTS.contains(&event_name.as_str()) {
diags.push(
Diagnostic::new(
Severity::Error,
H003,
format!("unknown event name: \"{event_name}\""),
)
.with_suggestion(format!("Valid events: {}", VALID_EVENTS.join(", "))),
);
}
for entry in entries {
let hooks = match &entry.hooks {
Some(h) => h,
None => {
diags.push(Diagnostic::new(
Severity::Error,
H004,
format!("hook entry for \"{event_name}\" missing `hooks` array"),
));
continue;
}
};
for hook in hooks {
let hook_type = match &hook.hook_type {
Some(t) => t.as_str(),
None => {
diags.push(Diagnostic::new(
Severity::Error,
H005,
format!("hook in \"{event_name}\" missing `type` field"),
));
continue;
}
};
if !VALID_HOOK_TYPES.contains(&hook_type) {
diags.push(
Diagnostic::new(
Severity::Error,
H006,
format!("unknown hook type: \"{hook_type}\""),
)
.with_suggestion("Valid types: command, prompt"),
);
continue;
}
if hook_type == "command" && hook.command.is_none() {
diags.push(Diagnostic::new(
Severity::Error,
H007,
format!("command hook in \"{event_name}\" missing `command` field"),
));
}
if hook_type == "prompt" && hook.prompt.is_none() {
diags.push(Diagnostic::new(
Severity::Error,
H008,
format!("prompt hook in \"{event_name}\" missing `prompt` field"),
));
}
if let Some(timeout) = hook.timeout {
if !(5.0..=600.0).contains(&timeout) {
diags.push(
Diagnostic::new(
Severity::Warning,
H009,
format!("timeout {timeout}s is outside recommended range (5–600s)"),
)
.with_suggestion("Use a timeout between 5 and 600 seconds"),
);
}
}
if hook_type == "command" {
if let Some(cmd) = &hook.command {
let has_absolute = cmd
.split_whitespace()
.any(|token| token.starts_with('/') || Path::new(token).is_absolute());
if has_absolute {
diags.push(
Diagnostic::new(
Severity::Warning,
H010,
format!("absolute path in command: \"{cmd}\""),
)
.with_suggestion("Use ${CLAUDE_PLUGIN_ROOT} for portable paths"),
);
}
}
}
if hook_type == "prompt" && !OPTIMAL_PROMPT_EVENTS.contains(&event_name.as_str()) {
diags.push(Diagnostic::new(
Severity::Info,
H011,
format!(
"prompt hook on \"{event_name}\" — prompt hooks work best on {}",
OPTIMAL_PROMPT_EVENTS.join(", ")
),
));
}
}
}
}
diags
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_hooks(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempdir().unwrap();
let path = dir.path().join("hooks.json");
fs::write(&path, content).unwrap();
(dir, path)
}
#[test]
fn valid_hooks_no_errors() {
let (_dir, path) = write_hooks(
r#"{
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo test", "timeout": 10 }]
}]
}"#,
);
let diags = validate_hooks(&path);
let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
}
#[test]
fn invalid_json_h001() {
let (_dir, path) = write_hooks("{ not json }");
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H001));
}
#[test]
fn invalid_structure_h002() {
let (_dir, path) = write_hooks(r#"["not", "an", "object"]"#);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H002));
}
#[test]
fn unknown_event_h003() {
let (_dir, path) = write_hooks(
r#"{ "OnSave": [{ "hooks": [{ "type": "command", "command": "echo" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H003));
}
#[test]
fn missing_hooks_array_h004() {
let (_dir, path) = write_hooks(r#"{ "PreToolUse": [{ "matcher": "Bash" }] }"#);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H004));
}
#[test]
fn missing_type_h005() {
let (_dir, path) =
write_hooks(r#"{ "PreToolUse": [{ "hooks": [{ "command": "echo test" }] }] }"#);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H005));
}
#[test]
fn unknown_hook_type_h006() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "script", "command": "echo" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H006));
}
#[test]
fn command_missing_command_h007() {
let (_dir, path) =
write_hooks(r#"{ "PreToolUse": [{ "hooks": [{ "type": "command" }] }] }"#);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H007));
}
#[test]
fn prompt_missing_prompt_h008() {
let (_dir, path) = write_hooks(r#"{ "Stop": [{ "hooks": [{ "type": "prompt" }] }] }"#);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H008));
}
#[test]
fn timeout_out_of_range_h009() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "echo", "timeout": 1 }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H009));
}
#[test]
fn timeout_too_high_h009() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "echo", "timeout": 700 }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H009));
}
#[test]
fn timeout_in_range_no_h009() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "echo", "timeout": 30 }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(!diags.iter().any(|d| d.code == H009));
}
#[test]
fn absolute_path_h010() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "/usr/bin/test" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H010));
}
#[test]
fn relative_path_no_h010() {
let (_dir, path) = write_hooks(
r#"{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "./scripts/test.sh" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(!diags.iter().any(|d| d.code == H010));
}
#[test]
fn prompt_on_suboptimal_event_h011() {
let (_dir, path) = write_hooks(
r#"{ "SessionStart": [{ "hooks": [{ "type": "prompt", "prompt": "Be careful" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(diags.iter().any(|d| d.code == H011));
}
#[test]
fn prompt_on_optimal_event_no_h011() {
let (_dir, path) = write_hooks(
r#"{ "Stop": [{ "hooks": [{ "type": "prompt", "prompt": "Review output" }] }] }"#,
);
let diags = validate_hooks(&path);
assert!(!diags.iter().any(|d| d.code == H011));
}
#[test]
fn nonexistent_file_returns_h001() {
let diags = validate_hooks(Path::new("/nonexistent/hooks.json"));
assert!(diags.iter().any(|d| d.code == H001));
}
#[test]
fn all_valid_events_accepted() {
for event in VALID_EVENTS {
let json = format!(
r#"{{ "{event}": [{{ "hooks": [{{ "type": "command", "command": "echo" }}] }}] }}"#
);
let (_dir, path) = write_hooks(&json);
let diags = validate_hooks(&path);
assert!(
!diags.iter().any(|d| d.code == H003),
"event {event} should be valid"
);
}
}
}