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