klasp_core/
trigger_config.rs1use regex::Regex;
14use serde::{de, Deserialize, Serialize};
15
16use crate::error::{KlaspError, Result};
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
28#[serde(deny_unknown_fields)]
29pub struct UserTriggerConfig {
30 pub name: String,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub pattern: Option<String>,
38
39 #[serde(default)]
42 pub agents: Vec<String>,
43
44 #[serde(default)]
47 pub commands: Vec<String>,
48}
49
50#[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 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 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
119pub 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}