Skip to main content

hd_watch/
filter.rs

1const DEFAULT_EXCLUDES: &[&str] = &[
2    ".git",
3    "node_modules/.cache",
4    ".DS_Store",
5    "target",
6];
7
8/// Filters file paths based on include/exclude patterns from the EnvSpec.
9pub struct PathFilter {
10    include_prefixes: Vec<String>,
11    exclude_patterns: Vec<String>,
12}
13
14impl PathFilter {
15    pub fn new(include: Vec<String>, exclude: Vec<String>) -> Self {
16        PathFilter {
17            include_prefixes: include,
18            exclude_patterns: exclude,
19        }
20    }
21
22    /// Check if a path should be watched (included and not excluded).
23    pub fn is_included(&self, path: &str) -> bool {
24        // Check default excludes first
25        for excl in DEFAULT_EXCLUDES {
26            if path.starts_with(excl) || path.contains(&format!("/{}", excl)) {
27                return false;
28            }
29        }
30
31        // Check user excludes (glob-style suffix matching)
32        for pattern in &self.exclude_patterns {
33            if glob_matches(pattern, path) {
34                return false;
35            }
36        }
37
38        // Check includes: if empty, include everything. If specified, path must
39        // start with one of the include prefixes or match exactly.
40        if self.include_prefixes.is_empty() {
41            return true;
42        }
43
44        self.include_prefixes.iter().any(|prefix| {
45            path == prefix || path.starts_with(&format!("{}/", prefix))
46        })
47    }
48}
49
50/// Simple glob matching: supports *.ext patterns.
51fn glob_matches(pattern: &str, path: &str) -> bool {
52    if let Some(ext) = pattern.strip_prefix("*.") {
53        let filename = path.rsplit('/').next().unwrap_or(path);
54        filename.ends_with(&format!(".{}", ext))
55    } else {
56        path == pattern || path.starts_with(&format!("{}/", pattern))
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn include_matches() {
66        let filter = PathFilter::new(
67            vec!["src".into(), "package.json".into()],
68            vec![],
69        );
70        assert!(filter.is_included("src/main.rs"));
71        assert!(filter.is_included("src/lib/utils.rs"));
72        assert!(filter.is_included("package.json"));
73        assert!(!filter.is_included("README.md"));
74    }
75
76    #[test]
77    fn exclude_overrides_include() {
78        let filter = PathFilter::new(
79            vec!["src".into()],
80            vec!["*.log".into()],
81        );
82        assert!(filter.is_included("src/main.rs"));
83        assert!(!filter.is_included("src/debug.log"));
84    }
85
86    #[test]
87    fn default_excludes() {
88        let filter = PathFilter::new(vec![], vec![]);
89        assert!(!filter.is_included(".git/config"));
90        assert!(!filter.is_included("node_modules/.cache/foo"));
91    }
92
93    #[test]
94    fn empty_include_means_all() {
95        let filter = PathFilter::new(vec![], vec![]);
96        assert!(filter.is_included("any/file.rs"));
97        assert!(filter.is_included("other.txt"));
98    }
99
100    #[test]
101    fn glob_patterns_in_exclude() {
102        let filter = PathFilter::new(
103            vec!["src".into()],
104            vec!["*.tmp".into(), "*.swp".into()],
105        );
106        assert!(!filter.is_included("src/file.tmp"));
107        assert!(!filter.is_included("src/.main.rs.swp"));
108        assert!(filter.is_included("src/main.rs"));
109    }
110}