use crate::error::AgentConfigError;
use super::validate::{validate_identifier, IdentifierKind};
mod builder;
pub use builder::HookSpecBuilder;
#[derive(Debug, Clone)]
pub struct HookSpec {
pub tag: String,
pub command: HookCommand,
pub matcher: Matcher,
pub event: Event,
pub rules: Option<RulesBlock>,
pub script: Option<ScriptTemplate>,
pub friendly_name: Option<String>,
}
impl HookSpec {
pub fn builder(tag: impl Into<String>) -> HookSpecBuilder {
HookSpecBuilder {
tag: tag.into(),
command: None,
matcher: Matcher::All,
event: Event::PreToolUse,
rules: None,
script: None,
friendly_name: None,
}
}
pub(crate) fn validate_tag(tag: &str) -> Result<(), AgentConfigError> {
validate_identifier(tag, IdentifierKind::Tag)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HookCommand {
Program {
program: String,
args: Vec<String>,
},
ShellUnchecked {
command: String,
},
}
impl HookCommand {
pub fn program<I, S>(program: impl Into<String>, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Program {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
}
}
pub fn shell_unchecked(command: impl Into<String>) -> Self {
Self::ShellUnchecked {
command: command.into(),
}
}
pub fn render_shell(&self) -> String {
match self {
Self::Program { program, args } => std::iter::once(program.as_str())
.chain(args.iter().map(String::as_str))
.map(shell_quote)
.collect::<Vec<_>>()
.join(" "),
Self::ShellUnchecked { command } => command.clone(),
}
}
pub(super) fn validate(&self) -> Result<(), AgentConfigError> {
match self {
Self::Program { program, args } => {
if program.is_empty() {
return Err(AgentConfigError::InvalidCommand {
reason: "program must not be empty",
});
}
validate_no_nul(program)?;
for arg in args {
validate_no_nul(arg)?;
}
}
Self::ShellUnchecked { command } => {
if command.trim().is_empty() {
return Err(AgentConfigError::InvalidCommand {
reason: "shell command must not be empty",
});
}
validate_no_nul(command)?;
}
}
Ok(())
}
}
fn validate_no_nul(value: &str) -> Result<(), AgentConfigError> {
if value.contains('\0') {
return Err(AgentConfigError::InvalidCommand {
reason: "command values must not contain NUL bytes",
});
}
Ok(())
}
#[derive(Copy, Clone)]
pub(super) enum HookStringKind {
Matcher,
CustomEvent,
}
impl HookStringKind {
fn empty_reason(self) -> &'static str {
match self {
Self::Matcher => "matcher value must not be empty",
Self::CustomEvent => "custom event name must not be empty",
}
}
fn control_reason(self) -> &'static str {
match self {
Self::Matcher => "matcher value must not contain control characters",
Self::CustomEvent => "custom event name must not contain control characters",
}
}
}
pub(super) fn validate_hook_string(
value: &str,
kind: HookStringKind,
) -> Result<(), AgentConfigError> {
if value.is_empty() {
return Err(AgentConfigError::InvalidTag {
tag: value.to_string(),
reason: kind.empty_reason(),
});
}
if value.chars().any(|c| (c as u32) < 0x20 || c == '\u{007F}') {
return Err(AgentConfigError::InvalidTag {
tag: value.to_string(),
reason: kind.control_reason(),
});
}
Ok(())
}
fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
if value
.bytes()
.all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' | b','))
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Matcher {
All,
Bash,
Exact(String),
AnyOf(Vec<String>),
Regex(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Event {
PreToolUse,
PostToolUse,
Custom(String),
}
#[derive(Debug, Clone)]
pub struct RulesBlock {
pub content: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ScriptTemplate {
Shell(String),
TypeScript(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn try_build_rejects_empty_tag() {
let err = HookSpec::builder("")
.command_program("x", [] as [&str; 0])
.try_build()
.unwrap_err();
assert!(
matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason == "tag must not be empty")
);
}
#[test]
fn try_build_rejects_tag_with_spaces() {
let err = HookSpec::builder("not valid")
.command_program("x", [] as [&str; 0])
.try_build()
.unwrap_err();
assert!(
matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason.contains("ASCII"))
);
}
#[test]
fn try_build_rejects_tag_with_special_chars() {
for bad in ["tag/slash", "tag.dot", "tag!bang", "tag@at"] {
let err = HookSpec::builder(bad)
.command_program("x", [] as [&str; 0])
.try_build()
.unwrap_err();
assert!(
matches!(err, AgentConfigError::InvalidTag { .. }),
"expected InvalidTag for {bad:?}"
);
}
}
#[test]
fn try_build_accepts_valid_tags() {
for ok in ["myapp", "my-app", "my_app", "App123", "A", "z9_z"] {
HookSpec::builder(ok)
.command_program("x", [] as [&str; 0])
.try_build()
.expect("expected valid tag");
}
}
#[test]
fn try_build_rejects_missing_command() {
let err = HookSpec::builder("ok").try_build().unwrap_err();
assert!(
matches!(err, AgentConfigError::MissingSpecField { field, .. } if field == "command")
);
}
#[test]
fn build_panics_on_missing_command() {
let result = std::panic::catch_unwind(|| {
HookSpec::builder("ok").build();
});
assert!(result.is_err());
}
#[test]
fn builder_sets_all_fields() {
let spec = HookSpec::builder("myapp")
.command_program("run", ["--flag"])
.matcher(Matcher::Bash)
.event(Event::PostToolUse)
.rules("my rules")
.script(ScriptTemplate::Shell("set -e".into()))
.friendly_name("My App")
.build();
assert_eq!(spec.tag, "myapp");
assert_eq!(
spec.command,
HookCommand::Program {
program: "run".into(),
args: vec!["--flag".into()]
}
);
assert!(matches!(spec.matcher, Matcher::Bash));
assert!(matches!(spec.event, Event::PostToolUse));
assert!(spec.rules.is_some());
assert!(spec.script.is_some());
assert_eq!(spec.friendly_name.as_deref(), Some("My App"));
}
#[test]
fn builder_defaults() {
let spec = HookSpec::builder("myapp")
.command_program("run", [] as [&str; 0])
.build();
assert!(matches!(spec.matcher, Matcher::All));
assert!(matches!(spec.event, Event::PreToolUse));
assert!(spec.rules.is_none());
assert!(spec.script.is_none());
assert!(spec.friendly_name.is_none());
}
#[test]
fn program_command_renders_shell_safe_arguments() {
let command = HookCommand::program(
"my hook",
[
"repo path",
"semi;colon",
"$(not run)",
"`not run`",
"line\nbreak",
"quote's",
"",
],
);
assert_eq!(
command.render_shell(),
"'my hook' 'repo path' 'semi;colon' '$(not run)' '`not run`' 'line\nbreak' 'quote'\\''s' ''"
);
}
#[test]
fn raw_shell_command_is_explicitly_unchecked() {
let spec = HookSpec::builder("myapp")
.command_shell_unchecked("myapp hook \"$REPO\"")
.build();
assert_eq!(spec.command.render_shell(), "myapp hook \"$REPO\"");
}
#[test]
fn try_build_rejects_invalid_command_values() {
let empty_program = HookSpec::builder("myapp")
.command_program("", [] as [&str; 0])
.try_build()
.unwrap_err();
assert!(matches!(
empty_program,
AgentConfigError::InvalidCommand { .. }
));
let nul_arg = HookSpec::builder("myapp")
.command_program("myapp", ["bad\0arg"])
.try_build()
.unwrap_err();
assert!(matches!(nul_arg, AgentConfigError::InvalidCommand { .. }));
let empty_shell = HookSpec::builder("myapp")
.command_shell_unchecked(" ")
.try_build()
.unwrap_err();
assert!(matches!(
empty_shell,
AgentConfigError::InvalidCommand { .. }
));
}
#[test]
fn try_build_rejects_empty_exact_matcher() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.matcher(Matcher::Exact(String::new()))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_rejects_empty_anyof_matcher() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.matcher(Matcher::AnyOf(Vec::new()))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_rejects_empty_string_in_anyof() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.matcher(Matcher::AnyOf(vec!["Edit".into(), String::new()]))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_rejects_control_char_in_regex() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.matcher(Matcher::Regex("foo\u{0007}".into()))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_rejects_empty_custom_event() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.event(Event::Custom(String::new()))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_rejects_control_char_in_custom_event() {
let err = HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.event(Event::Custom("before\nShell".into()))
.try_build()
.unwrap_err();
assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
}
#[test]
fn try_build_accepts_valid_custom_event_and_matcher() {
HookSpec::builder("ok")
.command_program("x", [] as [&str; 0])
.event(Event::Custom("beforeShellExecution".into()))
.matcher(Matcher::AnyOf(vec!["Edit".into(), "Read".into()]))
.try_build()
.expect("valid");
}
}