Skip to main content

relay_knowledge/watcher/
event_filter.rs

1use std::path::{Path, PathBuf};
2
3const INDEXABLE_EXTENSIONS: &[&str] = &[
4    "rs",
5    "py",
6    "js",
7    "mjs",
8    "ts",
9    "tsx",
10    "jsx",
11    "go",
12    "java",
13    "rb",
14    "c",
15    "cpp",
16    "cc",
17    "cxx",
18    "h",
19    "hpp",
20    "cs",
21    "kt",
22    "kts",
23    "scala",
24    "swift",
25    "php",
26    "sh",
27    "bash",
28    "yaml",
29    "yml",
30    "toml",
31    "json",
32    "xml",
33    "md",
34    "markdown",
35    "rst",
36    "adoc",
37    "sql",
38    "cmake",
39    "make",
40    "dockerfile",
41    "ini",
42    "properties",
43];
44
45const IGNORED_DIRECTORIES: &[&str] = &[
46    ".git",
47    "node_modules",
48    "target",
49    "__pycache__",
50    ".venv",
51    "venv",
52    ".env",
53    "build",
54    "dist",
55    ".tox",
56    ".mypy_cache",
57    ".pytest_cache",
58    ".idea",
59    ".vscode",
60    ".cache",
61];
62
63#[derive(Debug, Clone, Default)]
64pub struct WatcherEventFilter {
65    watched_root: Option<PathBuf>,
66    path_filters: Vec<String>,
67    language_filters: Vec<String>,
68}
69
70impl WatcherEventFilter {
71    pub fn new(
72        watched_root: PathBuf,
73        path_filters: Vec<String>,
74        language_filters: Vec<String>,
75    ) -> Self {
76        Self {
77            watched_root: Some(watched_root),
78            path_filters,
79            language_filters,
80        }
81    }
82
83    pub fn should_process_path(&self, path: &Path) -> bool {
84        let relative = match self.strip_root(path) {
85            Some(rel) => rel,
86            None => return false,
87        };
88
89        if self.is_in_ignored_directory(&relative) {
90            return false;
91        }
92
93        if !self.has_indexable_extension(&relative) {
94            return false;
95        }
96
97        if !self.path_filters.is_empty() && !self.path_matches_scope(&relative) {
98            return false;
99        }
100
101        if !self.language_filters.is_empty() && !self.language_matches_extension(&relative) {
102            return false;
103        }
104
105        true
106    }
107
108    fn strip_root(&self, path: &Path) -> Option<PathBuf> {
109        let root = self.watched_root.as_ref()?;
110        path.strip_prefix(root).ok().map(|p| p.to_path_buf())
111    }
112
113    fn is_in_ignored_directory(&self, relative: &Path) -> bool {
114        for component in relative.ancestors() {
115            if let Some(name) = component.file_name() {
116                if let Some(name_str) = name.to_str() {
117                    if IGNORED_DIRECTORIES.contains(&name_str) {
118                        return true;
119                    }
120                }
121            }
122        }
123        false
124    }
125
126    fn has_indexable_extension(&self, relative: &Path) -> bool {
127        let ext = match relative.extension().and_then(|e| e.to_str()) {
128            Some(e) => e.to_lowercase(),
129            None => {
130                let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or("");
131                return matches!(
132                    name.to_lowercase().as_str(),
133                    "dockerfile"
134                        | "makefile"
135                        | "cmakelists.txt"
136                        | "cargo.toml"
137                        | "package.json"
138                        | "go.mod"
139                        | "requirements.txt"
140                        | "pipfile"
141                        | "gemfile"
142                );
143            }
144        };
145        INDEXABLE_EXTENSIONS.contains(&ext.as_str())
146    }
147
148    fn path_matches_scope(&self, relative: &Path) -> bool {
149        let relative_str = relative.to_string_lossy();
150        self.path_filters
151            .iter()
152            .any(|f| relative_str.starts_with(f.as_str()) || relative_str.contains(f.as_str()))
153    }
154
155    fn language_matches_extension(&self, relative: &Path) -> bool {
156        let ext = match relative.extension().and_then(|e| e.to_str()) {
157            Some(e) => e.to_lowercase(),
158            None => return true,
159        };
160        self.language_filters
161            .iter()
162            .any(|lang| extension_matches_language(&ext, lang))
163    }
164}
165
166fn extension_matches_language(ext: &str, language: &str) -> bool {
167    match language.to_ascii_lowercase().as_str() {
168        "rust" => ext == "rs",
169        "python" | "py" => ext == "py" || ext == "pyw",
170        "javascript" | "js" => ext == "js" || ext == "jsx" || ext == "mjs" || ext == "cjs",
171        "jsx" => ext == "jsx",
172        "typescript" | "ts" => ext == "ts" || ext == "mts" || ext == "cts",
173        "tsx" => ext == "tsx",
174        "go" => ext == "go",
175        "java" => ext == "java",
176        "c" => ext == "c" || ext == "h",
177        "cpp" | "c++" => {
178            ext == "cpp"
179                || ext == "hpp"
180                || ext == "cc"
181                || ext == "cxx"
182                || ext == "hh"
183                || ext == "hxx"
184        }
185        "ruby" | "rb" => ext == "rb",
186        "kotlin" | "kt" => ext == "kt" || ext == "kts",
187        "scala" => ext == "scala",
188        "swift" => ext == "swift",
189        "csharp" | "c#" => ext == "cs",
190        "php" => ext == "php",
191        "bash" | "shell" | "sh" => ext == "sh" || ext == "bash" || ext == "bats",
192        "json" => ext == "json",
193        "yaml" | "yml" => ext == "yaml" || ext == "yml",
194        "toml" => ext == "toml",
195        "sql" => ext == "sql",
196        "markdown" | "md" => ext == "md" || ext == "markdown",
197        "xml" => ext == "xml" || ext == "xsd" || ext == "xsl" || ext == "xslt",
198        "ini" => ext == "ini" || ext == "conf" || ext == "cfg",
199        "properties" => ext == "properties",
200        _ => false,
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::path::PathBuf;
208
209    fn root() -> PathBuf {
210        PathBuf::from("/project")
211    }
212
213    #[test]
214    fn allows_rust_source_file() {
215        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
216        assert!(filter.should_process_path(&root().join("src/main.rs")));
217    }
218
219    #[test]
220    fn rejects_git_directory() {
221        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
222        assert!(!filter.should_process_path(&root().join(".git/HEAD")));
223    }
224
225    #[test]
226    fn rejects_node_modules() {
227        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
228        assert!(!filter.should_process_path(&root().join("node_modules/foo/index.js")));
229    }
230
231    #[test]
232    fn rejects_binary_file() {
233        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
234        assert!(!filter.should_process_path(&root().join("image.png")));
235    }
236
237    #[test]
238    fn allows_dockerfile_without_extension() {
239        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
240        assert!(filter.should_process_path(&root().join("Dockerfile")));
241    }
242
243    #[test]
244    fn path_filter_accepts_matching_prefix() {
245        let filter = WatcherEventFilter::new(root(), vec!["src/".to_owned()], vec![]);
246        assert!(filter.should_process_path(&root().join("src/lib.rs")));
247        assert!(!filter.should_process_path(&root().join("docs/README.md")));
248    }
249
250    #[test]
251    fn rejects_path_outside_root() {
252        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
253        assert!(!filter.should_process_path(&PathBuf::from("/other/project/main.rs")));
254    }
255
256    #[test]
257    fn rejects_target_directory() {
258        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
259        assert!(!filter.should_process_path(&root().join("target/debug/lib.rs")));
260    }
261
262    #[test]
263    fn default_filter_rejects_empty_path() {
264        let filter = WatcherEventFilter::default();
265        assert!(!filter.should_process_path(&PathBuf::from("main.rs")));
266    }
267
268    #[test]
269    fn allows_python_file() {
270        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
271        assert!(filter.should_process_path(&root().join("app/models.py")));
272    }
273
274    #[test]
275    fn rejects_pycache() {
276        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
277        assert!(!filter.should_process_path(&root().join("__pycache__/foo.cpython-311.pyc")));
278    }
279
280    #[test]
281    fn allows_known_language_alias_extensions() {
282        let filter = WatcherEventFilter::new(root(), vec![], vec![]);
283        assert!(filter.should_process_path(&root().join("src/lib.cc")));
284        assert!(filter.should_process_path(&root().join("web/app.mjs")));
285        assert!(filter.should_process_path(&root().join("build.gradle.kts")));
286    }
287
288    #[test]
289    fn language_filters_accept_config_and_document_languages() {
290        for (language, path) in [
291            ("json", "config/app.json"),
292            ("yaml", "config/app.yaml"),
293            ("toml", "Cargo.toml"),
294            ("sql", "schema/main.sql"),
295            ("markdown", "README.md"),
296        ] {
297            let filter = WatcherEventFilter::new(root(), vec![], vec![language.to_owned()]);
298            assert!(filter.should_process_path(&root().join(path)));
299        }
300    }
301
302    #[test]
303    fn unknown_language_filter_does_not_match_extension_by_name() {
304        let filter = WatcherEventFilter::new(root(), vec![], vec!["rs".to_owned()]);
305        assert!(!filter.should_process_path(&root().join("src/main.rs")));
306    }
307}