sparsync 0.1.12

rsync-style high-performance file synchronization over QUIC and Spargio
use anyhow::{Context, Result, bail};
use globset::{Glob, GlobMatcher};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuleAction {
    Include,
    Exclude,
}

#[derive(Debug, Clone)]
struct Rule {
    action: RuleAction,
    matcher: GlobMatcher,
}

#[derive(Debug, Clone)]
pub struct PathFilter {
    has_include: bool,
    rules: Vec<Rule>,
}

impl PathFilter {
    pub fn from_patterns(include: &[String], exclude: &[String]) -> Result<Self> {
        let mut rules = Vec::with_capacity(include.len().saturating_add(exclude.len()));
        for pattern in include {
            rules.push(compile_rule(pattern, RuleAction::Include)?);
        }
        for pattern in exclude {
            rules.push(compile_rule(pattern, RuleAction::Exclude)?);
        }
        Ok(Self {
            has_include: !include.is_empty(),
            rules,
        })
    }

    pub fn allows(&self, relative_path: &str) -> bool {
        for rule in &self.rules {
            if rule.matcher.is_match(relative_path) {
                return matches!(rule.action, RuleAction::Include);
            }
        }
        !self.has_include
    }
}

fn compile_rule(pattern: &str, action: RuleAction) -> Result<Rule> {
    let normalized = normalize_pattern(pattern)?;
    let glob = Glob::new(&normalized).with_context(|| {
        let label = match action {
            RuleAction::Include => "include",
            RuleAction::Exclude => "exclude",
        };
        format!("invalid {label} pattern '{}'", pattern)
    })?;
    Ok(Rule {
        action,
        matcher: glob.compile_matcher(),
    })
}

fn normalize_pattern(pattern: &str) -> Result<String> {
    let mut pattern = pattern.trim().replace('\\', "/");
    if pattern.is_empty() {
        bail!("pattern cannot be empty");
    }
    if let Some(stripped) = pattern.strip_prefix("./") {
        pattern = stripped.to_string();
    }
    if let Some(stripped) = pattern.strip_prefix('/') {
        pattern = stripped.to_string();
    }
    if pattern.is_empty() {
        bail!("pattern cannot be root-only ('/')");
    }
    if pattern.ends_with('/') {
        pattern.push_str("**");
    } else if !pattern.contains('/') {
        pattern = format!("**/{pattern}");
    }
    Ok(pattern)
}

#[cfg(test)]
mod tests {
    use super::PathFilter;

    #[test]
    fn include_and_exclude_apply_together() {
        let filter = PathFilter::from_patterns(
            &[String::from("**/*.txt")],
            &[String::from("**/secret*.txt")],
        )
        .expect("build filter");
        assert!(filter.allows("notes/readme.txt"));
        assert!(filter.allows("notes/secret.txt"));
        assert!(!filter.allows("notes/image.png"));
    }

    #[test]
    fn basename_pattern_matches_nested_paths() {
        let filter = PathFilter::from_patterns(&[], &[String::from("*.tmp")]).expect("build");
        assert!(!filter.allows("root/a.tmp"));
        assert!(!filter.allows("a.tmp"));
        assert!(filter.allows("a.txt"));
    }

    #[test]
    fn directory_suffix_pattern_matches_tree() {
        let filter = PathFilter::from_patterns(&[], &[String::from("cache/")]).expect("build");
        assert!(!filter.allows("cache/data.bin"));
        assert!(filter.allows("other/cache/data.bin"));
    }

    #[test]
    fn include_rules_default_deny() {
        let filter = PathFilter::from_patterns(&[String::from("docs/**")], &[]).expect("build");
        assert!(filter.allows("docs/readme.md"));
        assert!(!filter.allows("src/main.rs"));
    }
}