use super::model::{KeywordPattern, LlmPattern, SemanticPattern};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Outcome {
Match,
NoMatch,
Skipped,
}
impl Outcome {
#[must_use]
pub fn fired(self) -> bool {
matches!(self, Self::Match)
}
}
pub trait KeywordEvaluator {
fn eval(&self, var: &str, pattern: &KeywordPattern, body: &str) -> Outcome;
}
pub trait SemanticEvaluator {
fn eval(&self, var: &str, pattern: &SemanticPattern, body: &str) -> Outcome;
}
pub trait LlmEvaluator {
fn eval(&self, var: &str, pattern: &LlmPattern, body: &str) -> Outcome;
}
#[derive(Debug, Default)]
pub struct NativeKeywordEvaluator {
cache: std::sync::Mutex<std::collections::HashMap<String, regex::Regex>>,
}
impl NativeKeywordEvaluator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn compiled_regex(&self, pattern: &str, case_sensitive: bool) -> Option<regex::Regex> {
let cache_key = format!("{}|{}", if case_sensitive { "1" } else { "0" }, pattern);
let mut cache = self.cache.lock().ok()?;
if let Some(rx) = cache.get(&cache_key) {
return Some(rx.clone());
}
let body = if case_sensitive {
pattern.to_string()
} else {
if pattern.starts_with("(?") {
pattern.to_string()
} else {
format!("(?i){pattern}")
}
};
let rx = regex::Regex::new(&body).ok()?;
cache.insert(cache_key, rx.clone());
Some(rx)
}
}
impl KeywordEvaluator for NativeKeywordEvaluator {
fn eval(&self, _var: &str, pattern: &KeywordPattern, body: &str) -> Outcome {
if pattern.is_regex {
match self.compiled_regex(&pattern.pattern, pattern.case_sensitive) {
Some(rx) => {
if rx.is_match(body) {
Outcome::Match
} else {
Outcome::NoMatch
}
}
None => Outcome::NoMatch,
}
} else if pattern.case_sensitive {
if body.contains(&pattern.pattern) {
Outcome::Match
} else {
Outcome::NoMatch
}
} else if body
.to_ascii_lowercase()
.contains(&pattern.pattern.to_ascii_lowercase())
{
Outcome::Match
} else {
Outcome::NoMatch
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NotYetWiredSemantic;
impl SemanticEvaluator for NotYetWiredSemantic {
fn eval(&self, _var: &str, _pattern: &SemanticPattern, _body: &str) -> Outcome {
Outcome::Skipped
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NotYetWiredLlm;
impl LlmEvaluator for NotYetWiredLlm {
fn eval(&self, _var: &str, _pattern: &LlmPattern, _body: &str) -> Outcome {
Outcome::Skipped
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kw(pattern: &str, is_regex: bool, case: bool) -> KeywordPattern {
KeywordPattern {
pattern: pattern.to_string(),
is_regex,
case_sensitive: case,
}
}
#[test]
fn literal_keyword_is_case_insensitive_by_default() {
let ev = NativeKeywordEvaluator::new();
let p = kw("Ignore Previous", false, false);
assert_eq!(
ev.eval("$x", &p, "please ignore previous instructions"),
Outcome::Match
);
}
#[test]
fn literal_keyword_respects_case_sensitive() {
let ev = NativeKeywordEvaluator::new();
let p = kw("HACK", false, true);
assert_eq!(ev.eval("$x", &p, "i want to HACK things"), Outcome::Match);
assert_eq!(ev.eval("$x", &p, "i want to hack things"), Outcome::NoMatch);
}
#[test]
fn regex_keyword_matches_with_inline_flags() {
let ev = NativeKeywordEvaluator::new();
let p = kw(r"\bfoo\d+\b", true, false);
assert_eq!(ev.eval("$x", &p, "matches FOO42 here"), Outcome::Match);
assert_eq!(ev.eval("$x", &p, "no number"), Outcome::NoMatch);
}
#[test]
fn invalid_regex_returns_nomatch_not_panic() {
let ev = NativeKeywordEvaluator::new();
let p = kw(r"(unclosed", true, false);
assert_eq!(ev.eval("$x", &p, "anything"), Outcome::NoMatch);
}
#[test]
fn stub_evaluators_return_skipped() {
let s = NotYetWiredSemantic;
let l = NotYetWiredLlm;
let sem = SemanticPattern {
pattern: "x".into(),
threshold: 0.5,
};
let llm = LlmPattern {
pattern: "y".into(),
threshold: 0.5,
};
assert_eq!(s.eval("$x", &sem, "body"), Outcome::Skipped);
assert_eq!(l.eval("$x", &llm, "body"), Outcome::Skipped);
assert!(!Outcome::Skipped.fired());
}
}