1use crate::error::AgentConfigError;
10
11use super::validate::{validate_identifier, IdentifierKind};
12
13mod builder;
14
15pub use builder::HookSpecBuilder;
16
17#[derive(Debug, Clone)]
22pub struct HookSpec {
23 pub tag: String,
28
29 pub command: HookCommand,
35
36 pub matcher: Matcher,
38
39 pub event: Event,
41
42 pub rules: Option<RulesBlock>,
45
46 pub script: Option<ScriptTemplate>,
50
51 pub friendly_name: Option<String>,
54}
55
56impl HookSpec {
57 pub fn builder(tag: impl Into<String>) -> HookSpecBuilder {
59 HookSpecBuilder {
60 tag: tag.into(),
61 command: None,
62 matcher: Matcher::All,
63 event: Event::PreToolUse,
64 rules: None,
65 script: None,
66 friendly_name: None,
67 }
68 }
69
70 pub(crate) fn validate_tag(tag: &str) -> Result<(), AgentConfigError> {
72 validate_identifier(tag, IdentifierKind::Tag)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum HookCommand {
87 Program {
89 program: String,
91 args: Vec<String>,
93 },
94 ShellUnchecked {
96 command: String,
98 },
99}
100
101impl HookCommand {
102 pub fn program<I, S>(program: impl Into<String>, args: I) -> Self
104 where
105 I: IntoIterator<Item = S>,
106 S: Into<String>,
107 {
108 Self::Program {
109 program: program.into(),
110 args: args.into_iter().map(Into::into).collect(),
111 }
112 }
113
114 pub fn shell_unchecked(command: impl Into<String>) -> Self {
116 Self::ShellUnchecked {
117 command: command.into(),
118 }
119 }
120
121 pub fn render_shell(&self) -> String {
136 match self {
137 Self::Program { program, args } => std::iter::once(program.as_str())
138 .chain(args.iter().map(String::as_str))
139 .map(shell_quote)
140 .collect::<Vec<_>>()
141 .join(" "),
142 Self::ShellUnchecked { command } => command.clone(),
143 }
144 }
145
146 pub(super) fn validate(&self) -> Result<(), AgentConfigError> {
147 match self {
148 Self::Program { program, args } => {
149 if program.is_empty() {
150 return Err(AgentConfigError::InvalidCommand {
151 reason: "program must not be empty",
152 });
153 }
154 validate_no_nul(program)?;
155 for arg in args {
156 validate_no_nul(arg)?;
157 }
158 }
159 Self::ShellUnchecked { command } => {
160 if command.trim().is_empty() {
161 return Err(AgentConfigError::InvalidCommand {
162 reason: "shell command must not be empty",
163 });
164 }
165 validate_no_nul(command)?;
166 }
167 }
168 Ok(())
169 }
170}
171
172fn validate_no_nul(value: &str) -> Result<(), AgentConfigError> {
173 if value.contains('\0') {
174 return Err(AgentConfigError::InvalidCommand {
175 reason: "command values must not contain NUL bytes",
176 });
177 }
178 Ok(())
179}
180
181#[derive(Copy, Clone)]
182pub(super) enum HookStringKind {
183 Matcher,
184 CustomEvent,
185}
186
187impl HookStringKind {
188 fn empty_reason(self) -> &'static str {
189 match self {
190 Self::Matcher => "matcher value must not be empty",
191 Self::CustomEvent => "custom event name must not be empty",
192 }
193 }
194
195 fn control_reason(self) -> &'static str {
196 match self {
197 Self::Matcher => "matcher value must not contain control characters",
198 Self::CustomEvent => "custom event name must not contain control characters",
199 }
200 }
201}
202
203pub(super) fn validate_hook_string(
204 value: &str,
205 kind: HookStringKind,
206) -> Result<(), AgentConfigError> {
207 if value.is_empty() {
208 return Err(AgentConfigError::InvalidTag {
209 tag: value.to_string(),
210 reason: kind.empty_reason(),
211 });
212 }
213 if value.chars().any(|c| (c as u32) < 0x20 || c == '\u{007F}') {
214 return Err(AgentConfigError::InvalidTag {
215 tag: value.to_string(),
216 reason: kind.control_reason(),
217 });
218 }
219 Ok(())
220}
221
222fn shell_quote(value: &str) -> String {
223 if value.is_empty() {
224 return "''".to_string();
225 }
226 if value
227 .bytes()
228 .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','))
229 {
230 return value.to_string();
231 }
232 format!("'{}'", value.replace('\'', "'\\''"))
233}
234
235#[derive(Debug, Clone)]
241#[non_exhaustive]
242pub enum Matcher {
243 All,
245 Bash,
248 Exact(String),
250 AnyOf(Vec<String>),
252 Regex(String),
254}
255
256#[derive(Debug, Clone)]
260#[non_exhaustive]
261pub enum Event {
262 PreToolUse,
265 PostToolUse,
267 Custom(String),
269}
270
271#[derive(Debug, Clone)]
274pub struct RulesBlock {
275 pub content: String,
277}
278
279#[derive(Debug, Clone)]
281#[non_exhaustive]
282pub enum ScriptTemplate {
283 Shell(String),
287 TypeScript(String),
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn try_build_rejects_empty_tag() {
297 let err = HookSpec::builder("")
298 .command_program("x", [] as [&str; 0])
299 .try_build()
300 .unwrap_err();
301 assert!(
302 matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason == "tag must not be empty")
303 );
304 }
305
306 #[test]
307 fn try_build_rejects_tag_with_spaces() {
308 let err = HookSpec::builder("not valid")
309 .command_program("x", [] as [&str; 0])
310 .try_build()
311 .unwrap_err();
312 assert!(
313 matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason.contains("ASCII"))
314 );
315 }
316
317 #[test]
318 fn try_build_rejects_tag_with_special_chars() {
319 for bad in ["tag/slash", "tag.dot", "tag!bang", "tag@at"] {
320 let err = HookSpec::builder(bad)
321 .command_program("x", [] as [&str; 0])
322 .try_build()
323 .unwrap_err();
324 assert!(
325 matches!(err, AgentConfigError::InvalidTag { .. }),
326 "expected InvalidTag for {bad:?}"
327 );
328 }
329 }
330
331 #[test]
332 fn try_build_accepts_valid_tags() {
333 for ok in ["myapp", "my-app", "my_app", "App123", "A", "z9_z"] {
334 HookSpec::builder(ok)
335 .command_program("x", [] as [&str; 0])
336 .try_build()
337 .expect("expected valid tag");
338 }
339 }
340
341 #[test]
342 fn try_build_rejects_missing_command() {
343 let err = HookSpec::builder("ok").try_build().unwrap_err();
344 assert!(
345 matches!(err, AgentConfigError::MissingSpecField { field, .. } if field == "command")
346 );
347 }
348
349 #[test]
350 fn build_panics_on_missing_command() {
351 let result = std::panic::catch_unwind(|| {
352 HookSpec::builder("ok").build();
353 });
354 assert!(result.is_err());
355 }
356
357 #[test]
358 fn builder_sets_all_fields() {
359 let spec = HookSpec::builder("myapp")
360 .command_program("run", ["--flag"])
361 .matcher(Matcher::Bash)
362 .event(Event::PostToolUse)
363 .rules("my rules")
364 .script(ScriptTemplate::Shell("set -e".into()))
365 .friendly_name("My App")
366 .build();
367
368 assert_eq!(spec.tag, "myapp");
369 assert_eq!(
370 spec.command,
371 HookCommand::Program {
372 program: "run".into(),
373 args: vec!["--flag".into()]
374 }
375 );
376 assert!(matches!(spec.matcher, Matcher::Bash));
377 assert!(matches!(spec.event, Event::PostToolUse));
378 assert!(spec.rules.is_some());
379 assert!(spec.script.is_some());
380 assert_eq!(spec.friendly_name.as_deref(), Some("My App"));
381 }
382
383 #[test]
384 fn builder_defaults() {
385 let spec = HookSpec::builder("myapp")
386 .command_program("run", [] as [&str; 0])
387 .build();
388
389 assert!(matches!(spec.matcher, Matcher::All));
390 assert!(matches!(spec.event, Event::PreToolUse));
391 assert!(spec.rules.is_none());
392 assert!(spec.script.is_none());
393 assert!(spec.friendly_name.is_none());
394 }
395
396 #[test]
397 fn program_command_renders_shell_safe_arguments() {
398 let command = HookCommand::program(
399 "my hook",
400 [
401 "repo path",
402 "semi;colon",
403 "$(not run)",
404 "`not run`",
405 "line\nbreak",
406 "quote's",
407 "",
408 ],
409 );
410 assert_eq!(
411 command.render_shell(),
412 "'my hook' 'repo path' 'semi;colon' '$(not run)' '`not run`' 'line\nbreak' 'quote'\\''s' ''"
413 );
414 }
415
416 #[test]
417 fn raw_shell_command_is_explicitly_unchecked() {
418 let spec = HookSpec::builder("myapp")
419 .command_shell_unchecked("myapp hook \"$REPO\"")
420 .build();
421 assert_eq!(spec.command.render_shell(), "myapp hook \"$REPO\"");
422 }
423
424 #[test]
425 fn try_build_rejects_invalid_command_values() {
426 let empty_program = HookSpec::builder("myapp")
427 .command_program("", [] as [&str; 0])
428 .try_build()
429 .unwrap_err();
430 assert!(matches!(
431 empty_program,
432 AgentConfigError::InvalidCommand { .. }
433 ));
434
435 let nul_arg = HookSpec::builder("myapp")
436 .command_program("myapp", ["bad\0arg"])
437 .try_build()
438 .unwrap_err();
439 assert!(matches!(nul_arg, AgentConfigError::InvalidCommand { .. }));
440
441 let empty_shell = HookSpec::builder("myapp")
442 .command_shell_unchecked(" ")
443 .try_build()
444 .unwrap_err();
445 assert!(matches!(
446 empty_shell,
447 AgentConfigError::InvalidCommand { .. }
448 ));
449 }
450
451 #[test]
452 fn try_build_rejects_empty_exact_matcher() {
453 let err = HookSpec::builder("ok")
454 .command_program("x", [] as [&str; 0])
455 .matcher(Matcher::Exact(String::new()))
456 .try_build()
457 .unwrap_err();
458 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
459 }
460
461 #[test]
462 fn try_build_rejects_empty_anyof_matcher() {
463 let err = HookSpec::builder("ok")
464 .command_program("x", [] as [&str; 0])
465 .matcher(Matcher::AnyOf(Vec::new()))
466 .try_build()
467 .unwrap_err();
468 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
469 }
470
471 #[test]
472 fn try_build_rejects_empty_string_in_anyof() {
473 let err = HookSpec::builder("ok")
474 .command_program("x", [] as [&str; 0])
475 .matcher(Matcher::AnyOf(vec!["Edit".into(), String::new()]))
476 .try_build()
477 .unwrap_err();
478 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
479 }
480
481 #[test]
482 fn try_build_rejects_control_char_in_regex() {
483 let err = HookSpec::builder("ok")
484 .command_program("x", [] as [&str; 0])
485 .matcher(Matcher::Regex("foo\u{0007}".into()))
486 .try_build()
487 .unwrap_err();
488 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
489 }
490
491 #[test]
492 fn try_build_rejects_empty_custom_event() {
493 let err = HookSpec::builder("ok")
494 .command_program("x", [] as [&str; 0])
495 .event(Event::Custom(String::new()))
496 .try_build()
497 .unwrap_err();
498 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
499 }
500
501 #[test]
502 fn try_build_rejects_control_char_in_custom_event() {
503 let err = HookSpec::builder("ok")
504 .command_program("x", [] as [&str; 0])
505 .event(Event::Custom("before\nShell".into()))
506 .try_build()
507 .unwrap_err();
508 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
509 }
510
511 #[test]
512 fn try_build_accepts_valid_custom_event_and_matcher() {
513 HookSpec::builder("ok")
514 .command_program("x", [] as [&str; 0])
515 .event(Event::Custom("beforeShellExecution".into()))
516 .matcher(Matcher::AnyOf(vec!["Edit".into(), "Read".into()]))
517 .try_build()
518 .expect("valid");
519 }
520}