use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MatchStrategy {
#[default]
Exact,
Glob,
Regex,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventPattern {
pub source: String,
pub pattern: String,
#[serde(default)]
pub strategy: MatchStrategy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutineEvent {
pub source: String,
pub topic: String,
#[serde(default)]
pub payload: Option<String>,
pub timestamp: String,
}
pub fn matches(pattern: &EventPattern, event: &RoutineEvent) -> bool {
if pattern.source != event.source {
return false;
}
match pattern.strategy {
MatchStrategy::Exact => pattern.pattern == event.topic,
MatchStrategy::Glob => glob_match(&pattern.pattern, &event.topic),
MatchStrategy::Regex => regex_match(&pattern.pattern, &event.topic),
}
}
pub fn matches_any(patterns: &[EventPattern], event: &RoutineEvent) -> bool {
patterns.iter().any(|p| matches(p, event))
}
fn glob_match(pattern: &str, text: &str) -> bool {
glob::Pattern::new(pattern).map_or(false, |g| g.matches(text))
}
fn regex_match(pattern: &str, text: &str) -> bool {
regex::Regex::new(pattern).map_or(false, |re| re.is_match(text))
}
#[cfg(test)]
mod tests {
use super::*;
fn event(source: &str, topic: &str) -> RoutineEvent {
RoutineEvent {
source: source.into(),
topic: topic.into(),
payload: None,
timestamp: "2026-03-24T00:00:00Z".into(),
}
}
#[test]
fn exact_match_works() {
let pat = EventPattern {
source: "webhook".into(),
pattern: "/api/deploy".into(),
strategy: MatchStrategy::Exact,
};
assert!(matches(&pat, &event("webhook", "/api/deploy")));
assert!(!matches(&pat, &event("webhook", "/api/deploy/staging")));
assert!(!matches(&pat, &event("channel", "/api/deploy")));
}
#[test]
fn glob_match_works() {
let pat = EventPattern {
source: "channel".into(),
pattern: "telegram-*".into(),
strategy: MatchStrategy::Glob,
};
assert!(matches(&pat, &event("channel", "telegram-main")));
assert!(matches(&pat, &event("channel", "telegram-alerts")));
assert!(!matches(&pat, &event("channel", "discord-main")));
}
#[test]
fn regex_match_works() {
let pat = EventPattern {
source: "system".into(),
pattern: r"^build\.(success|failure)$".into(),
strategy: MatchStrategy::Regex,
};
assert!(matches(&pat, &event("system", "build.success")));
assert!(matches(&pat, &event("system", "build.failure")));
assert!(!matches(&pat, &event("system", "build.pending")));
}
#[test]
fn matches_any_returns_true_on_first_hit() {
let patterns = vec![
EventPattern {
source: "webhook".into(),
pattern: "/deploy".into(),
strategy: MatchStrategy::Exact,
},
EventPattern {
source: "channel".into(),
pattern: "slack-*".into(),
strategy: MatchStrategy::Glob,
},
];
assert!(matches_any(&patterns, &event("channel", "slack-general")));
assert!(!matches_any(
&patterns,
&event("channel", "discord-general")
));
}
#[test]
fn source_mismatch_never_matches() {
let pat = EventPattern {
source: "cron".into(),
pattern: "*".into(),
strategy: MatchStrategy::Glob,
};
assert!(!matches(&pat, &event("webhook", "anything")));
}
#[test]
fn invalid_regex_returns_false() {
let pat = EventPattern {
source: "system".into(),
pattern: "[invalid".into(),
strategy: MatchStrategy::Regex,
};
assert!(!matches(&pat, &event("system", "anything")));
}
#[test]
fn invalid_glob_returns_false() {
let pat = EventPattern {
source: "system".into(),
pattern: "[!invalid".into(),
strategy: MatchStrategy::Glob,
};
assert!(!matches(&pat, &event("system", "anything")));
}
#[test]
fn default_strategy_is_exact() {
assert_eq!(MatchStrategy::default(), MatchStrategy::Exact);
}
}