use regex::Regex;
use thiserror::Error;
pub const SYSTEM_EXCLUDE_DIRS: &str = "/vscode/|/dev/|/proc/|/sys/|/tmp/|/var/run/|/run/|/mnt/|/media/|/lost+found/|/var/snap/lxd/common/ns/shmounts/|/var/snap/lxd/common/ns/mntns/|/var/lib/lxcfs/";
pub const COMMON_EXCLUDE_DIRS: &str = ".cache|.git|.DS_Store|.vscode-server|.dbus|.gvfs|.local/share/gvfs-metadata|.local/share/Trash|.Trash|node_modules|Trash-1000";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FollowMode {
#[default]
Follow,
NoFollow,
}
impl FollowMode {
#[must_use]
pub fn follows_symlinks(self) -> bool {
matches!(self, Self::Follow)
}
}
#[derive(Debug, Error)]
pub enum ExcludeError {
#[error("invalid exclude regex: {0}")]
InvalidRegex(#[from] regex::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpandedExclude {
pub pattern: Option<String>,
pub forces_no_follow: bool,
}
#[must_use]
pub fn expand_excludes(pattern: &str, home_cache: &str, cache_dir: &str) -> ExpandedExclude {
if pattern.is_empty() {
return ExpandedExclude {
pattern: None,
forces_no_follow: false,
};
}
let mut expanded = pattern.to_owned();
let mut forces_no_follow = false;
if expanded.contains("%system%") {
let system_set = format!("(^({SYSTEM_EXCLUDE_DIRS}|{home_cache}|{cache_dir}))");
expanded = expanded.replace("%system%", &system_set);
forces_no_follow = true;
}
if expanded.contains("%common%") {
let common_set = format!("(/({COMMON_EXCLUDE_DIRS})($|/))");
expanded = expanded.replace("%common%", &common_set);
}
ExpandedExclude {
pattern: Some(expanded),
forces_no_follow,
}
}
#[derive(Debug, Clone)]
pub struct ExcludeMatcher {
regex: Regex,
}
impl ExcludeMatcher {
pub fn new(pattern: &str) -> Result<Self, ExcludeError> {
Ok(Self {
regex: Regex::new(pattern)?,
})
}
#[must_use]
pub fn is_excluded(&self, path: &str) -> bool {
self.regex.is_match(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
const HOME_CACHE: &str = "/home/user/.cache/";
const CACHE_DIR: &str = "/home/user/.cache/snapdir";
#[test]
fn exclude_system_expands_to_oracle_set_and_forces_no_follow() {
let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
let expected = format!("(^({SYSTEM_EXCLUDE_DIRS}|{HOME_CACHE}|{CACHE_DIR}))");
assert_eq!(out.pattern.as_deref(), Some(expected.as_str()));
assert!(out.forces_no_follow, "%system% must force no-follow");
}
#[test]
fn exclude_common_expands_to_oracle_set_without_forcing_no_follow() {
let out = expand_excludes("%common%", HOME_CACHE, CACHE_DIR);
let expected = format!("(/({COMMON_EXCLUDE_DIRS})($|/))");
assert_eq!(out.pattern.as_deref(), Some(expected.as_str()));
assert!(
!out.forces_no_follow,
"%common% alone must NOT force no-follow"
);
}
#[test]
fn exclude_combines_user_pattern_with_both_macros() {
let out = expand_excludes(".ignore|%common%|%system%", HOME_CACHE, CACHE_DIR);
let pattern = out.pattern.expect("non-empty");
assert!(pattern.starts_with(".ignore|"));
assert!(pattern.contains("node_modules"));
assert!(pattern.contains("/proc/"));
assert!(out.forces_no_follow, "%system% present forces no-follow");
}
#[test]
fn exclude_empty_pattern_yields_no_exclusion() {
let out = expand_excludes("", HOME_CACHE, CACHE_DIR);
assert_eq!(out.pattern, None);
assert!(!out.forces_no_follow);
}
#[test]
fn exclude_user_pattern_passes_through_verbatim() {
let out = expand_excludes(".git|.DS_Store", HOME_CACHE, CACHE_DIR);
assert_eq!(out.pattern.as_deref(), Some(".git|.DS_Store"));
assert!(!out.forces_no_follow);
}
#[test]
fn exclude_matcher_matches_representative_common_paths() {
let out = expand_excludes("%common%", HOME_CACHE, CACHE_DIR);
let matcher = ExcludeMatcher::new(&out.pattern.unwrap()).expect("valid regex");
assert!(matcher.is_excluded("/project/.git/config"));
assert!(matcher.is_excluded("/project/node_modules/pkg/index.js"));
assert!(matcher.is_excluded("/home/user/.DS_Store"));
assert!(matcher.is_excluded("/repo/.cache"));
assert!(!matcher.is_excluded("/project/src/main.rs"));
assert!(!matcher.is_excluded("/project/readme.md"));
assert!(!matcher.is_excluded("/project/.gitignore"));
}
#[test]
fn exclude_matcher_matches_representative_system_paths() {
let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
let matcher = ExcludeMatcher::new(&out.pattern.unwrap()).expect("valid regex");
assert!(matcher.is_excluded("/proc/cpuinfo"));
assert!(matcher.is_excluded("/dev/null"));
assert!(matcher.is_excluded("/sys/kernel"));
assert!(matcher.is_excluded("/tmp/scratch"));
assert!(matcher.is_excluded("/home/user/.cache/thing"));
assert!(!matcher.is_excluded("/data/proc/file"));
assert!(!matcher.is_excluded("/home/user/project/main.rs"));
}
#[test]
fn exclude_matcher_user_regex_is_extended_regex() {
let matcher = ExcludeMatcher::new("foo|bar").expect("valid regex");
assert!(matcher.is_excluded("/a/foo/b"));
assert!(matcher.is_excluded("/x/bar"));
assert!(!matcher.is_excluded("/x/baz"));
}
#[test]
fn no_follow_default_is_follow() {
assert_eq!(FollowMode::default(), FollowMode::Follow);
assert!(FollowMode::default().follows_symlinks());
}
#[test]
fn no_follow_drops_symlinks() {
assert!(!FollowMode::NoFollow.follows_symlinks());
assert!(FollowMode::Follow.follows_symlinks());
}
#[test]
fn no_follow_forced_by_system_exclude() {
let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
let mode = if out.forces_no_follow {
FollowMode::NoFollow
} else {
FollowMode::Follow
};
assert_eq!(mode, FollowMode::NoFollow);
assert!(!mode.follows_symlinks());
}
#[test]
fn no_follow_not_forced_by_common_or_plain_exclude() {
for pat in ["%common%", ".git", ""] {
let out = expand_excludes(pat, HOME_CACHE, CACHE_DIR);
assert!(
!out.forces_no_follow,
"pattern {pat:?} must not force no-follow"
);
}
}
}