Skip to main content

klasp_core/
trigger_config.rs

1//! User-configurable `[[trigger]]` blocks parsed from `klasp.toml`.
2//!
3//! Design: issue #45. User triggers extend the built-in commit/push regex
4//! (see `trigger.rs`) — they add new matching rules without replacing the
5//! built-ins. A command matched by any user trigger fires the gate just
6//! as a built-in match would.
7//!
8//! Validation rules (enforced at config-load time, not at match time):
9//! - At least one of `pattern` or `commands` must be present.
10//! - `pattern` must be a valid Rust regex (compiled eagerly to catch errors early).
11//! - `agents` is optional — empty means "fire for all agents".
12
13use regex::Regex;
14use serde::{de, Deserialize, Serialize};
15
16use crate::error::{KlaspError, Result};
17
18/// A single user-defined `[[trigger]]` block from `klasp.toml`.
19///
20/// ```toml
21/// [[trigger]]
22/// name = "jj-push"
23/// pattern = "^jj git push"
24/// agents = ["claude_code"]
25/// commands = ["jj git push", "jj git push -m main"]
26/// ```
27#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(deny_unknown_fields)]
29pub struct UserTriggerConfig {
30    /// Human-readable name for error messages and diagnostics.
31    pub name: String,
32
33    /// Optional regex matched against the full tool-input command string.
34    /// Must compile as a Rust regex. At least one of `pattern` / `commands`
35    /// is required.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub pattern: Option<String>,
38
39    /// Restrict this trigger to specific agents (e.g. `["claude_code"]`).
40    /// Empty or absent means the trigger fires for all agents.
41    #[serde(default)]
42    pub agents: Vec<String>,
43
44    /// Exact command strings that fire this trigger.  Matched after `pattern`
45    /// — a command that matches either fires the gate.
46    #[serde(default)]
47    pub commands: Vec<String>,
48}
49
50/// A compiled, validated user trigger ready for matching.
51///
52/// Constructed from [`UserTriggerConfig`] via [`UserTrigger::validate`].
53/// `Regex` is `Clone` (cheap, internally `Arc`), so cloning a `UserTrigger`
54/// is cheap; the regex's compiled state is shared rather than re-compiled.
55#[derive(Debug, Clone)]
56pub struct UserTrigger {
57    pub name: String,
58    pub pattern: Option<Regex>,
59    pub agents: Vec<String>,
60    pub commands: Vec<String>,
61}
62
63impl UserTrigger {
64    /// Validate and compile a [`UserTriggerConfig`] into a [`UserTrigger`].
65    ///
66    /// Returns `KlaspError::ConfigParse` if:
67    /// - Both `pattern` and `commands` are absent/empty (at least one required).
68    /// - `pattern` is present but is not a valid regex.
69    pub fn validate(cfg: &UserTriggerConfig) -> Result<Self> {
70        let has_pattern = cfg.pattern.is_some();
71        let has_commands = !cfg.commands.is_empty();
72
73        if !has_pattern && !has_commands {
74            return Err(KlaspError::ConfigParse(
75                <toml::de::Error as de::Error>::custom(format!(
76                    "trigger {:?}: at least one of `pattern` or `commands` is required",
77                    cfg.name
78                )),
79            ));
80        }
81
82        let pattern = match &cfg.pattern {
83            Some(p) => Some(Regex::new(p).map_err(|e| {
84                KlaspError::ConfigParse(<toml::de::Error as de::Error>::custom(format!(
85                    "trigger {:?}: invalid regex {:?}: {e}",
86                    cfg.name, p
87                )))
88            })?),
89            None => None,
90        };
91
92        Ok(UserTrigger {
93            name: cfg.name.clone(),
94            pattern,
95            agents: cfg.agents.clone(),
96            commands: cfg.commands.clone(),
97        })
98    }
99
100    /// Returns `true` if this trigger matches `cmd` for the given `agent`.
101    ///
102    /// Matching logic:
103    ///
104    /// 1. If `agents` is non-empty, `agent` must be listed.
105    /// 2. If `pattern` is set, it is tested against the full `cmd`.
106    /// 3. If `commands` is non-empty, `cmd` is tested for an exact match.
107    ///
108    /// A `pattern` or `commands` match is sufficient — they are OR'd.
109    pub fn matches(&self, cmd: &str, agent: &str) -> bool {
110        if !self.agents.is_empty() && !self.agents.iter().any(|a| a == agent) {
111            return false;
112        }
113        let pattern_match = self.pattern.as_ref().is_some_and(|re| re.is_match(cmd));
114        let command_match = self.commands.iter().any(|c| c == cmd);
115        pattern_match || command_match
116    }
117}
118
119/// Validate all `[[trigger]]` entries from config, returning compiled triggers.
120///
121/// Called once during config load so bad regexes fail early rather than
122/// silently at gate-run time. The returned `Vec` is in the same order as
123/// the input slice.
124pub fn validate_user_triggers(cfgs: &[UserTriggerConfig]) -> Result<Vec<UserTrigger>> {
125    cfgs.iter().map(UserTrigger::validate).collect()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn cfg(
133        name: &str,
134        pattern: Option<&str>,
135        agents: &[&str],
136        commands: &[&str],
137    ) -> UserTriggerConfig {
138        UserTriggerConfig {
139            name: name.into(),
140            pattern: pattern.map(String::from),
141            agents: agents.iter().map(|s| s.to_string()).collect(),
142            commands: commands.iter().map(|s| s.to_string()).collect(),
143        }
144    }
145
146    #[test]
147    fn pattern_only_trigger_validates() {
148        let t = UserTrigger::validate(&cfg("t", Some("^jj"), &[], &[])).unwrap();
149        assert!(t.pattern.is_some());
150    }
151
152    #[test]
153    fn commands_only_trigger_validates() {
154        let t = UserTrigger::validate(&cfg("t", None, &[], &["gh pr create"])).unwrap();
155        assert!(t.pattern.is_none());
156        assert_eq!(t.commands, vec!["gh pr create"]);
157    }
158
159    #[test]
160    fn no_pattern_no_commands_is_error() {
161        let err = UserTrigger::validate(&cfg("empty", None, &[], &[])).unwrap_err();
162        assert!(matches!(err, KlaspError::ConfigParse(_)));
163    }
164
165    #[test]
166    fn invalid_regex_is_error() {
167        let err = UserTrigger::validate(&cfg("bad", Some("[invalid"), &[], &[])).unwrap_err();
168        assert!(matches!(err, KlaspError::ConfigParse(_)));
169    }
170
171    #[test]
172    fn pattern_matches_command() {
173        let t = UserTrigger::validate(&cfg("t", Some("^jj git push"), &[], &[])).unwrap();
174        assert!(t.matches("jj git push -m main", "claude_code"));
175    }
176
177    #[test]
178    fn pattern_does_not_match_unrelated_command() {
179        let t = UserTrigger::validate(&cfg("t", Some("^jj git push"), &[], &[])).unwrap();
180        assert!(!t.matches("git push origin main", "claude_code"));
181    }
182
183    #[test]
184    fn commands_allowlist_exact_match() {
185        let t = UserTrigger::validate(&cfg("t", None, &[], &["gh pr create"])).unwrap();
186        assert!(t.matches("gh pr create", "claude_code"));
187        assert!(!t.matches("gh pr create --draft", "claude_code"));
188    }
189
190    #[test]
191    fn agents_filter_blocks_unlisted_agent() {
192        let t = UserTrigger::validate(&cfg("t", Some("^jj"), &["claude_code"], &[])).unwrap();
193        assert!(t.matches("jj git push", "claude_code"));
194        assert!(!t.matches("jj git push", "codex"));
195    }
196
197    #[test]
198    fn empty_agents_matches_any_agent() {
199        let t = UserTrigger::validate(&cfg("t", Some("^jj"), &[], &[])).unwrap();
200        assert!(t.matches("jj git push", "codex"));
201        assert!(t.matches("jj git push", "claude_code"));
202    }
203}