1use globset::{Glob, GlobBuilder, GlobMatcher};
13use regex::Regex;
14use thiserror::Error;
15
16#[derive(Debug, Clone)]
18pub enum CompiledMatcher {
19 Exact(String),
21 Glob(GlobMatcher),
23 Regex(Regex),
25}
26
27#[derive(Debug, Error)]
28pub enum MatcherCompileError {
29 #[error("invalid regex pattern '{pattern}': {source}")]
30 Regex {
31 pattern: String,
32 #[source]
33 source: regex::Error,
34 },
35 #[error("invalid glob pattern '{pattern}': {source}")]
36 Glob {
37 pattern: String,
38 #[source]
39 source: globset::Error,
40 },
41}
42
43impl CompiledMatcher {
44 pub fn compile(pattern: &str) -> Result<Self, MatcherCompileError> {
49 let trimmed = pattern.trim();
50 if trimmed.is_empty() {
51 return Ok(Self::Exact(String::new()));
55 }
56 if looks_like_regex(trimmed) {
57 let regex = Regex::new(trimmed).map_err(|source| MatcherCompileError::Regex {
58 pattern: trimmed.to_owned(),
59 source,
60 })?;
61 return Ok(Self::Regex(regex));
62 }
63 if looks_like_glob(trimmed) {
64 return Self::compile_glob(trimmed);
65 }
66 Ok(Self::Exact(trimmed.to_owned()))
67 }
68
69 pub fn compile_regex(pattern: &str) -> Result<Self, MatcherCompileError> {
73 let trimmed = pattern.trim();
74 let regex = Regex::new(trimmed).map_err(|source| MatcherCompileError::Regex {
75 pattern: trimmed.to_owned(),
76 source,
77 })?;
78 Ok(Self::Regex(regex))
79 }
80
81 pub fn compile_glob(pattern: &str) -> Result<Self, MatcherCompileError> {
84 let trimmed = pattern.trim();
85 if trimmed == "*" {
86 let glob = Glob::new("*").map_err(|source| MatcherCompileError::Glob {
89 pattern: trimmed.to_owned(),
90 source,
91 })?;
92 return Ok(Self::Glob(glob.compile_matcher()));
93 }
94 let rewritten = rewrite_glob_for_subtree(trimmed);
95 let glob = GlobBuilder::new(&rewritten)
96 .case_insensitive(true)
97 .literal_separator(false)
98 .build()
99 .map_err(|source| MatcherCompileError::Glob {
100 pattern: trimmed.to_owned(),
101 source,
102 })?;
103 Ok(Self::Glob(glob.compile_matcher()))
104 }
105
106 pub fn is_match(&self, input: &str) -> bool {
107 match self {
108 Self::Exact(literal) => literal.eq_ignore_ascii_case(input),
109 Self::Glob(matcher) => matcher.is_match(input),
110 Self::Regex(regex) => regex.is_match(input),
111 }
112 }
113}
114
115fn rewrite_glob_for_subtree(pattern: &str) -> String {
119 if let Some(rest) = pattern.strip_prefix("*.") {
120 format!("**.{rest}")
121 } else {
122 pattern.to_owned()
123 }
124}
125
126fn looks_like_regex(pattern: &str) -> bool {
127 pattern.chars().any(|ch| {
128 matches!(
129 ch,
130 '[' | ']' | '(' | ')' | '{' | '}' | '+' | '^' | '$' | '\\' | '|'
131 )
132 })
133}
134
135fn looks_like_glob(pattern: &str) -> bool {
136 pattern.chars().any(|ch| matches!(ch, '*' | '?'))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
146 fn review_hook_runtime_ac3_1_glob_matches_subtree() {
147 let matcher = CompiledMatcher::compile_glob("*.example.com").expect("compile");
148 assert!(matcher.is_match("api.example.com"));
149 assert!(matcher.is_match("api.prod.example.com"));
150 assert!(matcher.is_match("deeply.nested.example.com"));
151 }
152
153 #[test]
155 fn review_hook_runtime_ac3_2_bare_star_matches_anything() {
156 let matcher = CompiledMatcher::compile_glob("*").expect("compile");
157 assert!(matcher.is_match(""));
158 assert!(matcher.is_match("literally anything"));
159 assert!(matcher.is_match("api.example.com"));
160 }
161
162 #[test]
164 fn review_hook_runtime_ac3_3_literal_matches_only_exact() {
165 let matcher = CompiledMatcher::compile("api.example.com").expect("compile");
166 assert!(matcher.is_match("api.example.com"));
167 assert!(!matcher.is_match("api2.example.com"));
168 assert!(!matcher.is_match("api.example.com.evil.com"));
169 assert!(matcher.is_match("API.Example.Com"));
172 }
173
174 #[test]
176 fn review_hook_runtime_ac3_4_invalid_regex_rejected_at_compile() {
177 let error = CompiledMatcher::compile_regex("(").expect_err("invalid regex must reject");
178 assert!(matches!(error, MatcherCompileError::Regex { .. }));
179 }
180
181 #[test]
183 fn review_hook_runtime_ac3_6_glob_parity_with_legacy_matcher() {
184 let pairs: &[(&str, &str, bool)] = &[
187 ("git *", "git status", true),
188 ("git *", "cargo test", false),
189 ("shell", "Shell", true),
190 ("*", "anything", true),
191 ("read*", "readtokens", true),
192 ("read*", "write", false),
193 ];
194 for (pattern, candidate, expected) in pairs {
195 let matcher = CompiledMatcher::compile(pattern).expect("compile");
196 assert_eq!(
197 matcher.is_match(candidate),
198 *expected,
199 "pattern {pattern} vs {candidate}",
200 );
201 }
202 }
203}