1use regex::Regex;
25use thiserror::Error;
26
27pub 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/";
34
35pub 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";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum FollowMode {
49 #[default]
51 Follow,
52 NoFollow,
54}
55
56impl FollowMode {
57 #[must_use]
59 pub fn follows_symlinks(self) -> bool {
60 matches!(self, Self::Follow)
61 }
62}
63
64#[derive(Debug, Error)]
66pub enum ExcludeError {
67 #[error("invalid exclude regex: {0}")]
69 InvalidRegex(#[from] regex::Error),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct ExpandedExclude {
76 pub pattern: Option<String>,
79 pub forces_no_follow: bool,
81}
82
83#[must_use]
99pub fn expand_excludes(pattern: &str, home_cache: &str, cache_dir: &str) -> ExpandedExclude {
100 if pattern.is_empty() {
101 return ExpandedExclude {
102 pattern: None,
103 forces_no_follow: false,
104 };
105 }
106
107 let mut expanded = pattern.to_owned();
108 let mut forces_no_follow = false;
109
110 if expanded.contains("%system%") {
111 let system_set = format!("(^({SYSTEM_EXCLUDE_DIRS}|{home_cache}|{cache_dir}))");
112 expanded = expanded.replace("%system%", &system_set);
113 forces_no_follow = true;
114 }
115 if expanded.contains("%common%") {
116 let common_set = format!("(/({COMMON_EXCLUDE_DIRS})($|/))");
117 expanded = expanded.replace("%common%", &common_set);
118 }
119
120 ExpandedExclude {
121 pattern: Some(expanded),
122 forces_no_follow,
123 }
124}
125
126#[derive(Debug, Clone)]
129pub struct ExcludeMatcher {
130 regex: Regex,
131}
132
133impl ExcludeMatcher {
134 pub fn new(pattern: &str) -> Result<Self, ExcludeError> {
141 Ok(Self {
142 regex: Regex::new(pattern)?,
143 })
144 }
145
146 #[must_use]
149 pub fn is_excluded(&self, path: &str) -> bool {
150 self.regex.is_match(path)
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 const HOME_CACHE: &str = "/home/user/.cache/";
160 const CACHE_DIR: &str = "/home/user/.cache/snapdir";
161
162 #[test]
163 fn exclude_system_expands_to_oracle_set_and_forces_no_follow() {
164 let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
165 let expected = format!("(^({SYSTEM_EXCLUDE_DIRS}|{HOME_CACHE}|{CACHE_DIR}))");
166 assert_eq!(out.pattern.as_deref(), Some(expected.as_str()));
167 assert!(out.forces_no_follow, "%system% must force no-follow");
168 }
169
170 #[test]
171 fn exclude_common_expands_to_oracle_set_without_forcing_no_follow() {
172 let out = expand_excludes("%common%", HOME_CACHE, CACHE_DIR);
173 let expected = format!("(/({COMMON_EXCLUDE_DIRS})($|/))");
174 assert_eq!(out.pattern.as_deref(), Some(expected.as_str()));
175 assert!(
176 !out.forces_no_follow,
177 "%common% alone must NOT force no-follow"
178 );
179 }
180
181 #[test]
182 fn exclude_combines_user_pattern_with_both_macros() {
183 let out = expand_excludes(".ignore|%common%|%system%", HOME_CACHE, CACHE_DIR);
186 let pattern = out.pattern.expect("non-empty");
187 assert!(pattern.starts_with(".ignore|"));
188 assert!(pattern.contains("node_modules"));
189 assert!(pattern.contains("/proc/"));
190 assert!(out.forces_no_follow, "%system% present forces no-follow");
191 }
192
193 #[test]
194 fn exclude_empty_pattern_yields_no_exclusion() {
195 let out = expand_excludes("", HOME_CACHE, CACHE_DIR);
196 assert_eq!(out.pattern, None);
197 assert!(!out.forces_no_follow);
198 }
199
200 #[test]
201 fn exclude_user_pattern_passes_through_verbatim() {
202 let out = expand_excludes(".git|.DS_Store", HOME_CACHE, CACHE_DIR);
204 assert_eq!(out.pattern.as_deref(), Some(".git|.DS_Store"));
205 assert!(!out.forces_no_follow);
206 }
207
208 #[test]
209 fn exclude_matcher_matches_representative_common_paths() {
210 let out = expand_excludes("%common%", HOME_CACHE, CACHE_DIR);
211 let matcher = ExcludeMatcher::new(&out.pattern.unwrap()).expect("valid regex");
212
213 assert!(matcher.is_excluded("/project/.git/config"));
216 assert!(matcher.is_excluded("/project/node_modules/pkg/index.js"));
217 assert!(matcher.is_excluded("/home/user/.DS_Store"));
218 assert!(matcher.is_excluded("/repo/.cache"));
219
220 assert!(!matcher.is_excluded("/project/src/main.rs"));
222 assert!(!matcher.is_excluded("/project/readme.md"));
223 assert!(!matcher.is_excluded("/project/.gitignore"));
225 }
226
227 #[test]
228 fn exclude_matcher_matches_representative_system_paths() {
229 let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
230 let matcher = ExcludeMatcher::new(&out.pattern.unwrap()).expect("valid regex");
231
232 assert!(matcher.is_excluded("/proc/cpuinfo"));
234 assert!(matcher.is_excluded("/dev/null"));
235 assert!(matcher.is_excluded("/sys/kernel"));
236 assert!(matcher.is_excluded("/tmp/scratch"));
237 assert!(matcher.is_excluded("/home/user/.cache/thing"));
238
239 assert!(!matcher.is_excluded("/data/proc/file"));
241 assert!(!matcher.is_excluded("/home/user/project/main.rs"));
242 }
243
244 #[test]
245 fn exclude_matcher_user_regex_is_extended_regex() {
246 let matcher = ExcludeMatcher::new("foo|bar").expect("valid regex");
248 assert!(matcher.is_excluded("/a/foo/b"));
249 assert!(matcher.is_excluded("/x/bar"));
250 assert!(!matcher.is_excluded("/x/baz"));
251 }
252
253 #[test]
256 fn no_follow_default_is_follow() {
257 assert_eq!(FollowMode::default(), FollowMode::Follow);
258 assert!(FollowMode::default().follows_symlinks());
259 }
260
261 #[test]
262 fn no_follow_drops_symlinks() {
263 assert!(!FollowMode::NoFollow.follows_symlinks());
264 assert!(FollowMode::Follow.follows_symlinks());
265 }
266
267 #[test]
268 fn no_follow_forced_by_system_exclude() {
269 let out = expand_excludes("%system%", HOME_CACHE, CACHE_DIR);
272 let mode = if out.forces_no_follow {
273 FollowMode::NoFollow
274 } else {
275 FollowMode::Follow
276 };
277 assert_eq!(mode, FollowMode::NoFollow);
278 assert!(!mode.follows_symlinks());
279 }
280
281 #[test]
282 fn no_follow_not_forced_by_common_or_plain_exclude() {
283 for pat in ["%common%", ".git", ""] {
285 let out = expand_excludes(pat, HOME_CACHE, CACHE_DIR);
286 assert!(
287 !out.forces_no_follow,
288 "pattern {pat:?} must not force no-follow"
289 );
290 }
291 }
292}