Skip to main content

fxcp_core/
filter.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! Path filtering with include/exclude patterns.
5
6#![allow(clippy::expect_used)]
7use std::path::Path;
8
9/// Compiled filter rules for include/exclude matching.
10pub struct FilterRules {
11    pub excludes: Vec<glob::Pattern>,
12    pub includes: Vec<glob::Pattern>,
13}
14
15impl FilterRules {
16    /// Build filter rules from string patterns.
17    pub fn new(exclude: &[String], include: &[String]) -> Self {
18        Self {
19            excludes: exclude.iter()
20                .filter_map(|p| glob::Pattern::new(p).ok())
21                .collect(),
22            includes: include.iter()
23                .filter_map(|p| glob::Pattern::new(p).ok())
24                .collect(),
25        }
26    }
27
28    /// Returns true if the path should be skipped.
29    /// A path is skipped if it matches any exclude pattern,
30    /// UNLESS it also matches an include pattern (rsync semantics).
31    pub fn should_skip(&self, rel: &Path) -> bool {
32        let excluded = self.excludes.iter().any(|p| p.matches_path(rel));
33        if !excluded { return false; }
34        // Include overrides exclude
35        !self.includes.iter().any(|p| p.matches_path(rel))
36    }
37}
38
39/// Read patterns from a file, one per line.
40/// Empty lines and lines starting with '#' are ignored.
41pub fn read_patterns(path: &Path) -> anyhow::Result<Vec<String>> {
42    let content = std::fs::read_to_string(path)?;
43    Ok(content.lines()
44        .map(|l| l.trim())
45        .filter(|l| !l.is_empty() && !l.starts_with('#'))
46        .map(|l| l.to_string())
47        .collect())
48}
49
50/// Split positional args into (sources, destination).
51/// Last arg is always the destination. Requires at least 2 args.
52pub fn split_paths(paths: Vec<std::path::PathBuf>) -> anyhow::Result<(Vec<std::path::PathBuf>, std::path::PathBuf)> {
53    if paths.len() < 2 {
54        anyhow::bail!("requires at least a source and destination");
55    }
56    let mut paths = paths;
57    #[allow(clippy::unwrap_used)]
58    let destination = paths.pop().unwrap();
59    Ok((paths, destination))
60}
61
62#[cfg(test)]
63mod tests {
64    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
65    use super::*;
66    use std::path::PathBuf;
67
68    // ---- FilterRules tests ----
69
70    #[test]
71    fn test_no_filters_skips_nothing() {
72        let rules = FilterRules::new(&[], &[]);
73        assert!(!rules.should_skip(Path::new("foo.rs")));
74        assert!(!rules.should_skip(Path::new("dir/bar.tmp")));
75    }
76
77    #[test]
78    fn test_exclude_matches() {
79        let rules = FilterRules::new(&["*.tmp".into()], &[]);
80        assert!(rules.should_skip(Path::new("foo.tmp")));
81        assert!(!rules.should_skip(Path::new("foo.rs")));
82    }
83
84    #[test]
85    fn test_exclude_glob_directory() {
86        let rules = FilterRules::new(&[".git/**".into()], &[]);
87        assert!(rules.should_skip(Path::new(".git/config")));
88        assert!(rules.should_skip(Path::new(".git/refs/heads/main")));
89        assert!(!rules.should_skip(Path::new("src/main.rs")));
90    }
91
92    #[test]
93    fn test_include_overrides_exclude() {
94        let rules = FilterRules::new(
95            &["*.tmp".into()],
96            &["important.tmp".into()],
97        );
98        assert!(rules.should_skip(Path::new("junk.tmp")));
99        assert!(!rules.should_skip(Path::new("important.tmp")));
100        assert!(!rules.should_skip(Path::new("foo.rs")));
101    }
102
103    #[test]
104    fn test_exclude_all_include_specific() {
105        let rules = FilterRules::new(
106            &["*".into()],
107            &["*.rs".into()],
108        );
109        assert!(!rules.should_skip(Path::new("main.rs")));
110        assert!(rules.should_skip(Path::new("readme.md")));
111        assert!(rules.should_skip(Path::new("data.bin")));
112    }
113
114    #[test]
115    fn test_multiple_excludes() {
116        let rules = FilterRules::new(
117            &["*.tmp".into(), "*.bak".into(), "*.swp".into()],
118            &[],
119        );
120        assert!(rules.should_skip(Path::new("foo.tmp")));
121        assert!(rules.should_skip(Path::new("bar.bak")));
122        assert!(rules.should_skip(Path::new("baz.swp")));
123        assert!(!rules.should_skip(Path::new("good.rs")));
124    }
125
126    #[test]
127    fn test_multiple_includes() {
128        let rules = FilterRules::new(
129            &["*".into()],
130            &["*.rs".into(), "*.toml".into(), "Makefile".into()],
131        );
132        assert!(!rules.should_skip(Path::new("main.rs")));
133        assert!(!rules.should_skip(Path::new("Cargo.toml")));
134        assert!(!rules.should_skip(Path::new("Makefile")));
135        assert!(rules.should_skip(Path::new("readme.md")));
136    }
137
138    #[test]
139    fn test_nested_path_matching() {
140        let rules = FilterRules::new(&["target/**".into()], &[]);
141        assert!(rules.should_skip(Path::new("target/debug/fxcp")));
142        assert!(rules.should_skip(Path::new("target/release/foxingd")));
143        assert!(!rules.should_skip(Path::new("src/main.rs")));
144    }
145
146    #[test]
147    fn test_invalid_pattern_ignored() {
148        // Invalid glob pattern should be silently ignored
149        let rules = FilterRules::new(&["[invalid".into(), "*.tmp".into()], &[]);
150        assert!(rules.should_skip(Path::new("foo.tmp")));
151        assert!(!rules.should_skip(Path::new("foo.rs")));
152    }
153
154    // ---- split_paths tests ----
155
156    #[test]
157    fn test_split_two_args() {
158        let paths = vec![PathBuf::from("src"), PathBuf::from("dst")];
159        let (sources, dest) = split_paths(paths).unwrap();
160        assert_eq!(sources, vec![PathBuf::from("src")]);
161        assert_eq!(dest, PathBuf::from("dst"));
162    }
163
164    #[test]
165    fn test_split_three_args() {
166        let paths = vec![
167            PathBuf::from("file1"),
168            PathBuf::from("file2"),
169            PathBuf::from("dest/"),
170        ];
171        let (sources, dest) = split_paths(paths).unwrap();
172        assert_eq!(sources, vec![PathBuf::from("file1"), PathBuf::from("file2")]);
173        assert_eq!(dest, PathBuf::from("dest/"));
174    }
175
176    #[test]
177    fn test_split_many_args() {
178        let paths = vec![
179            PathBuf::from("a"), PathBuf::from("b"),
180            PathBuf::from("c"), PathBuf::from("d"),
181            PathBuf::from("target/"),
182        ];
183        let (sources, dest) = split_paths(paths).unwrap();
184        assert_eq!(sources.len(), 4);
185        assert_eq!(dest, PathBuf::from("target/"));
186    }
187
188    #[test]
189    fn test_split_one_arg_fails() {
190        let paths = vec![PathBuf::from("only")];
191        assert!(split_paths(paths).is_err());
192    }
193
194    #[test]
195    fn test_split_empty_fails() {
196        let paths: Vec<PathBuf> = vec![];
197        assert!(split_paths(paths).is_err());
198    }
199
200    // ---- read_patterns tests ----
201
202    #[test]
203    fn test_read_patterns_basic() {
204        let dir = tempfile::tempdir().unwrap();
205        let file = dir.path().join("patterns.txt");
206        std::fs::write(&file, "*.tmp\n*.bak\n*.swp\n").unwrap();
207        let patterns = read_patterns(&file).unwrap();
208        assert_eq!(patterns, vec!["*.tmp", "*.bak", "*.swp"]);
209    }
210
211    #[test]
212    fn test_read_patterns_comments_and_blanks() {
213        let dir = tempfile::tempdir().unwrap();
214        let file = dir.path().join("patterns.txt");
215        std::fs::write(&file, "# This is a comment\n*.tmp\n\n# Another comment\n*.bak\n  \n").unwrap();
216        let patterns = read_patterns(&file).unwrap();
217        assert_eq!(patterns, vec!["*.tmp", "*.bak"]);
218    }
219
220    #[test]
221    fn test_read_patterns_empty_file() {
222        let dir = tempfile::tempdir().unwrap();
223        let file = dir.path().join("empty.txt");
224        std::fs::write(&file, "").unwrap();
225        let patterns = read_patterns(&file).unwrap();
226        assert!(patterns.is_empty());
227    }
228
229    #[test]
230    fn test_read_patterns_whitespace_trimmed() {
231        let dir = tempfile::tempdir().unwrap();
232        let file = dir.path().join("patterns.txt");
233        std::fs::write(&file, "  *.tmp  \n  *.bak  \n").unwrap();
234        let patterns = read_patterns(&file).unwrap();
235        assert_eq!(patterns, vec!["*.tmp", "*.bak"]);
236    }
237
238    #[test]
239    fn test_read_patterns_missing_file() {
240        assert!(read_patterns(Path::new("/nonexistent/file")).is_err());
241    }
242}