agent_config/spec/hook/builder.rs
1//! Fluent builder for [`HookSpec`].
2
3use crate::error::AgentConfigError;
4
5use super::{
6 validate_hook_string, Event, HookCommand, HookSpec, HookStringKind, Matcher, RulesBlock,
7 ScriptTemplate,
8};
9
10/// Builder for [`HookSpec`].
11#[derive(Debug, Clone)]
12pub struct HookSpecBuilder {
13 pub(super) tag: String,
14 pub(super) command: Option<HookCommand>,
15 pub(super) matcher: Matcher,
16 pub(super) event: Event,
17 pub(super) rules: Option<RulesBlock>,
18 pub(super) script: Option<ScriptTemplate>,
19 pub(super) friendly_name: Option<String>,
20}
21
22impl HookSpecBuilder {
23 /// Set the program and arguments the harness should execute when the hook
24 /// fires.
25 ///
26 /// Integrations that only accept shell strings render this command with
27 /// POSIX shell quoting, so arguments containing spaces or shell
28 /// metacharacters remain arguments instead of becoming shell syntax.
29 pub fn command_program<I, S>(mut self, program: impl Into<String>, args: I) -> Self
30 where
31 I: IntoIterator<Item = S>,
32 S: Into<String>,
33 {
34 self.command = Some(HookCommand::Program {
35 program: program.into(),
36 args: args.into_iter().map(Into::into).collect(),
37 });
38 self
39 }
40
41 /// Set an unchecked raw shell command.
42 ///
43 /// This is intentionally explicit: the command string is passed through as
44 /// shell syntax for harnesses and generated scripts. Use this only when the
45 /// full command is trusted and already sanitized.
46 pub fn command_shell_unchecked(mut self, command: impl Into<String>) -> Self {
47 self.command = Some(HookCommand::ShellUnchecked {
48 command: command.into(),
49 });
50 self
51 }
52
53 /// Set the tool-call matcher.
54 pub fn matcher(mut self, m: Matcher) -> Self {
55 self.matcher = m;
56 self
57 }
58
59 /// Set the lifecycle event to attach to.
60 pub fn event(mut self, e: Event) -> Self {
61 self.event = e;
62 self
63 }
64
65 /// Attach a markdown rules block to be injected into the harness's memory
66 /// file.
67 pub fn rules(mut self, content: impl Into<String>) -> Self {
68 self.rules = Some(RulesBlock {
69 content: content.into(),
70 });
71 self
72 }
73
74 /// Attach a script template (shell or TS) for harnesses that need one.
75 pub fn script(mut self, script: ScriptTemplate) -> Self {
76 self.script = Some(script);
77 self
78 }
79
80 /// Set a human-friendly display name shown in install reports.
81 pub fn friendly_name(mut self, name: impl Into<String>) -> Self {
82 self.friendly_name = Some(name.into());
83 self
84 }
85
86 /// Finalize the spec, panicking on missing or invalid fields.
87 ///
88 /// Convenience wrapper around [`try_build()`](Self::try_build) for tests
89 /// and examples. Production code should prefer [`try_build()`](Self::try_build)
90 /// to propagate errors instead of panicking.
91 ///
92 /// # Panics
93 ///
94 /// Panics if `command` was never set.
95 pub fn build(self) -> HookSpec {
96 self.try_build().expect("HookSpec missing `command`")
97 }
98
99 /// Finalize the spec, returning [`Result`] on missing or invalid fields.
100 ///
101 /// This is the recommended way to build a spec in production code.
102 /// See [crate-level documentation](crate#production-usage) for a full example.
103 ///
104 /// # Errors
105 ///
106 /// - [`AgentConfigError::InvalidTag`] when `tag`, the matcher string, or
107 /// any custom event name fails identifier or hook-string validation.
108 /// - [`AgentConfigError::MissingSpecField`] (`field = "command"`) when
109 /// neither [`HookSpecBuilder::command_program`] nor
110 /// [`HookSpecBuilder::command_shell_unchecked`] was called.
111 /// - [`AgentConfigError::InvalidTag`] from `command.validate()` when the
112 /// command string is empty or contains control characters.
113 pub fn try_build(self) -> Result<HookSpec, AgentConfigError> {
114 HookSpec::validate_tag(&self.tag)?;
115 let command = self.command.ok_or(AgentConfigError::MissingSpecField {
116 id: "<builder>",
117 field: "command",
118 })?;
119 command.validate()?;
120 match &self.matcher {
121 Matcher::All | Matcher::Bash => {}
122 Matcher::Exact(s) | Matcher::Regex(s) => {
123 validate_hook_string(s, HookStringKind::Matcher)?;
124 }
125 Matcher::AnyOf(list) => {
126 if list.is_empty() {
127 return Err(AgentConfigError::InvalidTag {
128 tag: String::new(),
129 reason: "matcher AnyOf must contain at least one entry",
130 });
131 }
132 for s in list {
133 validate_hook_string(s, HookStringKind::Matcher)?;
134 }
135 }
136 }
137 if let Event::Custom(name) = &self.event {
138 validate_hook_string(name, HookStringKind::CustomEvent)?;
139 }
140 Ok(HookSpec {
141 tag: self.tag,
142 command,
143 matcher: self.matcher,
144 event: self.event,
145 rules: self.rules,
146 script: self.script,
147 friendly_name: self.friendly_name,
148 })
149 }
150}