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");
        }
    }
}