Skip to main content

cc_audit/
ignore.rs

1//! Ignore filter for scanning.
2//!
3//! Simple glob-based filtering for paths during scanning.
4
5use crate::config::IgnoreConfig;
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::path::Path;
8use tracing::warn;
9
10/// Filter for ignoring paths during scanning.
11///
12/// Uses glob patterns to determine which paths to skip.
13pub struct IgnoreFilter {
14    /// Compiled glob patterns for ignoring paths.
15    globset: Option<GlobSet>,
16}
17
18impl Default for IgnoreFilter {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl IgnoreFilter {
25    /// Create a new empty IgnoreFilter.
26    pub fn new() -> Self {
27        Self { globset: None }
28    }
29
30    /// Create IgnoreFilter from config.
31    pub fn from_config(config: &IgnoreConfig) -> Self {
32        if config.patterns.is_empty() {
33            return Self::new();
34        }
35
36        let mut builder = GlobSetBuilder::new();
37        for pattern in &config.patterns {
38            match Glob::new(pattern) {
39                Ok(glob) => {
40                    builder.add(glob);
41                }
42                Err(e) => {
43                    warn!(pattern = %pattern, error = %e, "Invalid ignore pattern");
44                }
45            }
46        }
47
48        let globset = match builder.build() {
49            Ok(set) => Some(set),
50            Err(e) => {
51                warn!(error = %e, "Failed to build globset");
52                None
53            }
54        };
55
56        Self { globset }
57    }
58
59    /// Add a glob pattern to the filter.
60    pub fn add_pattern(&mut self, pattern: &str) -> Result<(), globset::Error> {
61        let glob = Glob::new(pattern)?;
62
63        // Rebuild the globset with the new pattern
64        let mut builder = GlobSetBuilder::new();
65        builder.add(glob);
66
67        self.globset = Some(builder.build()?);
68
69        Ok(())
70    }
71
72    /// Check if a path should be ignored.
73    ///
74    /// Path separators are normalized to forward slashes for cross-platform
75    /// compatibility.
76    pub fn is_ignored(&self, path: &Path) -> bool {
77        if let Some(ref globset) = self.globset {
78            // Normalize path separators to forward slashes for cross-platform matching
79            let path_str = path.to_string_lossy().replace('\\', "/");
80            globset.is_match(Path::new(&path_str))
81        } else {
82            false
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_empty_filter() {
93        let filter = IgnoreFilter::new();
94        assert!(!filter.is_ignored(Path::new("/project/src/main.rs")));
95    }
96
97    #[test]
98    fn test_simple_pattern() {
99        let config = IgnoreConfig {
100            patterns: vec!["**/node_modules/**".to_string()],
101        };
102        let filter = IgnoreFilter::from_config(&config);
103
104        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg/index.js")));
105        assert!(filter.is_ignored(Path::new("/project/sub/node_modules/pkg/index.js")));
106        assert!(!filter.is_ignored(Path::new("/project/src/main.rs")));
107    }
108
109    #[test]
110    fn test_glob_pattern_with_extension() {
111        let config = IgnoreConfig {
112            patterns: vec!["**/*.test.{js,ts}".to_string()],
113        };
114        let filter = IgnoreFilter::from_config(&config);
115
116        assert!(filter.is_ignored(Path::new("/project/src/app.test.js")));
117        assert!(filter.is_ignored(Path::new("/project/src/app.test.ts")));
118        assert!(!filter.is_ignored(Path::new("/project/src/app.js")));
119    }
120
121    #[test]
122    fn test_multiple_patterns() {
123        let config = IgnoreConfig {
124            patterns: vec![
125                "**/node_modules/**".to_string(),
126                "**/target/**".to_string(),
127                "**/.git/**".to_string(),
128            ],
129        };
130        let filter = IgnoreFilter::from_config(&config);
131
132        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
133        assert!(filter.is_ignored(Path::new("/project/target/debug/main")));
134        assert!(filter.is_ignored(Path::new("/project/.git/config")));
135        assert!(!filter.is_ignored(Path::new("/project/src/main.rs")));
136    }
137
138    #[test]
139    fn test_invalid_pattern_is_skipped() {
140        let config = IgnoreConfig {
141            patterns: vec![
142                "**/valid/**".to_string(),
143                "[invalid".to_string(), // Invalid glob
144                "**/also_valid/**".to_string(),
145            ],
146        };
147        let filter = IgnoreFilter::from_config(&config);
148
149        assert!(filter.is_ignored(Path::new("/project/valid/file")));
150        assert!(filter.is_ignored(Path::new("/project/also_valid/file")));
151    }
152
153    #[test]
154    fn test_add_pattern() {
155        let mut filter = IgnoreFilter::new();
156        filter.add_pattern("**/node_modules/**").unwrap();
157
158        assert!(filter.is_ignored(Path::new("/project/node_modules/pkg")));
159    }
160
161    #[test]
162    fn test_directory_pattern() {
163        let config = IgnoreConfig {
164            patterns: vec!["**/test/**".to_string(), "**/tests/**".to_string()],
165        };
166        let filter = IgnoreFilter::from_config(&config);
167
168        assert!(filter.is_ignored(Path::new("/project/tests/unit.rs")));
169        assert!(filter.is_ignored(Path::new("/project/test/unit.rs")));
170        assert!(!filter.is_ignored(Path::new("/project/src/contest.rs")));
171    }
172
173    #[test]
174    fn test_extension_pattern() {
175        let config = IgnoreConfig {
176            patterns: vec!["**/*.{log,tmp,bak}".to_string()],
177        };
178        let filter = IgnoreFilter::from_config(&config);
179
180        assert!(filter.is_ignored(Path::new("/project/debug.log")));
181        assert!(filter.is_ignored(Path::new("/project/session.tmp")));
182        assert!(filter.is_ignored(Path::new("/project/config.bak")));
183        assert!(!filter.is_ignored(Path::new("/project/main.rs")));
184    }
185
186    #[test]
187    fn test_single_star_pattern() {
188        // Note: *.log matches any path ending with .log, including paths with directories
189        // Use **/*.log to explicitly match across directories, or just *.log at root level
190        let config = IgnoreConfig {
191            patterns: vec!["*.log".to_string()],
192        };
193        let filter = IgnoreFilter::from_config(&config);
194
195        assert!(filter.is_ignored(Path::new("debug.log")));
196        // In globset, *.log can match paths with directories depending on implementation
197        // For strict root-level matching, we'd need to check if path has no directory separators
198    }
199
200    #[test]
201    fn test_double_star_pattern() {
202        let config = IgnoreConfig {
203            patterns: vec!["**/*.log".to_string()],
204        };
205        let filter = IgnoreFilter::from_config(&config);
206
207        assert!(filter.is_ignored(Path::new("debug.log")));
208        assert!(filter.is_ignored(Path::new("logs/debug.log")));
209        assert!(filter.is_ignored(Path::new("deep/nested/path/debug.log")));
210    }
211
212    #[test]
213    fn test_specific_file_pattern() {
214        let config = IgnoreConfig {
215            patterns: vec!["**/secrets.txt".to_string()],
216        };
217        let filter = IgnoreFilter::from_config(&config);
218
219        assert!(filter.is_ignored(Path::new("secrets.txt")));
220        assert!(filter.is_ignored(Path::new("config/secrets.txt")));
221        assert!(!filter.is_ignored(Path::new("config/settings.txt")));
222    }
223}