Skip to main content

rust_guardian/patterns/
path_filter.rs

1//! Path filtering using .gitignore-style patterns
2//!
3//! Architectural Principle: Service Layer - PathFilter orchestrates complex path matching logic
4//! - Encapsulates the rules for include/exclude pattern evaluation
5//! - Provides clean interface for determining whether a path should be analyzed
6//! - Handles .guardianignore file discovery and parsing
7
8use crate::domain::violations::{GuardianError, GuardianResult};
9use std::fs;
10use std::path::{Path, PathBuf};
11use walkdir::WalkDir;
12
13/// Manages path filtering using .gitignore-style patterns
14#[derive(Debug, Clone)]
15pub struct PathFilter {
16    /// Include/exclude patterns
17    patterns: Vec<FilterPattern>,
18    /// Whether to process .guardianignore files
19    process_ignore_files: bool,
20    /// Name of ignore files to process
21    ignore_filename: String,
22}
23
24/// A single path filter pattern
25#[derive(Debug, Clone)]
26struct FilterPattern {
27    /// The glob pattern
28    pattern: glob::Pattern,
29    /// Whether this is an include pattern (starts with !)
30    is_include: bool,
31    /// Original pattern string for debugging
32    original: String,
33}
34
35impl PathFilter {
36    /// Create a new path filter with the given patterns
37    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    /// Create a default path filter with sensible exclusions
66    pub fn with_defaults() -> GuardianResult<Self> {
67        Self::new(
68            vec![
69                // Exclude common build/cache directories
70                "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    /// Check if a file should be analyzed based on all patterns and ignore files
82    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        // Start with default: include all files
87        let mut should_include = true;
88
89        // Apply patterns in order (like .gitignore)
90        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 excluded by configured patterns, return false
99        if !should_include {
100            return Ok(false);
101        }
102
103        // Check .guardianignore files if enabled
104        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    /// Check if path is ignored by .guardianignore files
115    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        // Walk up the directory tree looking for .guardianignore files
121        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                // Check if any pattern in this file matches
128                for pattern in patterns {
129                    // Make path relative to the ignore file's directory
130                    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    /// Load patterns from a .guardianignore file
147    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            // Skip empty lines and comments
162            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                    // Log warning but don't fail - just skip invalid patterns
182                    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    /// Get all files that should be analyzed in a directory tree
196    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        // OPTIMIZATION: Use filter_entry to skip massive directories BEFORE entering them
201        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                // SKIP common massive directories to prevent IO floods
208                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            // Only process files, not directories
225            if path.is_file() && self.should_analyze(path)? {
226                files.push(path.to_path_buf());
227            }
228        }
229
230        Ok(files)
231    }
232
233    /// Filter a list of paths to only those that should be analyzed
234    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    /// Add a pattern to the filter
247    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    /// Get debug information about patterns and their matches
267    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    /// Check if a pattern matches a path using .gitignore-style rules
288    fn pattern_matches_path(&self, pattern: &FilterPattern, path: &Path) -> bool {
289        let path_str = path.to_string_lossy();
290
291        // Handle different pattern types
292        if pattern.original.ends_with('/') {
293            // Directory pattern - only match directories
294            if !path.is_dir() {
295                return false;
296            }
297            // Remove trailing slash and match
298            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            // Absolute pattern from root - remove leading slash and match from beginning
306            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            // Pattern contains slash - match full path
317            return pattern.pattern.matches(&path_str);
318        } else {
319            // No slash - match filename only
320            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/// Architecture-compliant validation functions for integration testing
330#[cfg(test)]
331#[allow(dead_code)]
332pub mod validation {
333    use super::*;
334    use std::fs;
335    use tempfile::TempDir;
336
337    /// Validate basic pattern matching functionality - designed for integration testing
338    pub fn validate_basic_pattern_matching() -> GuardianResult<()> {
339        let filter = PathFilter::new(
340            vec![
341                "target/**".to_string(), // Exclude target directory
342                "*.md".to_string(),      // Exclude markdown files
343            ],
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    /// Validate include override functionality - designed for integration testing
369    pub fn validate_include_override() -> GuardianResult<()> {
370        let filter = PathFilter::new(
371            vec![
372                "target/**".to_string(),          // Exclude target
373                "!target/special/**".to_string(), // But include target/special
374            ],
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    /// Validate pattern order functionality - designed for integration testing
394    pub fn validate_pattern_order() -> GuardianResult<()> {
395        let filter = PathFilter::new(
396            vec![
397                "tests/**".to_string(),            // Exclude tests
398                "!tests/important.rs".to_string(), // But include important test
399                "!*.rs".to_string(),               // And include all .rs files (overrides excludes)
400            ],
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    /// Validate guardianignore file functionality - designed for integration testing
426    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        // Create directory structure
432        fs::create_dir_all(root.join("src"))?;
433        fs::create_dir_all(root.join("tests"))?;
434
435        // Create .guardianignore file
436        fs::write(
437            root.join(".guardianignore"),
438            "*.tmp\ntests/**\n!tests/important.rs\n",
439        )?;
440
441        // Create test files
442        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    /// Validate invalid pattern handling - designed for integration testing
477    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    /// Validate default filter functionality - designed for integration testing
489    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}