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            // Skip node_modules check if include_node_modules is true
155            if self.include_node_modules && name == "node_modules" {
156                return false;
157            }
158            // Skip vendor check if include_vendor is true
159            if self.include_vendor
160                && (name == "vendor" || name == "vendors" || name == "third_party")
161            {
162                return false;
163            }
164            self.extra_directories.contains(name.as_ref())
165        })
166    }
167
168    fn is_test_path(&self, path: &Path) -> bool {
169        path.components().any(|c| {
170            let name = c.as_os_str().to_string_lossy();
171            name == "tests"
172                || name == "test"
173                || name == "__tests__"
174                || name == "spec"
175                || name == "specs"
176                || name.ends_with("_test")
177                || name.ends_with(".test")
178        })
179    }
180
181    fn is_node_modules_path(&self, path: &Path) -> bool {
182        path.components()
183            .any(|c| c.as_os_str().to_string_lossy() == "node_modules")
184    }
185
186    fn is_vendor_path(&self, path: &Path) -> bool {
187        path.components().any(|c| {
188            let name = c.as_os_str().to_string_lossy();
189            name == "vendor" || name == "vendors" || name == "third_party"
190        })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::fs;
198    use tempfile::TempDir;
199
200    #[test]
201    fn test_default_excludes_tests() {
202        let dir = TempDir::new().unwrap();
203        let filter = IgnoreFilter::new(dir.path());
204
205        assert!(filter.is_ignored(Path::new("/project/tests/test_file.rs")));
206        assert!(filter.is_ignored(Path::new("/project/__tests__/spec.js")));
207        assert!(filter.is_ignored(Path::new("/project/spec/helpers.rb")));
208        assert!(!filter.is_ignored(Path::new("/project/src/main.rs")));
209    }
210
211    #[test]
212    fn test_default_excludes_node_modules() {
213        let dir = TempDir::new().unwrap();
214        let filter = IgnoreFilter::new(dir.path());
215
216        assert!(filter.is_ignored(Path::new("/project/node_modules/package/index.js")));
217        assert!(!filter.is_ignored(Path::new("/project/src/index.js")));
218    }
219
220    #[test]
221    fn test_default_excludes_vendor() {
222        let dir = TempDir::new().unwrap();
223        let filter = IgnoreFilter::new(dir.path());
224
225        assert!(filter.is_ignored(Path::new("/project/vendor/bundle/gems")));
226        assert!(filter.is_ignored(Path::new("/project/third_party/lib")));
227        assert!(!filter.is_ignored(Path::new("/project/src/lib")));
228    }
229
230    #[test]
231    fn test_include_tests() {
232        let dir = TempDir::new().unwrap();
233        let filter = IgnoreFilter::new(dir.path()).with_include_tests(true);
234
235        assert!(!filter.is_ignored(Path::new("/project/tests/test_file.rs")));
236    }
237
238    #[test]
239    fn test_include_node_modules() {
240        let dir = TempDir::new().unwrap();
241        let filter = IgnoreFilter::new(dir.path()).with_include_node_modules(true);
242
243        assert!(!filter.is_ignored(Path::new("/project/node_modules/package/index.js")));
244    }
245
246    #[test]
247    fn test_include_vendor() {
248        let dir = TempDir::new().unwrap();
249        let filter = IgnoreFilter::new(dir.path()).with_include_vendor(true);
250
251        assert!(!filter.is_ignored(Path::new("/project/vendor/bundle/gems")));
252    }
253
254    #[test]
255    fn test_custom_ignorefile() {
256        let dir = TempDir::new().unwrap();
257        let ignore_file = dir.path().join(".cc-auditignore");
258        fs::write(&ignore_file, "*.generated.js\nbuild/\n").unwrap();
259
260        let filter = IgnoreFilter::new(dir.path());
261
262        let generated_file = dir.path().join("app.generated.js");
263        fs::write(&generated_file, "").unwrap();
264
265        assert!(filter.is_ignored(&generated_file));
266    }
267
268    #[test]
269    fn test_no_ignorefile() {
270        let dir = TempDir::new().unwrap();
271        let filter = IgnoreFilter::new(dir.path());
272
273        assert!(!filter.is_ignored(&dir.path().join("src/main.rs")));
274    }
275
276    #[test]
277    fn test_default_trait() {
278        let filter = IgnoreFilter::default();
279
280        // Default should exclude tests, node_modules, vendor
281        assert!(filter.is_ignored(Path::new("/project/tests/test.rs")));
282        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
283        assert!(filter.is_ignored(Path::new("/project/vendor/lib")));
284    }
285
286    #[test]
287    fn test_chained_configuration() {
288        let dir = TempDir::new().unwrap();
289        let filter = IgnoreFilter::new(dir.path())
290            .with_include_tests(true)
291            .with_include_node_modules(true)
292            .with_include_vendor(true);
293
294        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
295        assert!(!filter.is_ignored(Path::new("/project/node_modules/pkg")));
296        assert!(!filter.is_ignored(Path::new("/project/vendor/lib")));
297    }
298
299    #[test]
300    fn test_gitignore_patterns() {
301        let dir = TempDir::new().unwrap();
302        let ignore_file = dir.path().join(".cc-auditignore");
303        fs::write(
304            &ignore_file,
305            r#"
306# Comment
307*.log
308/dist/
309!important.log
310"#,
311        )
312        .unwrap();
313
314        let filter = IgnoreFilter::new(dir.path());
315
316        let log_file = dir.path().join("debug.log");
317        fs::write(&log_file, "").unwrap();
318        assert!(filter.is_ignored(&log_file));
319
320        // Normal src file should not be ignored
321        let src_file = dir.path().join("main.rs");
322        fs::write(&src_file, "").unwrap();
323        assert!(!filter.is_ignored(&src_file));
324    }
325
326    #[test]
327    fn test_is_test_path_variations() {
328        let filter = IgnoreFilter::default();
329
330        assert!(filter.is_test_path(Path::new("/project/tests/unit")));
331        assert!(filter.is_test_path(Path::new("/project/test/fixtures")));
332        assert!(filter.is_test_path(Path::new("/project/__tests__/spec")));
333        assert!(filter.is_test_path(Path::new("/project/spec/helpers")));
334        assert!(filter.is_test_path(Path::new("/project/specs/api")));
335        assert!(filter.is_test_path(Path::new("/project/file_test")));
336        assert!(filter.is_test_path(Path::new("/project/api.test")));
337        assert!(!filter.is_test_path(Path::new("/project/src/main.rs")));
338        assert!(!filter.is_test_path(Path::new("/project/contest/app.js"))); // Should not match 'test' in 'contest'
339    }
340
341    #[test]
342    fn test_from_config() {
343        use crate::config::IgnoreConfig;
344
345        let dir = TempDir::new().unwrap();
346        let config = IgnoreConfig {
347            directories: ["custom_ignore_dir", "my_cache"]
348                .into_iter()
349                .map(String::from)
350                .collect(),
351            patterns: vec!["*.generated.js".to_string()],
352            include_tests: true,
353            include_node_modules: false,
354            include_vendor: true,
355        };
356
357        let filter = IgnoreFilter::from_config(dir.path(), &config);
358
359        // Tests should NOT be ignored (include_tests is true)
360        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
361        // node_modules should be ignored (include_node_modules is false)
362        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
363        // vendor should NOT be ignored (include_vendor is true)
364        assert!(!filter.is_ignored(Path::new("/project/vendor/lib")));
365        // custom directories should be ignored
366        assert!(filter.is_ignored(Path::new("/project/custom_ignore_dir/file.rs")));
367        assert!(filter.is_ignored(Path::new("/project/my_cache/data")));
368    }
369
370    #[test]
371    fn test_extra_directories_ignored() {
372        use crate::config::IgnoreConfig;
373
374        let dir = TempDir::new().unwrap();
375        let mut config = IgnoreConfig::default();
376        config.directories.insert("my_special_dir".to_string());
377
378        let filter = IgnoreFilter::from_config(dir.path(), &config);
379
380        // Custom directory should be ignored
381        assert!(filter.is_ignored(Path::new("/project/my_special_dir/file.rs")));
382        // Nested path with custom directory should be ignored
383        assert!(filter.is_ignored(Path::new("/project/src/my_special_dir/nested/file.rs")));
384    }
385
386    #[test]
387    fn test_custom_patterns_from_config() {
388        use crate::config::IgnoreConfig;
389
390        let dir = TempDir::new().unwrap();
391        let config = IgnoreConfig {
392            directories: std::collections::HashSet::new(),
393            patterns: vec!["*.log".to_string(), "temp/**".to_string()],
394            include_tests: true,
395            include_node_modules: true,
396            include_vendor: true,
397        };
398
399        let filter = IgnoreFilter::from_config(dir.path(), &config);
400
401        // Create test files
402        let log_file = dir.path().join("debug.log");
403        fs::write(&log_file, "").unwrap();
404        assert!(filter.is_ignored(&log_file));
405
406        let temp_file = dir.path().join("temp/cache.txt");
407        fs::create_dir_all(dir.path().join("temp")).unwrap();
408        fs::write(&temp_file, "").unwrap();
409        assert!(filter.is_ignored(&temp_file));
410
411        // Normal file should not be ignored
412        let src_file = dir.path().join("main.rs");
413        fs::write(&src_file, "").unwrap();
414        assert!(!filter.is_ignored(&src_file));
415    }
416
417    #[test]
418    fn test_with_config_method() {
419        use crate::config::IgnoreConfig;
420
421        let dir = TempDir::new().unwrap();
422        let config = IgnoreConfig {
423            directories: ["target", "dist"].into_iter().map(String::from).collect(),
424            patterns: vec![],
425            include_tests: true,
426            include_node_modules: true,
427            include_vendor: false,
428        };
429
430        let filter = IgnoreFilter::new(dir.path()).with_config(&config);
431
432        // Tests should NOT be ignored
433        assert!(!filter.is_ignored(Path::new("/project/tests/test.rs")));
434        // node_modules should NOT be ignored
435        assert!(!filter.is_ignored(Path::new("/project/node_modules/pkg")));
436        // vendor should be ignored
437        assert!(filter.is_ignored(Path::new("/project/vendor/lib")));
438        // target should be ignored (from extra_directories)
439        assert!(filter.is_ignored(Path::new("/project/target/debug")));
440    }
441}