use regex::Regex;
#[derive(Debug, Clone)]
pub struct Glob {
src: String,
re: Regex,
}
impl Glob {
pub fn compile(pat: &str) -> Result<Self, regex::Error> {
let re_src = glob_to_regex(pat);
let re = Regex::new(&re_src)?;
Ok(Self {
src: pat.to_string(),
re,
})
}
pub fn as_str(&self) -> &str {
&self.src
}
pub fn as_regex(&self) -> &Regex {
&self.re
}
pub fn matches(&self, s: &str) -> bool {
self.re.is_match(s)
}
}
pub fn compile_pattern(pat: &str) -> Result<Regex, regex::Error> {
if looks_like_regex(pat) {
Regex::new(pat)
} else {
Glob::compile(pat).map(|g| g.re)
}
}
fn looks_like_regex(pat: &str) -> bool {
let mut chars = pat.chars();
while let Some(c) = chars.next() {
match c {
'^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '+' | '\\' => return true,
_ => {}
}
}
false
}
fn glob_to_regex(pat: &str) -> String {
let bytes = pat.as_bytes();
let mut out = String::with_capacity(pat.len() + 8);
out.push('^');
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
match c {
'*' => {
if i + 1 < bytes.len() && bytes[i + 1] == b'*' {
out.push_str(".*");
i += 2;
if i < bytes.len() && bytes[i] == b'/' {
out.push_str("/?");
i += 1;
}
} else {
out.push_str("[^/]*");
i += 1;
}
}
'?' => {
out.push_str("[^/]");
i += 1;
}
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
out.push('\\');
out.push(c);
i += 1;
}
_ => {
out.push(c);
i += 1;
}
}
}
out.push('$');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn m(pat: &str, s: &str) -> bool {
Glob::compile(pat).expect("glob compiles").matches(s)
}
#[test]
fn star_does_not_cross_slash() {
assert!(m("/blog/*", "/blog/post"));
assert!(!m("/blog/*", "/blog/2026/post"));
}
#[test]
fn double_star_crosses_slash() {
assert!(m("/blog/**", "/blog/post"));
assert!(m("/blog/**", "/blog/2026/05/post"));
assert!(m("/blog/**", "/blog/"));
}
#[test]
fn exact_match() {
assert!(m("/about", "/about"));
assert!(!m("/about", "/about/team"));
}
#[test]
fn leading_double_star() {
assert!(m("**/post", "/post"));
assert!(m("**/post", "/blog/post"));
assert!(m("**/post", "/a/b/c/post"));
}
#[test]
fn trailing_double_star() {
assert!(m("/docs/**", "/docs/intro"));
assert!(m("/docs/**", "/docs/"));
assert!(!m("/docs/**", "/other/page"));
}
#[test]
fn question_mark_matches_one_non_slash() {
assert!(m("/p?ge", "/page"));
assert!(!m("/p?ge", "/paige"));
assert!(!m("/p?ge", "/p/ge"));
}
#[test]
fn regex_metachars_in_glob_are_literal() {
assert!(m("/file.txt", "/file.txt"));
assert!(!m("/file.txt", "/fileXtxt"));
}
#[test]
fn auto_detect_picks_glob_for_star() {
let re = compile_pattern("/blog/*").expect("compiles");
assert!(re.is_match("/blog/post"));
assert!(!re.is_match("/blog/a/b"));
}
#[test]
fn auto_detect_picks_regex_for_metachars() {
let re = compile_pattern("^/api/(v1|v2)/").expect("compiles");
assert!(re.is_match("/api/v1/"));
assert!(re.is_match("/api/v2/users"));
assert!(!re.is_match("/api/v3/"));
}
#[test]
fn exclude_over_include_precedence() {
let inc = Glob::compile("/blog/**").unwrap();
let exc = Glob::compile("/blog/drafts/**").unwrap();
let url = "/blog/drafts/secret";
assert!(inc.matches(url));
assert!(exc.matches(url));
let allowed = inc.matches(url) && !exc.matches(url);
assert!(!allowed);
}
}