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