Skip to main content

cc_audit/
ignore.rs

1use crate::config::IgnoreConfig;
2use ignore::gitignore::{Gitignore, GitignoreBuilder};
3use std::collections::HashSet;
4use std::path::Path;
5
6#[derive(Default)]
7pub struct IgnoreFilter {
8    gitignore: Option<Gitignore>,
9    include_tests: bool,
10    include_node_modules: bool,
11    include_vendor: bool,
12    /// Additional directories to ignore from config
13    extra_directories: HashSet<String>,
14    /// Custom glob patterns from config
15    custom_patterns: Option<Gitignore>,
16}
17
18impl IgnoreFilter {
19    pub fn new(root: &Path) -> Self {
20        let gitignore = Self::load_ignorefiles(root);
21
22        Self {
23            gitignore,
24            include_tests: false,
25            include_node_modules: false,
26            include_vendor: false,
27            extra_directories: HashSet::new(),
28            custom_patterns: None,
29        }
30    }
31
32    /// Create IgnoreFilter from config
33    pub fn from_config(root: &Path, config: &IgnoreConfig) -> Self {
34        let gitignore = Self::load_ignorefiles(root);
35        let custom_patterns = Self::build_custom_patterns(root, &config.patterns);
36
37        Self {
38            gitignore,
39            include_tests: config.include_tests,
40            include_node_modules: config.include_node_modules,
41            include_vendor: config.include_vendor,
42            extra_directories: config.directories.clone(),
43            custom_patterns,
44        }
45    }
46
47    /// Apply config settings to existing filter
48    pub fn with_config(mut self, config: &IgnoreConfig) -> Self {
49        self.include_tests = config.include_tests;
50        self.include_node_modules = config.include_node_modules;
51        self.include_vendor = config.include_vendor;
52        self.extra_directories = config.directories.clone();
53        // Note: custom_patterns requires root path, so it's not updated here
54        self
55    }
56
57    pub fn with_include_tests(mut self, include: bool) -> Self {
58        self.include_tests = include;
59        self
60    }
61
62    pub fn with_include_node_modules(mut self, include: bool) -> Self {
63        self.include_node_modules = include;
64        self
65    }
66
67    pub fn with_include_vendor(mut self, include: bool) -> Self {
68        self.include_vendor = include;
69        self
70    }
71
72    /// Build gitignore-style patterns from config patterns
73    fn build_custom_patterns(root: &Path, patterns: &[String]) -> Option<Gitignore> {
74        if patterns.is_empty() {
75            return None;
76        }
77
78        let mut builder = GitignoreBuilder::new(root);
79        for pattern in patterns {
80            // Add pattern - ignore errors for invalid patterns
81            let _ = builder.add_line(None, pattern);
82        }
83
84        builder.build().ok()
85    }
86
87    fn load_ignorefiles(root: &Path) -> Option<Gitignore> {
88        let mut builder = GitignoreBuilder::new(root);
89        let mut has_patterns = false;
90
91        // Load .gitignore first (if it exists and there's a .git directory)
92        let git_dir = root.join(".git");
93        let gitignore_file = root.join(".gitignore");
94        if git_dir.exists() && gitignore_file.exists() && builder.add(&gitignore_file).is_none() {
95            has_patterns = true;
96        }
97
98        // Load .cc-auditignore (overrides/extends .gitignore)
99        let cc_audit_ignore = root.join(".cc-auditignore");
100        if cc_audit_ignore.exists() && builder.add(&cc_audit_ignore).is_none() {
101            has_patterns = true;
102        }
103
104        if has_patterns {
105            builder.build().ok()
106        } else {
107            None
108        }
109    }
110
111    pub fn is_ignored(&self, path: &Path) -> bool {
112        // Check default exclusions first
113        if !self.include_tests && self.is_test_path(path) {
114            return true;
115        }
116
117        if !self.include_node_modules && self.is_node_modules_path(path) {
118            return true;
119        }
120
121        if !self.include_vendor && self.is_vendor_path(path) {
122            return true;
123        }
124
125        // Check extra directories from config
126        if self.is_in_extra_directories(path) {
127            return true;
128        }
129
130        // Check custom patterns from config
131        if let Some(ref custom) = self.custom_patterns {
132            let is_dir = path.is_dir();
133            if custom.matched(path, is_dir).is_ignore() {
134                return true;
135            }
136        }
137
138        // Check .cc-auditignore patterns
139        if let Some(ref gitignore) = self.gitignore {
140            let is_dir = path.is_dir();
141            return gitignore.matched(path, is_dir).is_ignore();
142        }
143
144        false
145    }
146
147    fn is_in_extra_directories(&self, path: &Path) -> bool {
148        if self.extra_directories.is_empty() {
149            return false;
150        }
151
152        path.components().any(|c| {
153            let name = c.as_os_str().to_string_lossy();
154            self.extra_directories.contains(name.as_ref())
155        })
156    }
157
158    fn is_test_path(&self, path: &Path) -> bool {
159        path.components().any(|c| {
160            let name = c.as_os_str().to_string_lossy();
161            name == "tests"
162                || name == "test"
163                || name == "__tests__"
164                || name == "spec"
165                || name == "specs"
166                || name.ends_with("_test")
167                || name.ends_with(".test")
168        })
169    }
170
171    fn is_node_modules_path(&self, path: &Path) -> bool {
172        path.components()
173            .any(|c| c.as_os_str().to_string_lossy() == "node_modules")
174    }
175
176    fn is_vendor_path(&self, path: &Path) -> bool {
177        path.components().any(|c| {
178            let name = c.as_os_str().to_string_lossy();
179            name == "vendor" || name == "vendors" || name == "third_party"
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::fs;
188    use tempfile::TempDir;
189
190    #[test]
191    fn test_default_excludes_tests() {
192        let dir = TempDir::new().unwrap();
193        let filter = IgnoreFilter::new(dir.path());
194
195        assert!(filter.is_ignored(Path::new("/project/tests/test_file.rs")));
196        assert!(filter.is_ignored(Path::new("/project/__tests__/spec.js")));
197        assert!(filter.is_ignored(Path::new("/project/spec/helpers.rb")));
198        assert!(!filter.is_ignored(Path::new("/project/src/main.rs")));
199    }
200
201    #[test]
202    fn test_default_excludes_node_modules() {
203        let dir = TempDir::new().unwrap();
204        let filter = IgnoreFilter::new(dir.path());
205
206        assert!(filter.is_ignored(Path::new("/project/node_modules/package/index.js")));
207        assert!(!filter.is_ignored(Path::new("/project/src/index.js")));
208    }
209
210    #[test]
211    fn test_default_excludes_vendor() {
212        let dir = TempDir::new().unwrap();
213        let filter = IgnoreFilter::new(dir.path());
214
215        assert!(filter.is_ignored(Path::new("/project/vendor/bundle/gems")));
216        assert!(filter.is_ignored(Path::new("/project/third_party/lib")));
217        assert!(!filter.is_ignored(Path::new("/project/src/lib")));
218    }
219
220    #[test]
221    fn test_include_tests() {
222        let dir = TempDir::new().unwrap();
223        let filter = IgnoreFilter::new(dir.path()).with_include_tests(true);
224
225        assert!(!filter.is_ignored(Path::new("/project/tests/test_file.rs")));
226    }
227
228    #[test]
229    fn test_include_node_modules() {
230        let dir = TempDir::new().unwrap();
231        let filter = IgnoreFilter::new(dir.path()).with_include_node_modules(true);
232
233        assert!(!filter.is_ignored(Path::new("/project/node_modules/package/index.js")));
234    }
235
236    #[test]
237    fn test_include_vendor() {
238        let dir = TempDir::new().unwrap();
239        let filter = IgnoreFilter::new(dir.path()).with_include_vendor(true);
240
241        assert!(!filter.is_ignored(Path::new("/project/vendor/bundle/gems")));
242    }
243
244    #[test]
245    fn test_custom_ignorefile() {
246        let dir = TempDir::new().unwrap();
247        let ignore_file = dir.path().join(".cc-auditignore");
248        fs::write(&ignore_file, "*.generated.js\nbuild/\n").unwrap();
249
250        let filter = IgnoreFilter::new(dir.path());
251
252        let generated_file = dir.path().join("app.generated.js");
253        fs::write(&generated_file, "").unwrap();
254
255        assert!(filter.is_ignored(&generated_file));
256    }
257
258    #[test]
259    fn test_no_ignorefile() {
260        let dir = TempDir::new().unwrap();
261        let filter = IgnoreFilter::new(dir.path());
262
263        assert!(!filter.is_ignored(&dir.path().join("src/main.rs")));
264    }
265
266    #[test]
267    fn test_default_trait() {
268        let filter = IgnoreFilter::default();
269
270        // Default should exclude tests, node_modules, vendor
271        assert!(filter.is_ignored(Path::new("/project/tests/test.rs")));
272        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
273        assert!(filter.is_ignored(Path::new("/project/vendor/lib")));
274    }
275
276    #[test]
277    fn test_chained_configuration() {
278        let dir = TempDir::new().unwrap();
279        let filter = IgnoreFilter::new(dir.path())
280            .with_include_tests(true)
281            .with_include_node_modules(true)
282            .with_include_vendor(true);
283
284        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
285        assert!(!filter.is_ignored(Path::new("/project/node_modules/pkg")));
286        assert!(!filter.is_ignored(Path::new("/project/vendor/lib")));
287    }
288
289    #[test]
290    fn test_gitignore_patterns() {
291        let dir = TempDir::new().unwrap();
292        let ignore_file = dir.path().join(".cc-auditignore");
293        fs::write(
294            &ignore_file,
295            r#"
296# Comment
297*.log
298/dist/
299!important.log
300"#,
301        )
302        .unwrap();
303
304        let filter = IgnoreFilter::new(dir.path());
305
306        let log_file = dir.path().join("debug.log");
307        fs::write(&log_file, "").unwrap();
308        assert!(filter.is_ignored(&log_file));
309
310        // Normal src file should not be ignored
311        let src_file = dir.path().join("main.rs");
312        fs::write(&src_file, "").unwrap();
313        assert!(!filter.is_ignored(&src_file));
314    }
315
316    #[test]
317    fn test_is_test_path_variations() {
318        let filter = IgnoreFilter::default();
319
320        assert!(filter.is_test_path(Path::new("/project/tests/unit")));
321        assert!(filter.is_test_path(Path::new("/project/test/fixtures")));
322        assert!(filter.is_test_path(Path::new("/project/__tests__/spec")));
323        assert!(filter.is_test_path(Path::new("/project/spec/helpers")));
324        assert!(filter.is_test_path(Path::new("/project/specs/api")));
325        assert!(filter.is_test_path(Path::new("/project/file_test")));
326        assert!(filter.is_test_path(Path::new("/project/api.test")));
327        assert!(!filter.is_test_path(Path::new("/project/src/main.rs")));
328        assert!(!filter.is_test_path(Path::new("/project/contest/app.js"))); // Should not match 'test' in 'contest'
329    }
330
331    #[test]
332    fn test_from_config() {
333        use crate::config::IgnoreConfig;
334
335        let dir = TempDir::new().unwrap();
336        let config = IgnoreConfig {
337            directories: ["custom_ignore_dir", "my_cache"]
338                .into_iter()
339                .map(String::from)
340                .collect(),
341            patterns: vec!["*.generated.js".to_string()],
342            include_tests: true,
343            include_node_modules: false,
344            include_vendor: true,
345        };
346
347        let filter = IgnoreFilter::from_config(dir.path(), &config);
348
349        // Tests should NOT be ignored (include_tests is true)
350        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
351        // node_modules should be ignored (include_node_modules is false)
352        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
353        // vendor should NOT be ignored (include_vendor is true)
354        assert!(!filter.is_ignored(Path::new("/project/vendor/lib")));
355        // custom directories should be ignored
356        assert!(filter.is_ignored(Path::new("/project/custom_ignore_dir/file.rs")));
357        assert!(filter.is_ignored(Path::new("/project/my_cache/data")));
358    }
359
360    #[test]
361    fn test_extra_directories_ignored() {
362        use crate::config::IgnoreConfig;
363
364        let dir = TempDir::new().unwrap();
365        let mut config = IgnoreConfig::default();
366        config.directories.insert("my_special_dir".to_string());
367
368        let filter = IgnoreFilter::from_config(dir.path(), &config);
369
370        // Custom directory should be ignored
371        assert!(filter.is_ignored(Path::new("/project/my_special_dir/file.rs")));
372        // Nested path with custom directory should be ignored
373        assert!(filter.is_ignored(Path::new("/project/src/my_special_dir/nested/file.rs")));
374    }
375
376    #[test]
377    fn test_custom_patterns_from_config() {
378        use crate::config::IgnoreConfig;
379
380        let dir = TempDir::new().unwrap();
381        let config = IgnoreConfig {
382            directories: std::collections::HashSet::new(),
383            patterns: vec!["*.log".to_string(), "temp/**".to_string()],
384            include_tests: true,
385            include_node_modules: true,
386            include_vendor: true,
387        };
388
389        let filter = IgnoreFilter::from_config(dir.path(), &config);
390
391        // Create test files
392        let log_file = dir.path().join("debug.log");
393        fs::write(&log_file, "").unwrap();
394        assert!(filter.is_ignored(&log_file));
395
396        let temp_file = dir.path().join("temp/cache.txt");
397        fs::create_dir_all(dir.path().join("temp")).unwrap();
398        fs::write(&temp_file, "").unwrap();
399        assert!(filter.is_ignored(&temp_file));
400
401        // Normal file should not be ignored
402        let src_file = dir.path().join("main.rs");
403        fs::write(&src_file, "").unwrap();
404        assert!(!filter.is_ignored(&src_file));
405    }
406
407    #[test]
408    fn test_with_config_method() {
409        use crate::config::IgnoreConfig;
410
411        let dir = TempDir::new().unwrap();
412        let config = IgnoreConfig {
413            directories: ["target", "dist"].into_iter().map(String::from).collect(),
414            patterns: vec![],
415            include_tests: true,
416            include_node_modules: true,
417            include_vendor: false,
418        };
419
420        let filter = IgnoreFilter::new(dir.path()).with_config(&config);
421
422        // Tests should NOT be ignored
423        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
424        // node_modules should NOT be ignored
425        assert!(!filter.is_ignored(Path::new("/project/node_modules/pkg")));
426        // vendor should be ignored
427        assert!(filter.is_ignored(Path::new("/project/vendor/lib")));
428        // target should be ignored (from extra_directories)
429        assert!(filter.is_ignored(Path::new("/project/target/debug")));
430    }
431}