Skip to main content

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, HookRuntimeOptions, HookSpec, HookStringKind,
7    Matcher, RulesBlock, 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    pub(super) options: HookRuntimeOptions,
21}
22
23impl HookSpecBuilder {
24    /// Set the program and arguments the harness should execute when the hook
25    /// fires.
26    ///
27    /// Integrations that only accept shell strings render this command with
28    /// POSIX shell quoting, so arguments containing spaces or shell
29    /// metacharacters remain arguments instead of becoming shell syntax.
30    pub fn command_program<I, S>(mut self, program: impl Into<String>, args: I) -> Self
31    where
32        I: IntoIterator<Item = S>,
33        S: Into<String>,
34    {
35        self.command = Some(HookCommand::Program {
36            program: program.into(),
37            args: args.into_iter().map(Into::into).collect(),
38        });
39        self
40    }
41
42    /// Set an unchecked raw shell command.
43    ///
44    /// This is intentionally explicit: the command string is passed through as
45    /// shell syntax for harnesses and generated scripts. Use this only when the
46    /// full command is trusted and already sanitized.
47    pub fn command_shell_unchecked(mut self, command: impl Into<String>) -> Self {
48        self.command = Some(HookCommand::ShellUnchecked {
49            command: command.into(),
50        });
51        self
52    }
53
54    /// Set the tool-call matcher.
55    pub fn matcher(mut self, m: Matcher) -> Self {
56        self.matcher = m;
57        self
58    }
59
60    /// Set the lifecycle event to attach to.
61    pub fn event(mut self, e: Event) -> Self {
62        self.event = e;
63        self
64    }
65
66    /// Attach a markdown rules block to be injected into the harness's memory
67    /// file.
68    pub fn rules(mut self, content: impl Into<String>) -> Self {
69        self.rules = Some(RulesBlock {
70            content: content.into(),
71        });
72        self
73    }
74
75    /// Attach a script template (shell or TS) for harnesses that need one.
76    pub fn script(mut self, script: ScriptTemplate) -> Self {
77        self.script = Some(script);
78        self
79    }
80
81    /// Set a human-friendly display name shown in install reports.
82    pub fn friendly_name(mut self, name: impl Into<String>) -> Self {
83        self.friendly_name = Some(name.into());
84        self
85    }
86
87    /// Set the hook execution timeout in seconds.
88    pub fn timeout_seconds(mut self, timeout: u64) -> Self {
89        self.options.timeout_seconds = Some(timeout);
90        self
91    }
92
93    /// Set the status message to show during execution.
94    pub fn status_message(mut self, msg: impl Into<String>) -> Self {
95        self.options.status_message = Some(msg.into());
96        self
97    }
98
99    /// Set whether the hook is run asynchronously.
100    pub fn async_run(mut self, val: bool) -> Self {
101        self.options.async_run = Some(val);
102        self
103    }
104
105    /// Set the custom shell to execute the command.
106    pub fn shell(mut self, sh: impl Into<String>) -> Self {
107        self.options.shell = Some(sh.into());
108        self
109    }
110
111    /// Set the Windows-specific command override.
112    pub fn windows_command(mut self, cmd: HookCommand) -> Self {
113        self.options.windows_command = Some(cmd);
114        self
115    }
116
117    /// Set whether Codex should install this hook inline in `config.toml`.
118    pub fn codex_inline_toml(mut self, val: bool) -> Self {
119        self.options.codex_inline_toml = Some(val);
120        self
121    }
122
123    /// Finalize the spec, panicking on missing or invalid fields.
124    ///
125    /// Convenience wrapper around [`try_build()`](Self::try_build) for tests
126    /// and examples. Production code should prefer [`try_build()`](Self::try_build)
127    /// to propagate errors instead of panicking.
128    ///
129    /// # Panics
130    ///
131    /// Panics if `command` was never set.
132    pub fn build(self) -> HookSpec {
133        self.try_build().expect("HookSpec missing `command`")
134    }
135
136    /// Finalize the spec, returning [`Result`] on missing or invalid fields.
137    ///
138    /// This is the recommended way to build a spec in production code.
139    /// See [crate-level documentation](crate#production-usage) for a full example.
140    ///
141    /// # Errors
142    ///
143    /// - [`AgentConfigError::InvalidTag`] when `tag`, the matcher string, or
144    ///   any custom event name fails identifier or hook-string validation.
145    /// - [`AgentConfigError::MissingSpecField`] (`field = "command"`) when
146    ///   neither [`HookSpecBuilder::command_program`] nor
147    ///   [`HookSpecBuilder::command_shell_unchecked`] was called.
148    /// - [`AgentConfigError::InvalidTag`] from `command.validate()` when the
149    ///   command string is empty or contains control characters.
150    pub fn try_build(self) -> Result<HookSpec, AgentConfigError> {
151        HookSpec::validate_tag(&self.tag)?;
152        let command = self.command.ok_or(AgentConfigError::MissingSpecField {
153            id: "<builder>",
154            field: "command",
155        })?;
156        command.validate()?;
157        match &self.matcher {
158            Matcher::All | Matcher::Bash => {}
159            Matcher::Exact(s) | Matcher::Regex(s) => {
160                validate_hook_string(s, HookStringKind::Matcher)?;
161            }
162            Matcher::AnyOf(list) => {
163                if list.is_empty() {
164                    return Err(AgentConfigError::InvalidTag {
165                        tag: String::new(),
166                        reason: "matcher AnyOf must contain at least one entry",
167                    });
168                }
169                for s in list {
170                    validate_hook_string(s, HookStringKind::Matcher)?;
171                }
172            }
173        }
174        if let Event::Custom(name) = &self.event {
175            validate_hook_string(name, HookStringKind::CustomEvent)?;
176        }
177        Ok(HookSpec {
178            tag: self.tag,
179            command,
180            matcher: self.matcher,
181            event: self.event,
182            rules: self.rules,
183            script: self.script,
184            friendly_name: self.friendly_name,
185            options: self.options,
186        })
187    }
188}