gitwatch_rs/
filter.rs

1use std::path::{absolute, Path};
2
3use anyhow::Result;
4use ignore::{
5    gitignore::{Gitignore, GitignoreBuilder},
6    Match,
7};
8use log::debug;
9use regex::Regex;
10
11pub struct PathFilter {
12    ignore_regex: Option<Regex>,
13    gitignore: Gitignore,
14    repo_path: std::path::PathBuf,
15}
16
17impl PathFilter {
18    pub fn new(repo_path: &Path, ignore_regex: Option<Regex>) -> Result<Self> {
19        Ok(Self {
20            ignore_regex,
21            gitignore: build_gitignore(repo_path)?,
22            repo_path: repo_path.to_path_buf(),
23        })
24    }
25
26    pub fn is_path_ignored(&self, path: &Path) -> bool {
27        let normalized_path = match absolute(path) {
28            Ok(path) => path,
29            Err(_) => return false,
30        };
31        // path should always be repository-relative
32        let relative_path = match normalized_path.strip_prefix(&self.repo_path) {
33            Ok(path) => path,
34            Err(_) => return false,
35        };
36
37        if relative_path.starts_with(".git") {
38            return true;
39        }
40
41        if let Match::Ignore(_) = self
42            .gitignore
43            .matched_path_or_any_parents(relative_path, relative_path.is_dir())
44        {
45            debug!("Path {} ignored via .gitignore", path.display());
46            return true;
47        }
48
49        if let Some(regex) = &self.ignore_regex {
50            if regex.is_match(&relative_path.to_string_lossy()) {
51                debug!(
52                    "Path {} ignored via --ignore-regex",
53                    relative_path.display()
54                );
55                return true;
56            }
57        }
58        false
59    }
60}
61
62fn build_gitignore(repo_path: &Path) -> Result<Gitignore> {
63    let mut builder = GitignoreBuilder::new(repo_path);
64    let gitignore_path = repo_path.join(".gitignore");
65    if gitignore_path.exists() {
66        log::trace!("Using gitignore {}", gitignore_path.display());
67        builder.add(gitignore_path);
68    }
69
70    let gitignore = builder.build()?;
71    Ok(gitignore)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::fs;
78    use tempfile::TempDir;
79
80    #[test]
81    fn test_gitignore() -> Result<()> {
82        let temp_dir = TempDir::new()?;
83        let repo_path = temp_dir.path();
84
85        // create .gitignore file
86        fs::write(repo_path.join(".gitignore"), "*.ignored\nignored_dir/")?;
87
88        let path_filter = PathFilter::new(repo_path, None)?;
89
90        // test ignored files
91        assert!(path_filter.is_path_ignored(&repo_path.join(".git/config")));
92        assert!(path_filter.is_path_ignored(&repo_path.join("test.ignored")));
93        assert!(path_filter.is_path_ignored(&repo_path.join("ignored_dir/file.txt")));
94
95        // test non-ignored files
96        assert!(!path_filter.is_path_ignored(&repo_path.join("test.txt")));
97        assert!(!path_filter.is_path_ignored(&repo_path.join("allowed_dir/file.txt")));
98
99        Ok(())
100    }
101
102    #[test]
103    fn test_ignore_regex() -> Result<()> {
104        let temp_dir = TempDir::new()?;
105        let repo_path = temp_dir.path();
106
107        let ignore_regex = Some(Regex::new(".*\\.temp$")?);
108        let path_filter = PathFilter::new(repo_path, ignore_regex)?;
109
110        // test ignored files
111        assert!(path_filter.is_path_ignored(&repo_path.join("test.temp")));
112        assert!(path_filter.is_path_ignored(&repo_path.join("subdir/another.temp")));
113
114        // test non-ignored files
115        assert!(!path_filter.is_path_ignored(&repo_path.join("test.txt")));
116        assert!(!path_filter.is_path_ignored(&repo_path.join("temp.txt")));
117
118        Ok(())
119    }
120
121    #[test]
122    fn test_path_absolute_error() -> Result<()> {
123        let temp_dir = TempDir::new()?;
124        let repo_path = temp_dir.path();
125        let path_filter = PathFilter::new(repo_path, None)?;
126
127        // create an invalid path containing a null byte which will fail absolute()
128        let invalid_path = Path::new("\0invalid");
129
130        assert!(!path_filter.is_path_ignored(invalid_path));
131        Ok(())
132    }
133
134    #[test]
135    fn test_strip_prefix_error() -> Result<()> {
136        let temp_dir = TempDir::new()?;
137        let repo_path = temp_dir.path();
138        let path_filter = PathFilter::new(repo_path, None)?;
139
140        // create a path outside the repo directory that will fail strip_prefix()
141        let outside_path = temp_dir.path().parent().unwrap().join("outside.txt");
142
143        assert!(!path_filter.is_path_ignored(&outside_path));
144        Ok(())
145    }
146}