#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum PathClass {
#[default]
Extract,
Opaque,
Excluded,
}
impl PathClass {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Extract => "extract",
Self::Opaque => "opaque",
Self::Excluded => "excluded",
}
}
#[must_use]
pub const fn mines(self) -> bool {
matches!(self, Self::Extract)
}
#[must_use]
pub const fn reads(self) -> bool {
!matches!(self, Self::Excluded)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PathPolicy {
exclude: Vec<String>,
opaque: Vec<String>,
}
static EMPTY: PathPolicy = PathPolicy {
exclude: Vec::new(),
opaque: Vec::new(),
};
impl PathPolicy {
#[must_use]
pub fn new(exclude: Vec<String>, opaque: Vec<String>) -> Self {
Self { exclude, opaque }
}
#[must_use]
pub fn empty() -> &'static Self {
&EMPTY
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.exclude.is_empty() && self.opaque.is_empty()
}
#[must_use]
pub fn classify(&self, path: &str) -> PathClass {
if self.exclude.iter().any(|g| glob_match(g, path)) {
PathClass::Excluded
} else if self.opaque.iter().any(|g| glob_match(g, path)) {
PathClass::Opaque
} else {
PathClass::Extract
}
}
#[must_use]
pub fn exclude_patterns(&self) -> &[String] {
&self.exclude
}
#[must_use]
pub fn opaque_patterns(&self) -> &[String] {
&self.opaque
}
#[must_use]
pub fn fingerprint(&self) -> u64 {
if self.is_empty() {
return 0;
}
let mut h = 0xcbf2_9ce4_8422_2325_u64;
let mut fold = |bytes: &[u8]| {
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
};
for (tag, list) in [(b'x', &self.exclude), (b'o', &self.opaque)] {
fold(&[tag]);
fold(&(list.len() as u64).to_le_bytes());
for pattern in list {
fold(&(pattern.len() as u64).to_le_bytes());
fold(pattern.as_bytes());
}
}
h
}
}
#[must_use]
pub fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').collect();
let seg: Vec<&str> = path.split('/').collect();
match_segments(&pat, &seg)
}
fn match_segments(pat: &[&str], seg: &[&str]) -> bool {
let stride = seg.len() + 1;
let mut failed = vec![false; (pat.len() + 1) * stride];
match_segments_memo(pat, seg, &mut failed, stride)
}
fn match_segments_memo(pat: &[&str], seg: &[&str], failed: &mut [bool], stride: usize) -> bool {
let slot = pat.len() * stride + seg.len();
if failed[slot] {
return false;
}
let matched = match pat.first() {
None => seg.is_empty(),
Some(&"**") => {
(0..=seg.len()).any(|i| match_segments_memo(&pat[1..], &seg[i..], failed, stride))
}
Some(token) => {
!seg.is_empty()
&& match_token(token, seg[0])
&& match_segments_memo(&pat[1..], &seg[1..], failed, stride)
}
};
if !matched {
failed[slot] = true;
}
matched
}
fn match_token(pattern: &str, s: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let chars: Vec<char> = s.chars().collect();
match_token_chars(&pat, &chars)
}
fn match_token_chars(pat: &[char], chars: &[char]) -> bool {
let stride = chars.len() + 1;
let mut failed = vec![false; (pat.len() + 1) * stride];
match_token_memo(pat, chars, &mut failed, stride)
}
fn match_token_memo(pat: &[char], chars: &[char], failed: &mut [bool], stride: usize) -> bool {
let slot = pat.len() * stride + chars.len();
if failed[slot] {
return false;
}
let matched = match pat.first() {
None => chars.is_empty(),
Some('*') => {
(0..=chars.len()).any(|i| match_token_memo(&pat[1..], &chars[i..], failed, stride))
}
Some('?') => !chars.is_empty() && match_token_memo(&pat[1..], &chars[1..], failed, stride),
Some(&ch) => {
!chars.is_empty()
&& chars[0] == ch
&& match_token_memo(&pat[1..], &chars[1..], failed, stride)
}
};
if !matched {
failed[slot] = true;
}
matched
}
#[cfg(test)]
mod tests {
use super::{PathClass, PathPolicy, glob_match};
#[test]
fn an_empty_policy_extracts_everything_and_costs_no_cache_key() {
let policy = PathPolicy::default();
assert!(policy.is_empty());
assert_eq!(policy.classify("src/main.rs"), PathClass::Extract);
assert_eq!(policy.classify("raw/paper.pdf"), PathClass::Extract);
assert_eq!(
policy.fingerprint(),
0,
"a declared-nothing policy must leave every existing cache key alone"
);
assert_eq!(PathPolicy::empty(), &policy);
}
#[test]
fn exclude_and_opaque_classify_independently() {
let policy = PathPolicy::new(vec!["raw/**".into()], vec!["manifest/**".into()]);
assert_eq!(policy.classify("raw/paper.pdf"), PathClass::Excluded);
assert_eq!(policy.classify("raw/nested/deep.md"), PathClass::Excluded);
assert_eq!(policy.classify("manifest/papers.jsonl"), PathClass::Opaque);
assert_eq!(policy.classify("src/main.rs"), PathClass::Extract);
assert_eq!(policy.classify("rawish/a.rs"), PathClass::Extract);
}
#[test]
fn exclude_beats_opaque_when_both_match() {
let policy = PathPolicy::new(vec!["corpus/**".into()], vec!["corpus/**".into()]);
assert_eq!(policy.classify("corpus/a.json"), PathClass::Excluded);
}
#[test]
fn the_fingerprint_separates_the_two_lists() {
let excluded = PathPolicy::new(vec!["raw/**".into()], Vec::new());
let opaque = PathPolicy::new(Vec::new(), vec!["raw/**".into()]);
assert_ne!(excluded.fingerprint(), opaque.fingerprint());
assert_ne!(excluded.fingerprint(), 0);
assert_eq!(
excluded.fingerprint(),
PathPolicy::new(vec!["raw/**".into()], Vec::new()).fingerprint(),
"deterministic for an identical declaration"
);
}
#[test]
fn a_pattern_cannot_forge_the_fingerprint_of_another_policy() {
let two = PathPolicy::new(vec!["a".into(), "b".into()], Vec::new());
let one_forged = PathPolicy::new(vec!["a\0x\0b".into()], Vec::new());
assert_ne!(two.fingerprint(), one_forged.fingerprint());
let split = PathPolicy::new(vec!["a".into()], vec!["b".into()]);
let forged = PathPolicy::new(vec!["a\0o\0b".into()], Vec::new());
assert_ne!(split.fingerprint(), forged.fingerprint());
assert_ne!(
PathPolicy::new(vec!["a".into()], vec!["b".into()]).fingerprint(),
PathPolicy::new(vec!["b".into()], vec!["a".into()]).fingerprint()
);
}
#[test]
fn the_three_classes_answer_the_two_questions_readers_ask() {
assert!(PathClass::Extract.mines() && PathClass::Extract.reads());
assert!(!PathClass::Opaque.mines() && PathClass::Opaque.reads());
assert!(!PathClass::Excluded.mines() && !PathClass::Excluded.reads());
}
#[test]
fn a_pathological_pattern_is_bounded_rather_than_exponential() {
let deep = vec!["**"; 20].join("/") + "/needle";
let path = (0..20)
.map(|i| format!("d{i}"))
.collect::<Vec<_>>()
.join("/");
let start = std::time::Instant::now();
assert!(
!glob_match(&deep, &path),
"no `needle` segment, so no match"
);
assert!(glob_match(&deep, &format!("{path}/needle")));
let starred = format!("{}.rs", "*a".repeat(16));
assert!(
!glob_match(&starred, &format!("{}.txt", "a".repeat(40))),
"the trailing `.rs` cannot match `.txt`"
);
assert!(
glob_match(&starred, &format!("{}.rs", "a".repeat(40))),
"and the matching case still matches"
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"the matcher must be bounded, not exponential — took {elapsed:?}"
);
}
#[test]
fn glob_matches_segments_and_wildcards() {
assert!(glob_match("vendor/**", "vendor/lib/a.rs"));
assert!(glob_match("vendor/**", "vendor"));
assert!(glob_match("**/generated/*", "src/gen/generated/x.rs"));
assert!(glob_match("**/*.jsonl", "manifest/papers.jsonl"));
assert!(!glob_match("src/*.rs", "src/a/b.rs"));
}
}