1use crate::domain::violations::{GuardianError, GuardianResult};
9use std::fs;
10use std::path::{Path, PathBuf};
11use walkdir::WalkDir;
12
13#[derive(Debug, Clone)]
15pub struct PathFilter {
16 patterns: Vec<FilterPattern>,
18 process_ignore_files: bool,
20 ignore_filename: String,
22}
23
24#[derive(Debug, Clone)]
26struct FilterPattern {
27 pattern: glob::Pattern,
29 is_include: bool,
31 original: String,
33}
34
35impl PathFilter {
36 pub fn new(patterns: Vec<String>, ignore_filename: Option<String>) -> GuardianResult<Self> {
38 let mut filter_patterns = Vec::new();
39
40 for pattern_str in patterns {
41 let (is_include, pattern_str) = if let Some(stripped) = pattern_str.strip_prefix('!') {
42 (true, stripped.to_string())
43 } else {
44 (false, pattern_str)
45 };
46
47 let pattern = glob::Pattern::new(&pattern_str).map_err(|e| {
48 GuardianError::pattern(format!("Invalid pattern '{pattern_str}': {e}"))
49 })?;
50
51 filter_patterns.push(FilterPattern {
52 pattern,
53 is_include,
54 original: pattern_str,
55 });
56 }
57
58 Ok(Self {
59 patterns: filter_patterns,
60 process_ignore_files: ignore_filename.is_some(),
61 ignore_filename: ignore_filename.unwrap_or_else(|| ".guardianignore".to_string()),
62 })
63 }
64
65 pub fn with_defaults() -> GuardianResult<Self> {
67 Self::new(
68 vec![
69 "target/**".to_string(),
71 "**/node_modules/**".to_string(),
72 "**/.git/**".to_string(),
73 "**/*.generated.*".to_string(),
74 "**/dist/**".to_string(),
75 "**/build/**".to_string(),
76 ],
77 Some(".guardianignore".to_string()),
78 )
79 }
80
81 pub fn should_analyze<P: AsRef<Path>>(&self, path: P) -> GuardianResult<bool> {
83 let path = path.as_ref();
84 let _path_str = path.to_string_lossy();
85
86 let mut should_include = true;
88
89 for pattern in &self.patterns {
91 let matches = self.pattern_matches_path(pattern, path);
92
93 if matches {
94 should_include = pattern.is_include;
95 }
96 }
97
98 if !should_include {
100 return Ok(false);
101 }
102
103 if self.process_ignore_files {
105 let ignored_by_files = self.is_ignored_by_files(path)?;
106 if ignored_by_files {
107 return Ok(false);
108 }
109 }
110
111 Ok(true)
112 }
113
114 fn is_ignored_by_files<P: AsRef<Path>>(&self, path: P) -> GuardianResult<bool> {
116 let path = path.as_ref();
117 let mut current_dir = path.parent();
118 let mut is_ignored = false;
119
120 while let Some(dir) = current_dir {
122 let ignore_file = dir.join(&self.ignore_filename);
123
124 if ignore_file.exists() {
125 let patterns = self.load_ignore_file(&ignore_file)?;
126
127 for pattern in patterns {
129 if let Ok(relative_path) = path.strip_prefix(dir) {
131 let matches = self.pattern_matches_path(&pattern, relative_path);
132
133 if matches {
134 is_ignored = !pattern.is_include;
135 }
136 }
137 }
138 }
139
140 current_dir = dir.parent();
141 }
142
143 Ok(is_ignored)
144 }
145
146 fn load_ignore_file<P: AsRef<Path>>(&self, path: P) -> GuardianResult<Vec<FilterPattern>> {
148 let content = fs::read_to_string(&path).map_err(|e| {
149 GuardianError::config(format!(
150 "Failed to read ignore file '{}': {}",
151 path.as_ref().display(),
152 e
153 ))
154 })?;
155
156 let mut patterns = Vec::new();
157
158 for line in content.lines() {
159 let line = line.trim();
160
161 if line.is_empty() || line.starts_with('#') {
163 continue;
164 }
165
166 let (is_include, pattern_str) = if let Some(stripped) = line.strip_prefix('!') {
167 (true, stripped.to_string())
168 } else {
169 (false, line.to_string())
170 };
171
172 match glob::Pattern::new(&pattern_str) {
173 Ok(pattern) => {
174 patterns.push(FilterPattern {
175 pattern,
176 is_include,
177 original: pattern_str,
178 });
179 }
180 Err(e) => {
181 tracing::warn!(
183 "Invalid pattern '{}' in {}: {}",
184 pattern_str,
185 path.as_ref().display(),
186 e
187 );
188 }
189 }
190 }
191
192 Ok(patterns)
193 }
194
195 pub fn find_files<P: AsRef<Path>>(&self, root: P) -> GuardianResult<Vec<PathBuf>> {
197 let root = root.as_ref();
198 let mut files = Vec::new();
199
200 let walker = WalkDir::new(root)
202 .follow_links(false)
203 .into_iter()
204 .filter_entry(|e| {
205 let name = e.file_name().to_string_lossy();
206
207 if name == ".git"
209 || name == "target"
210 || name == "node_modules"
211 || name == ".venv"
212 || name == "venv"
213 || name == ".idea"
214 || name == ".vscode"
215 {
216 return false;
217 }
218 true
219 });
220
221 for entry in walker.filter_map(|e| e.ok()) {
222 let path = entry.path();
223
224 if path.is_file() && self.should_analyze(path)? {
226 files.push(path.to_path_buf());
227 }
228 }
229
230 Ok(files)
231 }
232
233 pub fn filter_paths<P: AsRef<Path>>(&self, paths: &[P]) -> GuardianResult<Vec<PathBuf>> {
235 let mut filtered = Vec::new();
236
237 for path in paths {
238 if self.should_analyze(path)? {
239 filtered.push(path.as_ref().to_path_buf());
240 }
241 }
242
243 Ok(filtered)
244 }
245
246 pub fn add_pattern(&mut self, pattern: String) -> GuardianResult<()> {
248 let (is_include, pattern_str) = if let Some(stripped) = pattern.strip_prefix('!') {
249 (true, stripped.to_string())
250 } else {
251 (false, pattern)
252 };
253
254 let glob_pattern = glob::Pattern::new(&pattern_str)
255 .map_err(|e| GuardianError::pattern(format!("Invalid pattern '{pattern_str}': {e}")))?;
256
257 self.patterns.push(FilterPattern {
258 pattern: glob_pattern,
259 is_include,
260 original: pattern_str,
261 });
262
263 Ok(())
264 }
265
266 pub fn debug_patterns<P: AsRef<Path>>(&self, path: P) -> Vec<String> {
268 let path = path.as_ref();
269 let mut debug_info = Vec::new();
270
271 for (i, pattern) in self.patterns.iter().enumerate() {
272 let matches = self.pattern_matches_path(pattern, path);
273 let prefix = if pattern.is_include { "!" } else { "" };
274
275 debug_info.push(format!(
276 "Pattern {}: {}{} -> {}",
277 i,
278 prefix,
279 pattern.original,
280 if matches { "MATCH" } else { "no match" }
281 ));
282 }
283
284 debug_info
285 }
286
287 fn pattern_matches_path(&self, pattern: &FilterPattern, path: &Path) -> bool {
289 let path_str = path.to_string_lossy();
290
291 if pattern.original.ends_with('/') {
293 if !path.is_dir() {
295 return false;
296 }
297 let dir_pattern = pattern.original.trim_end_matches('/');
299 return glob::Pattern::new(dir_pattern)
300 .map(|p| p.matches(&path_str))
301 .unwrap_or(false);
302 }
303
304 if pattern.original.starts_with('/') {
305 let absolute_pattern = pattern
307 .original
308 .strip_prefix('/')
309 .unwrap_or(&pattern.original);
310 return glob::Pattern::new(absolute_pattern)
311 .map(|p| p.matches(&path_str))
312 .unwrap_or(false);
313 }
314
315 if pattern.original.contains('/') {
316 return pattern.pattern.matches(&path_str);
318 } else {
319 if let Some(filename) = path.file_name() {
321 return pattern.pattern.matches(&filename.to_string_lossy());
322 }
323 }
324
325 false
326 }
327}
328
329#[cfg(test)]
331#[allow(dead_code)]
332pub mod validation {
333 use super::*;
334 use std::fs;
335 use tempfile::TempDir;
336
337 pub fn validate_basic_pattern_matching() -> GuardianResult<()> {
339 let filter = PathFilter::new(
340 vec![
341 "target/**".to_string(), "*.md".to_string(), ],
344 None,
345 )?;
346
347 if !filter.should_analyze(Path::new("src/lib.rs"))? {
348 return Err(GuardianError::pattern(
349 "Basic pattern validation failed - should analyze src files",
350 ));
351 }
352
353 if filter.should_analyze(Path::new("target/debug/lib.rs"))? {
354 return Err(GuardianError::pattern(
355 "Basic pattern validation failed - should exclude target files",
356 ));
357 }
358
359 if filter.should_analyze(Path::new("README.md"))? {
360 return Err(GuardianError::pattern(
361 "Basic pattern validation failed - should exclude markdown files",
362 ));
363 }
364
365 Ok(())
366 }
367
368 pub fn validate_include_override() -> GuardianResult<()> {
370 let filter = PathFilter::new(
371 vec![
372 "target/**".to_string(), "!target/special/**".to_string(), ],
375 None,
376 )?;
377
378 if filter.should_analyze(Path::new("target/debug/lib.rs"))? {
379 return Err(GuardianError::pattern(
380 "Include override validation failed - should exclude target/debug",
381 ));
382 }
383
384 if !filter.should_analyze(Path::new("target/special/lib.rs"))? {
385 return Err(GuardianError::pattern(
386 "Include override validation failed - should include target/special",
387 ));
388 }
389
390 Ok(())
391 }
392
393 pub fn validate_pattern_order() -> GuardianResult<()> {
395 let filter = PathFilter::new(
396 vec![
397 "tests/**".to_string(), "!tests/important.rs".to_string(), "!*.rs".to_string(), ],
401 None,
402 )?;
403
404 if !filter.should_analyze(Path::new("src/lib.rs"))? {
405 return Err(GuardianError::pattern(
406 "Pattern order validation failed - should analyze src files",
407 ));
408 }
409
410 if !filter.should_analyze(Path::new("tests/unit.rs"))? {
411 return Err(GuardianError::pattern(
412 "Pattern order validation failed - should analyze test files with overrides",
413 ));
414 }
415
416 if !filter.should_analyze(Path::new("tests/important.rs"))? {
417 return Err(GuardianError::pattern(
418 "Pattern order validation failed - should analyze important test files",
419 ));
420 }
421
422 Ok(())
423 }
424
425 pub fn validate_guardianignore_file() -> GuardianResult<()> {
427 let temp_dir = TempDir::new()
428 .map_err(|e| GuardianError::config(format!("Failed to create temp dir: {}", e)))?;
429 let root = temp_dir.path();
430
431 fs::create_dir_all(root.join("src"))?;
433 fs::create_dir_all(root.join("tests"))?;
434
435 fs::write(
437 root.join(".guardianignore"),
438 "*.tmp\ntests/**\n!tests/important.rs\n",
439 )?;
440
441 fs::write(root.join("src/lib.rs"), "")?;
443 fs::write(root.join("temp.tmp"), "")?;
444 fs::write(root.join("tests/unit.rs"), "")?;
445 fs::write(root.join("tests/important.rs"), "")?;
446
447 let filter = PathFilter::new(vec![], Some(".guardianignore".to_string()))?;
448
449 if !filter.should_analyze(root.join("src/lib.rs"))? {
450 return Err(GuardianError::pattern(
451 "Guardianignore validation failed - should analyze src files",
452 ));
453 }
454
455 if filter.should_analyze(root.join("temp.tmp"))? {
456 return Err(GuardianError::pattern(
457 "Guardianignore validation failed - should exclude tmp files",
458 ));
459 }
460
461 if filter.should_analyze(root.join("tests/unit.rs"))? {
462 return Err(GuardianError::pattern(
463 "Guardianignore validation failed - should exclude test files",
464 ));
465 }
466
467 if !filter.should_analyze(root.join("tests/important.rs"))? {
468 return Err(GuardianError::pattern(
469 "Guardianignore validation failed - should include important files",
470 ));
471 }
472
473 Ok(())
474 }
475
476 pub fn validate_invalid_pattern_handling() -> GuardianResult<()> {
478 let result = PathFilter::new(vec!["[invalid".to_string()], None);
479 if result.is_ok() {
480 return Err(GuardianError::pattern(
481 "Invalid pattern validation failed - should reject invalid patterns",
482 ));
483 }
484
485 Ok(())
486 }
487
488 pub fn validate_default_filter() -> GuardianResult<()> {
490 let filter = PathFilter::with_defaults()?;
491
492 if filter.should_analyze(Path::new("target/debug/lib.rs"))? {
493 return Err(GuardianError::pattern(
494 "Default filter validation failed - should exclude target directory",
495 ));
496 }
497
498 if !filter.should_analyze(Path::new("src/lib.rs"))? {
499 return Err(GuardianError::pattern(
500 "Default filter validation failed - should include source files",
501 ));
502 }
503
504 Ok(())
505 }
506}