use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
#[derive(Debug)]
pub struct TargetMatcher {
globset: Option<GlobSet>,
}
impl TargetMatcher {
pub fn new(targets: &[String]) -> Result<Self, String> {
if targets.is_empty() {
return Ok(Self { globset: None });
}
let mut builder = GlobSetBuilder::new();
for target in targets {
let normalized = normalize_target(target);
let glob = GlobBuilder::new(&normalized)
.literal_separator(true)
.build()
.map_err(|e| format!("Invalid glob pattern '{}': {}", target, e))?;
builder.add(glob);
}
let globset = builder
.build()
.map_err(|e| format!("Failed to build glob set: {}", e))?;
Ok(Self {
globset: Some(globset),
})
}
pub fn is_match(&self, path: &str) -> bool {
let globset = match &self.globset {
Some(gs) => gs,
None => return true, };
let normalized = path.replace('\\', "/");
if globset.is_match(&normalized) {
return true;
}
if let Some(colon_idx) = normalized.find(':') {
let stripped = &normalized[colon_idx + 1..];
if globset.is_match(stripped) {
return true;
}
}
false
}
}
fn normalize_target(target: &str) -> String {
let mut normalized = target.replace('\\', "/");
let has_slash = normalized.contains('/');
if normalized.ends_with('/') {
normalized.push_str("**");
} else if !has_slash {
normalized.insert_str(0, "**/");
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matcher_empty_targets() {
let matcher = TargetMatcher::new( &[]).unwrap();
assert!(matcher.is_match("any/path.txt"));
assert!(matcher.is_match("data:another\\path.dat"));
}
#[test]
fn test_matcher_exact_match() {
let targets = vec!["data/config.ini".to_string()];
let matcher = TargetMatcher::new(&targets).unwrap();
assert!(matcher.is_match("data/config.ini"));
assert!(matcher.is_match("data\\config.ini"));
assert!(!matcher.is_match("other/file.txt"));
}
#[test]
fn test_matcher_namespace_recursive() {
let targets = vec!["data/global/".to_string()];
let matcher = TargetMatcher::new(&targets).unwrap();
assert!(matcher.is_match("data/global/excel/weapons.txt"));
assert!(matcher.is_match("data\\global\\file.dat"));
assert!(!matcher.is_match("data/other/file.txt"));
}
#[test]
fn test_matcher_glob_patterns() {
let targets = vec!["**/*.txt".to_string()];
let matcher = TargetMatcher::new(&targets).unwrap();
assert!(matcher.is_match("readme.txt"));
assert!(matcher.is_match("docs/sub/notes.txt"));
assert!(!matcher.is_match("binary.exe"));
let targets_global = vec!["*.txt".to_string()];
let matcher_global = TargetMatcher::new(&targets_global).unwrap();
assert!(matcher_global.is_match("data/excel/weapons.txt"));
assert!(matcher_global.is_match("root.txt"));
assert!(!matcher_global.is_match("data/excel/weapons.bin"));
let targets_brace = vec!["path/to/file/file{.txt,.bin}".to_string()];
let matcher_brace = TargetMatcher::new(&targets_brace).unwrap();
assert!(matcher_brace.is_match("path/to/file/file.txt"));
assert!(matcher_brace.is_match("path/to/file/file.bin"));
assert!(!matcher_brace.is_match("path/to/file/file.csv"));
assert!(
matcher_brace.is_match("data:path/to/file/file.txt"),
"Should also support prefix stripping"
);
}
#[test]
fn test_matcher_dual_matching_prefix_omission() {
let targets = vec!["locales/data/**/*.dc6".to_string()];
let matcher = TargetMatcher::new(&targets).unwrap();
assert!(matcher.is_match("data:locales/data/zhtw/ui/tradestash.dc6"));
assert!(matcher.is_match("data:locales\\data\\enus\\ui\\button.dc6"));
let targets_with_prefix = vec!["data:locales/data/**/*.dc6".to_string()];
let matcher_prefix = TargetMatcher::new(&targets_with_prefix).unwrap();
assert!(matcher_prefix.is_match("data:locales/data/zhtw/ui/tradestash.dc6"));
assert!(!matcher.is_match("data:other/path/file.dc6"));
}
#[test]
fn test_matcher_normalization_symmetry() {
let matcher = TargetMatcher::new(&["a/b/c".to_string()]).unwrap();
assert!(matcher.is_match("a\\b\\c"));
let matcher2 = TargetMatcher::new(&["x\\y\\z".to_string()]).unwrap();
assert!(matcher2.is_match("x/y/z"));
}
#[test]
fn test_matcher_multiple_targets_or_logic() {
let targets = vec!["*.txt".to_string(), "data/global/".to_string()];
let matcher = TargetMatcher::new(&targets).unwrap();
assert!(matcher.is_match("readme.txt"), "Should match first target");
assert!(
matcher.is_match("data/global/config.ini"),
"Should match second target"
);
assert!(
matcher.is_match("data:data/global/excel/abc.txt"),
"Should match second target with prefix"
);
assert!(
!matcher.is_match("other/file.dat"),
"Should not match any target"
);
}
#[test]
fn test_matcher_prefix_stripping_edge_cases() {
let matcher = TargetMatcher::new(&["important.txt".to_string()]).unwrap();
assert!(matcher.is_match("data:important.txt"));
assert!(!matcher.is_match("namespace:sub:important.txt"));
assert!(matcher.is_match(":important.txt"));
assert!(matcher.is_match("important.txt"));
let matcher2 = TargetMatcher::new(&["sub:important.txt".to_string()]).unwrap();
assert!(matcher2.is_match("data:sub:important.txt"));
}
#[test]
fn test_matcher_namespace_with_backslash() {
let matcher = TargetMatcher::new(&["dir\\".to_string()]).unwrap();
assert!(matcher.is_match("dir/file.txt"));
assert!(matcher.is_match("data:dir\\sub\\file.dat"));
}
#[test]
fn test_matcher_invalid_glob() {
let targets = vec!["[invalid".to_string()];
let res = TargetMatcher::new(&targets);
assert!(res.is_err());
assert!(res.unwrap_err().contains("Invalid glob pattern"));
}
}