1use crate::config::IgnoreConfig;
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use std::path::Path;
8use tracing::warn;
9
10pub struct IgnoreFilter {
14 globset: Option<GlobSet>,
16}
17
18impl Default for IgnoreFilter {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl IgnoreFilter {
25 pub fn new() -> Self {
27 Self { globset: None }
28 }
29
30 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 pub fn add_pattern(&mut self, pattern: &str) -> Result<(), globset::Error> {
61 let glob = Glob::new(pattern)?;
62
63 let mut builder = GlobSetBuilder::new();
65 builder.add(glob);
66
67 self.globset = Some(builder.build()?);
68
69 Ok(())
70 }
71
72 pub fn is_ignored(&self, path: &Path) -> bool {
77 if let Some(ref globset) = self.globset {
78 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(), "**/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 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 }
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}