patterns.rs
raw
//! Path-pattern compilation shared by backup excludes and restore
//! selection, with gitignore-style anchoring:
//!
//! - Patterns are matched against paths relative to the backup root
//! (or snapshot root), case-sensitively.
//! - A leading `/` anchors the pattern there: `/cache` matches only
//! the top-level `cache`.
//! - Without a leading `/`, the pattern applies at every depth
//! (compiled as `**/pattern`).
//! - `*` and `?` never cross a `/`; `**` is the explicit way to span
//! directories. A trailing `/` is accepted and ignored.
//!
//! What a match *means* differs by use: an excluded directory is pruned
//! along with everything beneath it, while a selected directory brings
//! everything beneath it along.
use std::path::Path;
use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
#[derive(Debug, thiserror::Error)]
pub enum PatternError {
#[error("pattern {0:?} matches nothing")]
Degenerate(String),
#[error("invalid pattern {pattern:?}: {source}")]
Invalid {
pattern: String,
source: globset::Error,
},
}
/// Compiles one pattern under the semantics above.
pub fn compile(pattern: &str) -> Result<Glob, PatternError> {
let trimmed = pattern.trim_end_matches('/');
if trimmed.is_empty() {
return Err(PatternError::Degenerate(pattern.to_string()));
}
let source = match trimmed.strip_prefix('/') {
Some("") => return Err(PatternError::Degenerate(pattern.to_string())),
Some(anchored) => anchored.to_string(),
// Already-recursive patterns pass through; anything else gains
// the every-depth prefix
None if trimmed.starts_with("**") => trimmed.to_string(),
None => format!("**/{trimmed}"),
};
GlobBuilder::new(&source)
.literal_separator(true)
.build()
.map_err(|source| PatternError::Invalid {
pattern: pattern.to_string(),
source,
})
}
/// Compiles a whole pattern list into a matching set.
pub fn compile_set<I, S>(patterns: I) -> Result<GlobSet, PatternError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut set = GlobSetBuilder::new();
for pattern in patterns {
set.add(compile(pattern.as_ref())?);
}
set.build().map_err(|source| PatternError::Invalid {
pattern: "<set>".to_string(),
source,
})
}
/// True if `path` or any of its ancestors matches the set — the rule
/// that makes matching a directory cover everything beneath it, used
/// both by dequeue-time exclusion and by restore selection.
pub fn matches_with_ancestors(set: &GlobSet, path: &Path) -> bool {
path.ancestors()
.filter(|ancestor| !ancestor.as_os_str().is_empty())
.any(|ancestor| set.is_match(ancestor))
}
#[cfg(test)]
mod tests {
use super::*;
fn set(patterns: &[&str]) -> GlobSet {
compile_set(patterns).unwrap()
}
#[test]
fn unanchored_patterns_match_at_every_depth() {
let set = set(&["*.log", "cache"]);
for path in ["x.log", "sub/x.log", "sub/deep/x.log", "cache", "sub/cache"] {
assert!(set.is_match(path), "{path} should match");
}
assert!(!set.is_match("x.log.txt"));
assert!(!set.is_match("cachet"));
}
#[test]
fn a_leading_slash_anchors_to_the_root() {
let set = set(&["/cache"]);
assert!(set.is_match("cache"));
assert!(!set.is_match("sub/cache"));
}
#[test]
fn single_star_stays_within_one_component() {
let set = set(&["/build/*.o"]);
assert!(set.is_match("build/x.o"));
assert!(!set.is_match("build/sub/x.o"));
assert!(!set.is_match("other/build/x.o"));
}
#[test]
fn double_star_crosses_directories() {
let set = set(&["/build/**/*.o"]);
assert!(set.is_match("build/x.o"));
assert!(set.is_match("build/sub/deep/x.o"));
assert!(!set.is_match("other/build/x.o"));
}
#[test]
fn unanchored_multi_component_patterns_float() {
let set = set(&["docs/*.log"]);
assert!(set.is_match("docs/x.log"));
assert!(set.is_match("a/b/docs/x.log"));
assert!(!set.is_match("docs/sub/x.log"));
}
#[test]
fn contents_only_exclusion_spares_the_directory_itself() {
let set = set(&["cache/**"]);
assert!(!set.is_match("cache"), "the directory itself survives");
assert!(set.is_match("cache/a"));
assert!(set.is_match("sub/cache/a/b"));
}
#[test]
fn trailing_slashes_are_ignored() {
let set = set(&["cache/"]);
assert!(set.is_match("cache"));
assert!(set.is_match("sub/cache"));
}
#[test]
fn alternation_and_classes_work() {
let set = set(&["*.{tmp,temp}", "[ab].bak"]);
assert!(set.is_match("x.tmp"));
assert!(set.is_match("sub/x.temp"));
assert!(set.is_match("a.bak"));
assert!(!set.is_match("c.bak"));
}
#[test]
fn ancestor_matching_covers_directory_contents() {
let set = set(&["/junk"]);
assert!(matches_with_ancestors(&set, Path::new("junk")));
assert!(matches_with_ancestors(
&set,
Path::new("junk/deep/file.txt")
));
assert!(!matches_with_ancestors(
&set,
Path::new("junkyard/file.txt")
));
assert!(!matches_with_ancestors(
&set,
Path::new("sub/junk/file.txt")
));
}
#[test]
fn degenerate_patterns_are_rejected() {
for pattern in ["", "/", "//"] {
assert!(compile(pattern).is_err(), "{pattern:?} should be rejected");
}
}
}