//! 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 { 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(patterns: I) -> Result where I: IntoIterator, S: AsRef, { let mut set = GlobSetBuilder::new(); for pattern in patterns { set.add(compile(pattern.as_ref())?); } set.build().map_err(|source| PatternError::Invalid { pattern: "".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"); } } }