use regex::Regex;
use serde::{de, Deserialize, Serialize};
use crate::error::{KlaspError, Result};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct UserTriggerConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub commands: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct UserTrigger {
pub name: String,
pub pattern: Option<Regex>,
pub agents: Vec<String>,
pub commands: Vec<String>,
}
impl UserTrigger {
pub fn validate(cfg: &UserTriggerConfig) -> Result<Self> {
let has_pattern = cfg.pattern.is_some();
let has_commands = !cfg.commands.is_empty();
if !has_pattern && !has_commands {
return Err(KlaspError::ConfigParse(
<toml::de::Error as de::Error>::custom(format!(
"trigger {:?}: at least one of `pattern` or `commands` is required",
cfg.name
)),
));
}
let pattern = match &cfg.pattern {
Some(p) => Some(Regex::new(p).map_err(|e| {
KlaspError::ConfigParse(<toml::de::Error as de::Error>::custom(format!(
"trigger {:?}: invalid regex {:?}: {e}",
cfg.name, p
)))
})?),
None => None,
};
Ok(UserTrigger {
name: cfg.name.clone(),
pattern,
agents: cfg.agents.clone(),
commands: cfg.commands.clone(),
})
}
pub fn matches(&self, cmd: &str, agent: &str) -> bool {
if !self.agents.is_empty() && !self.agents.iter().any(|a| a == agent) {
return false;
}
let pattern_match = self.pattern.as_ref().is_some_and(|re| re.is_match(cmd));
let command_match = self.commands.iter().any(|c| c == cmd);
pattern_match || command_match
}
}
pub fn validate_user_triggers(cfgs: &[UserTriggerConfig]) -> Result<Vec<UserTrigger>> {
cfgs.iter().map(UserTrigger::validate).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(
name: &str,
pattern: Option<&str>,
agents: &[&str],
commands: &[&str],
) -> UserTriggerConfig {
UserTriggerConfig {
name: name.into(),
pattern: pattern.map(String::from),
agents: agents.iter().map(|s| s.to_string()).collect(),
commands: commands.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn pattern_only_trigger_validates() {
let t = UserTrigger::validate(&cfg("t", Some("^jj"), &[], &[])).unwrap();
assert!(t.pattern.is_some());
}
#[test]
fn commands_only_trigger_validates() {
let t = UserTrigger::validate(&cfg("t", None, &[], &["gh pr create"])).unwrap();
assert!(t.pattern.is_none());
assert_eq!(t.commands, vec!["gh pr create"]);
}
#[test]
fn no_pattern_no_commands_is_error() {
let err = UserTrigger::validate(&cfg("empty", None, &[], &[])).unwrap_err();
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn invalid_regex_is_error() {
let err = UserTrigger::validate(&cfg("bad", Some("[invalid"), &[], &[])).unwrap_err();
assert!(matches!(err, KlaspError::ConfigParse(_)));
}
#[test]
fn pattern_matches_command() {
let t = UserTrigger::validate(&cfg("t", Some("^jj git push"), &[], &[])).unwrap();
assert!(t.matches("jj git push -m main", "claude_code"));
}
#[test]
fn pattern_does_not_match_unrelated_command() {
let t = UserTrigger::validate(&cfg("t", Some("^jj git push"), &[], &[])).unwrap();
assert!(!t.matches("git push origin main", "claude_code"));
}
#[test]
fn commands_allowlist_exact_match() {
let t = UserTrigger::validate(&cfg("t", None, &[], &["gh pr create"])).unwrap();
assert!(t.matches("gh pr create", "claude_code"));
assert!(!t.matches("gh pr create --draft", "claude_code"));
}
#[test]
fn agents_filter_blocks_unlisted_agent() {
let t = UserTrigger::validate(&cfg("t", Some("^jj"), &["claude_code"], &[])).unwrap();
assert!(t.matches("jj git push", "claude_code"));
assert!(!t.matches("jj git push", "codex"));
}
#[test]
fn empty_agents_matches_any_agent() {
let t = UserTrigger::validate(&cfg("t", Some("^jj"), &[], &[])).unwrap();
assert!(t.matches("jj git push", "codex"));
assert!(t.matches("jj git push", "claude_code"));
}
}