use serde_json::{json, Map, Value};
#[derive(Debug, Clone, PartialEq)]
pub struct HookSpec {
pub command: String,
pub matcher: Option<String>,
pub timeout: Option<u64>,
pub plugin_name: Option<String>,
pub description: Option<String>,
}
impl HookSpec {
pub fn command(command: impl Into<String>) -> Self {
Self {
command: command.into(),
matcher: None,
timeout: None,
plugin_name: None,
description: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HookShape {
Nested {
event: &'static str,
with_timeout: bool,
},
Flat { event: &'static str },
CommandOnly { events: &'static [&'static str] },
CrossShell { event: &'static str },
}
pub fn render(spec: &HookSpec, shape: HookShape) -> Value {
match shape {
HookShape::Nested {
event,
with_timeout,
} => {
let mut hook = Map::new();
hook.insert("type".into(), json!("command"));
hook.insert("command".into(), json!(spec.command));
if with_timeout {
if let Some(timeout) = spec.timeout {
hook.insert("timeout".into(), json!(timeout));
}
}
let mut group = Map::new();
if let Some(matcher) = &spec.matcher {
group.insert("matcher".into(), json!(matcher));
}
group.insert("hooks".into(), Value::Array(vec![Value::Object(hook)]));
event_map(&[event], Value::Array(vec![Value::Object(group)]))
}
HookShape::Flat { event } => {
let mut group = Map::new();
if let Some(matcher) = &spec.matcher {
group.insert("matcher".into(), json!(matcher));
}
group.insert("command".into(), json!(spec.command));
if let Some(timeout) = spec.timeout {
group.insert("timeout".into(), json!(timeout));
}
event_map(&[event], Value::Array(vec![Value::Object(group)]))
}
HookShape::CommandOnly { events } => {
let entry = Value::Array(vec![json!({ "command": spec.command })]);
event_map(events, entry)
}
HookShape::CrossShell { event } => {
let entry = json!({
"type": "command",
"bash": spec.command,
"powershell": spec.command,
});
event_map(&[event], Value::Array(vec![entry]))
}
}
}
fn event_map(events: &[&str], entries: Value) -> Value {
let mut map = Map::new();
for event in events {
map.insert((*event).to_string(), entries.clone());
}
Value::Object(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_without_timeout_drops_timeout() {
let spec = HookSpec {
command: "guard hook codex".into(),
matcher: Some("Bash".into()),
timeout: Some(10),
plugin_name: None,
description: None,
};
let shape = HookShape::Nested {
event: "PreToolUse",
with_timeout: false,
};
assert_eq!(
render(&spec, shape),
json!({
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [ { "type": "command", "command": "guard hook codex" } ],
}
]
}),
"timeout must be dropped for a shape whose schema has no timeout",
);
}
#[test]
fn nested_with_timeout_keeps_timeout() {
let spec = HookSpec {
command: "guard hook claude-code".into(),
matcher: Some("Bash".into()),
timeout: Some(10),
plugin_name: None,
description: None,
};
let shape = HookShape::Nested {
event: "PreToolUse",
with_timeout: true,
};
assert_eq!(
render(&spec, shape),
json!({
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "guard hook claude-code", "timeout": 10 }
],
}
]
}),
);
}
#[test]
fn flat_matches_crush_shape() {
let spec = HookSpec {
command: "guard hook crush".into(),
matcher: Some("bash".into()),
timeout: Some(10),
plugin_name: None,
description: None,
};
assert_eq!(
render(
&spec,
HookShape::Flat {
event: "PreToolUse"
}
),
json!({
"PreToolUse": [
{ "matcher": "bash", "command": "guard hook crush", "timeout": 10 }
]
}),
);
}
#[test]
fn command_only_matches_cursor_shape() {
let spec = HookSpec::command("guard hook cursor");
let shape = HookShape::CommandOnly {
events: &[
"beforeShellExecution",
"beforeReadFile",
"beforeMCPExecution",
],
};
assert_eq!(
render(&spec, shape),
json!({
"beforeShellExecution": [ { "command": "guard hook cursor" } ],
"beforeReadFile": [ { "command": "guard hook cursor" } ],
"beforeMCPExecution": [ { "command": "guard hook cursor" } ],
}),
);
}
#[test]
fn cross_shell_matches_copilot_shape() {
let spec = HookSpec::command("guard hook copilot");
assert_eq!(
render(
&spec,
HookShape::CrossShell {
event: "preToolUse"
}
),
json!({
"preToolUse": [
{
"type": "command",
"bash": "guard hook copilot",
"powershell": "guard hook copilot",
}
]
}),
);
}
#[test]
fn absent_matcher_is_omitted_not_nulled() {
let spec = HookSpec::command("guard hook claude-code");
let nested = render(
&spec,
HookShape::Nested {
event: "PreToolUse",
with_timeout: false,
},
);
let group = &nested["PreToolUse"][0];
assert!(
group.get("matcher").is_none(),
"absent matcher must not appear: {group}",
);
let flat = render(
&spec,
HookShape::Flat {
event: "PreToolUse",
},
);
assert!(flat["PreToolUse"][0].get("matcher").is_none());
}
#[test]
fn absent_timeout_is_omitted() {
let spec = HookSpec {
command: "guard hook goose".into(),
matcher: None,
timeout: None,
plugin_name: None,
description: None,
};
let rendered = render(
&spec,
HookShape::Nested {
event: "PreToolUse",
with_timeout: true,
},
);
assert!(rendered["PreToolUse"][0]["hooks"][0]
.get("timeout")
.is_none());
}
}