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, 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}