use globset::{Glob, GlobBuilder, GlobMatcher};
use regex::Regex;
use thiserror::Error;
#[derive(Debug, Clone)]
pub enum CompiledMatcher {
Exact(String),
Glob(GlobMatcher),
Regex(Regex),
}
#[derive(Debug, Error)]
pub enum MatcherCompileError {
#[error("invalid regex pattern '{pattern}': {source}")]
Regex {
pattern: String,
#[source]
source: regex::Error,
},
#[error("invalid glob pattern '{pattern}': {source}")]
Glob {
pattern: String,
#[source]
source: globset::Error,
},
}
impl CompiledMatcher {
pub fn compile(pattern: &str) -> Result<Self, MatcherCompileError> {
let trimmed = pattern.trim();
if trimmed.is_empty() {
return Ok(Self::Exact(String::new()));
}
if looks_like_regex(trimmed) {
let regex = Regex::new(trimmed).map_err(|source| MatcherCompileError::Regex {
pattern: trimmed.to_owned(),
source,
})?;
return Ok(Self::Regex(regex));
}
if looks_like_glob(trimmed) {
return Self::compile_glob(trimmed);
}
Ok(Self::Exact(trimmed.to_owned()))
}
pub fn compile_regex(pattern: &str) -> Result<Self, MatcherCompileError> {
let trimmed = pattern.trim();
let regex = Regex::new(trimmed).map_err(|source| MatcherCompileError::Regex {
pattern: trimmed.to_owned(),
source,
})?;
Ok(Self::Regex(regex))
}
pub fn compile_glob(pattern: &str) -> Result<Self, MatcherCompileError> {
let trimmed = pattern.trim();
if trimmed == "*" {
let glob = Glob::new("*").map_err(|source| MatcherCompileError::Glob {
pattern: trimmed.to_owned(),
source,
})?;
return Ok(Self::Glob(glob.compile_matcher()));
}
let rewritten = rewrite_glob_for_subtree(trimmed);
let glob = GlobBuilder::new(&rewritten)
.case_insensitive(true)
.literal_separator(false)
.build()
.map_err(|source| MatcherCompileError::Glob {
pattern: trimmed.to_owned(),
source,
})?;
Ok(Self::Glob(glob.compile_matcher()))
}
pub fn is_match(&self, input: &str) -> bool {
match self {
Self::Exact(literal) => literal.eq_ignore_ascii_case(input),
Self::Glob(matcher) => matcher.is_match(input),
Self::Regex(regex) => regex.is_match(input),
}
}
}
fn rewrite_glob_for_subtree(pattern: &str) -> String {
if let Some(rest) = pattern.strip_prefix("*.") {
format!("**.{rest}")
} else {
pattern.to_owned()
}
}
fn looks_like_regex(pattern: &str) -> bool {
pattern.chars().any(|ch| {
matches!(
ch,
'[' | ']' | '(' | ')' | '{' | '}' | '+' | '^' | '$' | '\\' | '|'
)
})
}
fn looks_like_glob(pattern: &str) -> bool {
pattern.chars().any(|ch| matches!(ch, '*' | '?'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn review_hook_runtime_ac3_1_glob_matches_subtree() {
let matcher = CompiledMatcher::compile_glob("*.example.com").expect("compile");
assert!(matcher.is_match("api.example.com"));
assert!(matcher.is_match("api.prod.example.com"));
assert!(matcher.is_match("deeply.nested.example.com"));
}
#[test]
fn review_hook_runtime_ac3_2_bare_star_matches_anything() {
let matcher = CompiledMatcher::compile_glob("*").expect("compile");
assert!(matcher.is_match(""));
assert!(matcher.is_match("literally anything"));
assert!(matcher.is_match("api.example.com"));
}
#[test]
fn review_hook_runtime_ac3_3_literal_matches_only_exact() {
let matcher = CompiledMatcher::compile("api.example.com").expect("compile");
assert!(matcher.is_match("api.example.com"));
assert!(!matcher.is_match("api2.example.com"));
assert!(!matcher.is_match("api.example.com.evil.com"));
assert!(matcher.is_match("API.Example.Com"));
}
#[test]
fn review_hook_runtime_ac3_4_invalid_regex_rejected_at_compile() {
let error = CompiledMatcher::compile_regex("(").expect_err("invalid regex must reject");
assert!(matches!(error, MatcherCompileError::Regex { .. }));
}
#[test]
fn review_hook_runtime_ac3_6_glob_parity_with_legacy_matcher() {
let pairs: &[(&str, &str, bool)] = &[
("git *", "git status", true),
("git *", "cargo test", false),
("shell", "Shell", true),
("*", "anything", true),
("read*", "readtokens", true),
("read*", "write", false),
];
for (pattern, candidate, expected) in pairs {
let matcher = CompiledMatcher::compile(pattern).expect("compile");
assert_eq!(
matcher.is_match(candidate),
*expected,
"pattern {pattern} vs {candidate}",
);
}
}
}