scribe_patterns/
gitignore.rs

1//! Gitignore pattern handling with proper precedence and syntax support.
2//!
3//! This module provides comprehensive gitignore functionality including:
4//! - Full gitignore syntax support (negation, directory matching, etc.)
5//! - Proper precedence handling for multiple gitignore files
6//! - Integration with the ignore crate for performance
7//! - Support for .gitignore, .ignore, and custom ignore files
8
9use scribe_core::{Result, ScribeError};
10use std::path::{Path, PathBuf};
11use std::fs;
12use std::io::{BufRead, BufReader};
13// use std::collections::HashMap; // Not needed currently
14use ignore::{WalkBuilder, overrides::OverrideBuilder};
15use serde::{Serialize, Deserialize};
16
17/// Gitignore pattern matcher with full syntax support
18#[derive(Debug)]
19pub struct GitignoreMatcher {
20    patterns: Vec<GitignorePattern>,
21    ignore_files: Vec<IgnoreFile>,
22    overrides: Option<ignore::overrides::Override>,
23    case_sensitive: bool,
24    require_literal_separator: bool,
25}
26
27/// Individual gitignore pattern with parsing and matching
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct GitignorePattern {
30    pub original: String,
31    pub pattern: String,
32    pub negated: bool,
33    pub directory_only: bool,
34    pub anchored: bool,
35    pub rule_type: GitignoreRule,
36}
37
38/// Type of gitignore rule
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub enum GitignoreRule {
41    Include,
42    Exclude,
43    Comment,
44    Empty,
45}
46
47/// Information about a loaded ignore file
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IgnoreFile {
50    pub path: PathBuf,
51    pub ignore_type: IgnoreType,
52    pub patterns: Vec<GitignorePattern>,
53    pub line_count: usize,
54}
55
56/// Type of ignore file
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub enum IgnoreType {
59    Gitignore,
60    GlobalGitignore,
61    CustomIgnore,
62    DotIgnore,
63}
64
65/// Match result for gitignore patterns
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct IgnoreMatchResult {
68    pub ignored: bool,
69    pub matched_pattern: Option<String>,
70    pub matched_file: Option<PathBuf>,
71    pub rule_type: GitignoreRule,
72    pub line_number: Option<usize>,
73}
74
75impl GitignorePattern {
76    /// Create a new gitignore pattern from a line
77    pub fn new(line: &str) -> Result<Self> {
78        let trimmed = line.trim();
79        
80        // Handle empty lines and comments
81        if trimmed.is_empty() {
82            return Ok(Self {
83                original: line.to_string(),
84                pattern: String::new(),
85                negated: false,
86                directory_only: false,
87                anchored: false,
88                rule_type: GitignoreRule::Empty,
89            });
90        }
91        
92        if trimmed.starts_with('#') {
93            return Ok(Self {
94                original: line.to_string(),
95                pattern: trimmed.to_string(),
96                negated: false,
97                directory_only: false,
98                anchored: false,
99                rule_type: GitignoreRule::Comment,
100            });
101        }
102        
103        let mut pattern = trimmed.to_string();
104        let mut negated = false;
105        let mut directory_only = false;
106        let mut anchored = false;
107        
108        // Handle negation
109        if pattern.starts_with('!') {
110            negated = true;
111            pattern = pattern[1..].to_string();
112        }
113        
114        // Handle directory-only patterns
115        if pattern.ends_with('/') {
116            directory_only = true;
117            pattern = pattern.trim_end_matches('/').to_string();
118        }
119        
120        // Handle anchoring
121        if pattern.starts_with('/') {
122            anchored = true;
123            pattern = pattern[1..].to_string();
124        }
125        
126        // Determine rule type
127        let rule_type = if negated {
128            GitignoreRule::Include
129        } else {
130            GitignoreRule::Exclude
131        };
132        
133        Ok(Self {
134            original: line.to_string(),
135            pattern,
136            negated,
137            directory_only,
138            anchored,
139            rule_type,
140        })
141    }
142
143    /// Check if this pattern matches a path
144    pub fn matches<P: AsRef<Path>>(&self, path: P, is_directory: bool, case_sensitive: bool) -> bool {
145        if matches!(self.rule_type, GitignoreRule::Comment | GitignoreRule::Empty) {
146            return false;
147        }
148        
149        let path_str = path.as_ref().to_string_lossy();
150        self.matches_glob(&self.pattern, &path_str, is_directory, case_sensitive)
151    }
152
153    /// Convert gitignore pattern to glob pattern
154    fn to_glob_pattern(&self) -> String {
155        let pattern = self.pattern.clone();
156        
157        // Handle anchored vs unanchored patterns
158        if self.anchored {
159            // Anchored patterns match from the root
160            pattern
161        } else {
162            // Unanchored patterns can match anywhere
163            if pattern.contains('/') {
164                // If pattern contains '/', it's treated as a path pattern
165                format!("**/{}", pattern)
166            } else {
167                // If no '/', it matches files/directories with that name anywhere
168                format!("**/{}", pattern)
169            }
170        }
171    }
172
173    /// Simple glob-like matching (simplified implementation)
174    fn matches_glob(&self, pattern: &str, path: &str, is_directory: bool, case_sensitive: bool) -> bool {
175        // This is a simplified implementation
176        // A full implementation would use proper gitignore pattern matching
177        
178        if pattern.contains("**") {
179            // Handle recursive patterns
180            let parts: Vec<&str> = pattern.split("**").collect();
181            if parts.len() == 2 {
182                let prefix = parts[0];
183                let suffix = parts[1].trim_start_matches('/');
184                
185                if prefix.is_empty() {
186                    // Pattern like **/suffix
187                    if suffix.contains('*') {
188                        // If suffix has wildcards, match it against path components
189                        let path_parts: Vec<&str> = path.split('/').collect();
190                        return path_parts.iter().any(|part| self.wildcard_match(suffix, part, case_sensitive));
191                    } else {
192                        return path.ends_with(suffix) || path.contains(&format!("/{}", suffix));
193                    }
194                } else if suffix.is_empty() {
195                    // Pattern like prefix/**
196                    return path.starts_with(prefix.trim_end_matches('/'));
197                } else {
198                    // Pattern like prefix/**/suffix
199                    return path.starts_with(prefix.trim_end_matches('/')) && 
200                           (path.ends_with(suffix) || path.contains(&format!("/{}", suffix)));
201                }
202            }
203        }
204        
205        // Simple wildcard matching
206        if pattern.contains('*') {
207            return self.wildcard_match(pattern, path, case_sensitive);
208        }
209        
210        // For directory patterns, only match directories or paths inside directories
211        if self.directory_only {
212            // Directory-only patterns (ending with /) should only match:
213            // 1. If the path is a directory AND matches the pattern exactly (anywhere in path for unanchored)
214            // 2. If the path is inside a directory that matches the pattern
215            
216            if case_sensitive {
217                if self.anchored {
218                    // Anchored: must start with pattern/ or be exactly pattern (if directory)
219                    let dir_pattern = format!("{}/", pattern);
220                    path.starts_with(&dir_pattern) || (path == pattern && is_directory)
221                } else {
222                    // Unanchored: can match anywhere in the path
223                    let dir_pattern = format!("{}/", pattern);
224                    let component_pattern = format!("/{}", pattern);
225                    path.starts_with(&dir_pattern) || 
226                    (path == pattern && is_directory) ||
227                    path.contains(&dir_pattern) ||
228                    (path.ends_with(&component_pattern) && is_directory)
229                }
230            } else {
231                let path_lower = path.to_ascii_lowercase();
232                let pattern_lower = pattern.to_ascii_lowercase();
233                let dir_pattern_lower = format!("{}/", pattern_lower);
234                let component_pattern_lower = format!("/{}", pattern_lower);
235                
236                if self.anchored {
237                    path_lower.starts_with(&dir_pattern_lower) || (path_lower == pattern_lower && is_directory)
238                } else {
239                    path_lower.starts_with(&dir_pattern_lower) || 
240                    (path_lower == pattern_lower && is_directory) ||
241                    path_lower.contains(&dir_pattern_lower) ||
242                    (path_lower.ends_with(&component_pattern_lower) && is_directory)
243                }
244            }
245        } else {
246            // Exact match or path component match
247            let component_pattern = format!("/{}", pattern);
248            if case_sensitive {
249                path == pattern || path.ends_with(&component_pattern)
250            } else {
251                path.to_ascii_lowercase() == pattern.to_ascii_lowercase() || 
252                path.to_ascii_lowercase().ends_with(&component_pattern.to_ascii_lowercase())
253            }
254        }
255    }
256
257    /// Simple wildcard matching
258    fn wildcard_match(&self, pattern: &str, text: &str, case_sensitive: bool) -> bool {
259        let pattern_chars: Vec<char> = pattern.chars().collect();
260        let text_chars: Vec<char> = text.chars().collect();
261        
262        self.wildcard_match_recursive(&pattern_chars, &text_chars, 0, 0, case_sensitive)
263    }
264
265    fn wildcard_match_recursive(&self, pattern: &[char], text: &[char], p: usize, t: usize, case_sensitive: bool) -> bool {
266        if p == pattern.len() {
267            return t == text.len();
268        }
269        
270        if pattern[p] == '*' {
271            // In gitignore, * matches any character except '/'
272            // Try matching zero characters
273            if self.wildcard_match_recursive(pattern, text, p + 1, t, case_sensitive) {
274                return true;
275            }
276            // Try matching one or more characters (but not '/')
277            for i in t..text.len() {
278                if text[i] == '/' {
279                    break; // Stop at directory separator
280                }
281                if self.wildcard_match_recursive(pattern, text, p + 1, i + 1, case_sensitive) {
282                    return true;
283                }
284            }
285            false
286        } else if pattern[p] == '?' {
287            if t < text.len() {
288                self.wildcard_match_recursive(pattern, text, p + 1, t + 1, case_sensitive)
289            } else {
290                false
291            }
292        } else {
293            if t < text.len() {
294                let chars_match = if case_sensitive {
295                    pattern[p] == text[t]
296                } else {
297                    pattern[p].to_ascii_lowercase() == text[t].to_ascii_lowercase()
298                };
299                if chars_match {
300                    self.wildcard_match_recursive(pattern, text, p + 1, t + 1, case_sensitive)
301                } else {
302                    false
303                }
304            } else {
305                false
306            }
307        }
308    }
309
310    /// Check if this pattern is a comment
311    pub fn is_comment(&self) -> bool {
312        self.rule_type == GitignoreRule::Comment
313    }
314
315    /// Check if this pattern is empty
316    pub fn is_empty(&self) -> bool {
317        self.rule_type == GitignoreRule::Empty
318    }
319
320    /// Get the effective pattern (without gitignore syntax)
321    pub fn effective_pattern(&self) -> &str {
322        &self.pattern
323    }
324}
325
326impl GitignoreMatcher {
327    /// Create a new gitignore matcher
328    pub fn new() -> Self {
329        Self {
330            patterns: Vec::new(),
331            ignore_files: Vec::new(),
332            overrides: None,
333            case_sensitive: true,
334            require_literal_separator: false,
335        }
336    }
337
338    /// Create a case-insensitive matcher
339    pub fn case_insensitive() -> Self {
340        Self {
341            patterns: Vec::new(),
342            ignore_files: Vec::new(),
343            overrides: None,
344            case_sensitive: false,
345            require_literal_separator: false,
346        }
347    }
348
349    /// Add a gitignore pattern directly
350    pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
351        let gitignore_pattern = GitignorePattern::new(pattern)?;
352        self.patterns.push(gitignore_pattern);
353        self.invalidate_overrides();
354        Ok(())
355    }
356
357    /// Add multiple patterns
358    pub fn add_patterns<I, S>(&mut self, patterns: I) -> Result<()>
359    where
360        I: IntoIterator<Item = S>,
361        S: AsRef<str>,
362    {
363        for pattern in patterns {
364            self.add_pattern(pattern.as_ref())?;
365        }
366        Ok(())
367    }
368
369    /// Load patterns from a gitignore file
370    pub fn add_gitignore_file<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
371        let path = path.as_ref();
372        let ignore_type = self.determine_ignore_type(path);
373        let ignore_file = self.load_ignore_file(path, ignore_type)?;
374        
375        // Add patterns to the main list
376        for pattern in &ignore_file.patterns {
377            self.patterns.push(pattern.clone());
378        }
379        
380        self.ignore_files.push(ignore_file);
381        self.invalidate_overrides();
382        Ok(())
383    }
384
385    /// Load patterns from multiple gitignore files
386    pub fn add_gitignore_files<P, I>(&mut self, paths: I) -> Result<()>
387    where
388        P: AsRef<Path>,
389        I: IntoIterator<Item = P>,
390    {
391        for path in paths {
392            self.add_gitignore_file(path)?;
393        }
394        Ok(())
395    }
396
397    /// Check if a path should be ignored
398    pub fn is_ignored<P: AsRef<Path>>(&mut self, path: P) -> Result<bool> {
399        let result = self.match_path(path)?;
400        Ok(result.ignored)
401    }
402
403    /// Get detailed match information for a path
404    pub fn match_path<P: AsRef<Path>>(&mut self, path: P) -> Result<IgnoreMatchResult> {
405        let path = path.as_ref();
406        // For gitignore patterns, we need to work with theoretical paths that may not exist
407        // A path ending with '/' is considered a directory, otherwise check filesystem if it exists
408        let path_str = path.to_string_lossy();
409        let is_directory = path_str.ends_with('/') || path.is_dir();
410        
411        // Process patterns in reverse order (later patterns override earlier ones)
412        let mut result = IgnoreMatchResult {
413            ignored: false,
414            matched_pattern: None,
415            matched_file: None,
416            rule_type: GitignoreRule::Exclude,
417            line_number: None,
418        };
419        
420        for (index, pattern) in self.patterns.iter().enumerate().rev() {
421            if pattern.matches(path, is_directory, self.case_sensitive) {
422                result.matched_pattern = Some(pattern.original.clone());
423                result.rule_type = pattern.rule_type.clone();
424                
425                // Find which file this pattern came from
426                let mut line_count = 0;
427                for ignore_file in &self.ignore_files {
428                    if index < line_count + ignore_file.patterns.len() {
429                        result.matched_file = Some(ignore_file.path.clone());
430                        result.line_number = Some(index - line_count + 1);
431                        break;
432                    }
433                    line_count += ignore_file.patterns.len();
434                }
435                
436                // Set ignore status based on rule type
437                match pattern.rule_type {
438                    GitignoreRule::Exclude => {
439                        result.ignored = true;
440                    }
441                    GitignoreRule::Include => {
442                        result.ignored = false; // Negation pattern
443                    }
444                    _ => continue, // Comments and empty lines don't affect matching
445                }
446                
447                // Stop at first match (patterns are processed in reverse order)
448                break;
449            }
450        }
451        
452        Ok(result)
453    }
454
455    /// Check multiple paths efficiently using ignore crate integration
456    pub fn filter_paths<P>(&mut self, paths: &[P]) -> Result<Vec<P>>
457    where
458        P: AsRef<Path> + Clone,
459    {
460        if self.overrides.is_none() {
461            self.build_overrides()?;
462        }
463        
464        let mut result = Vec::new();
465        
466        for path in paths {
467            if !self.is_ignored(path)? {
468                result.push(path.clone());
469            }
470        }
471        
472        Ok(result)
473    }
474
475    /// Get all loaded ignore files
476    pub fn ignore_files(&self) -> &[IgnoreFile] {
477        &self.ignore_files
478    }
479
480    /// Get all patterns
481    pub fn patterns(&self) -> &[GitignorePattern] {
482        &self.patterns
483    }
484
485    /// Clear all patterns and files
486    pub fn clear(&mut self) {
487        self.patterns.clear();
488        self.ignore_files.clear();
489        self.invalidate_overrides();
490    }
491
492    /// Get statistics about loaded patterns
493    pub fn stats(&self) -> GitignoreStats {
494        let total_patterns = self.patterns.len();
495        let exclude_patterns = self.patterns.iter()
496            .filter(|p| p.rule_type == GitignoreRule::Exclude)
497            .count();
498        let include_patterns = self.patterns.iter()
499            .filter(|p| p.rule_type == GitignoreRule::Include)
500            .count();
501        let comment_lines = self.patterns.iter()
502            .filter(|p| p.rule_type == GitignoreRule::Comment)
503            .count();
504        
505        GitignoreStats {
506            total_patterns,
507            exclude_patterns,
508            include_patterns,
509            comment_lines,
510            ignore_files: self.ignore_files.len(),
511        }
512    }
513
514    /// Load patterns from an ignore file
515    fn load_ignore_file(&self, path: &Path, ignore_type: IgnoreType) -> Result<IgnoreFile> {
516        if !path.exists() {
517            return Err(ScribeError::path(format!("Ignore file does not exist: {}", path.display()), path));
518        }
519        
520        let file = fs::File::open(path)
521            .map_err(|e| ScribeError::io(format!("Failed to open ignore file {}: {}", path.display(), e), e))?;
522        
523        let reader = BufReader::new(file);
524        let mut patterns = Vec::new();
525        let mut line_count = 0;
526        
527        for line in reader.lines() {
528            let line = line.map_err(|e| ScribeError::io(format!("Failed to read ignore file: {}", e), e))?;
529            line_count += 1;
530            
531            match GitignorePattern::new(&line) {
532                Ok(pattern) => patterns.push(pattern),
533                Err(e) => {
534                    log::warn!("Invalid gitignore pattern in {} line {}: {} ({})", 
535                              path.display(), line_count, line, e);
536                }
537            }
538        }
539        
540        Ok(IgnoreFile {
541            path: path.to_path_buf(),
542            ignore_type,
543            patterns,
544            line_count,
545        })
546    }
547
548    /// Determine the type of ignore file based on its path
549    fn determine_ignore_type(&self, path: &Path) -> IgnoreType {
550        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
551            match filename {
552                ".gitignore" => IgnoreType::Gitignore,
553                ".ignore" => IgnoreType::DotIgnore,
554                _ => IgnoreType::CustomIgnore,
555            }
556        } else {
557            IgnoreType::CustomIgnore
558        }
559    }
560
561    /// Build override patterns for the ignore crate
562    fn build_overrides(&mut self) -> Result<()> {
563        let mut builder = OverrideBuilder::new(".");
564        
565        for pattern in &self.patterns {
566            if matches!(pattern.rule_type, GitignoreRule::Exclude | GitignoreRule::Include) {
567                let glob_pattern = pattern.to_glob_pattern();
568                let override_pattern = if pattern.negated {
569                    format!("!{}", glob_pattern)
570                } else {
571                    glob_pattern
572                };
573                
574                if let Err(e) = builder.add(&override_pattern) {
575                    log::warn!("Failed to add override pattern {}: {}", override_pattern, e);
576                }
577            }
578        }
579        
580        self.overrides = Some(builder.build()?);
581        Ok(())
582    }
583
584    /// Invalidate compiled overrides
585    fn invalidate_overrides(&mut self) {
586        self.overrides = None;
587    }
588
589    /// Find gitignore files in a directory tree
590    pub fn discover_gitignore_files<P: AsRef<Path>>(root: P) -> Result<Vec<PathBuf>> {
591        let root = root.as_ref();
592        let mut gitignore_files = Vec::new();
593        
594        // Use WalkBuilder from ignore crate to respect existing gitignore rules
595        let walker = WalkBuilder::new(root)
596            .hidden(false) // Include hidden files to find .gitignore
597            .git_ignore(false) // Don't apply gitignore during discovery
598            .build();
599        
600        for entry in walker {
601            match entry {
602                Ok(entry) => {
603                    let path = entry.path();
604                    if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
605                        if matches!(filename, ".gitignore" | ".ignore") {
606                            gitignore_files.push(path.to_path_buf());
607                        }
608                    }
609                }
610                Err(e) => {
611                    log::warn!("Error walking directory tree: {}", e);
612                }
613            }
614        }
615        
616        Ok(gitignore_files)
617    }
618
619    /// Load all gitignore files from a directory tree
620    pub fn from_directory<P: AsRef<Path>>(root: P) -> Result<Self> {
621        let mut matcher = Self::new();
622        let gitignore_files = Self::discover_gitignore_files(&root)?;
623        
624        for file in gitignore_files {
625            if let Err(e) = matcher.add_gitignore_file(&file) {
626                log::warn!("Failed to load gitignore file {}: {}", file.display(), e);
627            }
628        }
629        
630        Ok(matcher)
631    }
632
633    /// Create a matcher with commonly ignored patterns
634    pub fn with_defaults() -> Self {
635        let mut matcher = Self::new();
636        
637        // Add common ignore patterns
638        let default_patterns = [
639            ".DS_Store",
640            "Thumbs.db",
641            "*.tmp",
642            "*.temp",
643            ".git/",
644            ".svn/",
645            ".hg/",
646            "node_modules/",
647            "target/",
648            "build/",
649            "dist/",
650            "__pycache__/",
651            "*.pyc",
652            "*.pyo",
653        ];
654        
655        for pattern in &default_patterns {
656            if let Err(e) = matcher.add_pattern(pattern) {
657                log::warn!("Failed to add default pattern {}: {}", pattern, e);
658            }
659        }
660        
661        matcher
662    }
663}
664
665impl Default for GitignoreMatcher {
666    fn default() -> Self {
667        Self::new()
668    }
669}
670
671/// Statistics about gitignore patterns
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct GitignoreStats {
674    pub total_patterns: usize,
675    pub exclude_patterns: usize,
676    pub include_patterns: usize,
677    pub comment_lines: usize,
678    pub ignore_files: usize,
679}
680
681// Note: From<ignore::Error> for ScribeError needs to be implemented in scribe-core
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use tempfile::TempDir;
687    use std::fs;
688
689    #[test]
690    fn test_gitignore_pattern_parsing() {
691        // Basic pattern
692        let pattern = GitignorePattern::new("*.rs").unwrap();
693        assert_eq!(pattern.pattern, "*.rs");
694        assert!(!pattern.negated);
695        assert!(!pattern.directory_only);
696        assert!(!pattern.anchored);
697        assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
698
699        // Negated pattern
700        let pattern = GitignorePattern::new("!important.rs").unwrap();
701        assert_eq!(pattern.pattern, "important.rs");
702        assert!(pattern.negated);
703        assert_eq!(pattern.rule_type, GitignoreRule::Include);
704
705        // Directory pattern
706        let pattern = GitignorePattern::new("build/").unwrap();
707        assert_eq!(pattern.pattern, "build");
708        assert!(pattern.directory_only);
709        assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
710
711        // Anchored pattern
712        let pattern = GitignorePattern::new("/root-only").unwrap();
713        assert_eq!(pattern.pattern, "root-only");
714        assert!(pattern.anchored);
715        assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
716
717        // Comment
718        let pattern = GitignorePattern::new("# This is a comment").unwrap();
719        assert_eq!(pattern.rule_type, GitignoreRule::Comment);
720
721        // Empty line
722        let pattern = GitignorePattern::new("   ").unwrap();
723        assert_eq!(pattern.rule_type, GitignoreRule::Empty);
724    }
725
726    #[test]
727    fn test_gitignore_pattern_matching() {
728        let pattern = GitignorePattern::new("*.rs").unwrap();
729        assert!(pattern.matches("lib.rs", false, true));
730        assert!(!pattern.matches("src/lib.rs", false, true)); // Single * doesn't match across directories
731        assert!(!pattern.matches("lib.py", false, true));
732        
733        // For recursive matching, use **
734        let pattern = GitignorePattern::new("**/*.rs").unwrap();
735        assert!(pattern.matches("lib.rs", false, true));
736        assert!(pattern.matches("src/lib.rs", false, true));
737
738        let pattern = GitignorePattern::new("build/").unwrap();
739        assert!(pattern.matches("build", true, true)); // Directory
740        assert!(!pattern.matches("build", false, true)); // File
741        assert!(pattern.matches("src/build", true, true));
742
743        let pattern = GitignorePattern::new("/root-only").unwrap();
744        assert!(pattern.matches("root-only", false, true));
745        // Note: Full anchoring logic would be more complex in a real implementation
746
747        let pattern = GitignorePattern::new("!*.rs").unwrap();
748        assert!(pattern.negated);
749        assert_eq!(pattern.rule_type, GitignoreRule::Include);
750    }
751
752    #[test]
753    fn test_gitignore_matcher_basic() {
754        let mut matcher = GitignoreMatcher::new();
755        matcher.add_pattern("**/*.rs").unwrap(); // Use ** for recursive matching
756        matcher.add_pattern("build/").unwrap();
757        matcher.add_pattern("!important.rs").unwrap();
758
759        assert!(matcher.is_ignored("lib.rs").unwrap());
760        assert!(matcher.is_ignored("src/lib.rs").unwrap());
761        assert!(!matcher.is_ignored("lib.py").unwrap());
762        
763        // Negation should override exclude
764        assert!(!matcher.is_ignored("important.rs").unwrap());
765    }
766
767    #[test]
768    fn test_gitignore_file_loading() {
769        let temp_dir = TempDir::new().unwrap();
770        let gitignore_path = temp_dir.path().join(".gitignore");
771        
772        let gitignore_content = r#"
773# Ignore compiled files
774*.o
775*.so
776*.dylib
777
778# Ignore build directory
779build/
780
781# Don't ignore important files
782!important.txt
783
784# Empty line above
785"#;
786        
787        fs::write(&gitignore_path, gitignore_content).unwrap();
788
789        let mut matcher = GitignoreMatcher::new();
790        matcher.add_gitignore_file(&gitignore_path).unwrap();
791
792        // Check statistics
793        let stats = matcher.stats();
794        assert_eq!(stats.ignore_files, 1);
795        assert!(stats.exclude_patterns > 0);
796        assert!(stats.include_patterns > 0);
797        assert!(stats.comment_lines > 0);
798
799        // Test matching
800        assert!(matcher.is_ignored("test.o").unwrap());
801        assert!(matcher.is_ignored("libtest.so").unwrap());
802        assert!(matcher.is_ignored("build/").unwrap()); // Directory indicated by trailing slash
803        assert!(!matcher.is_ignored("important.txt").unwrap()); // Negated
804        assert!(!matcher.is_ignored("source.c").unwrap()); // Not matched
805    }
806
807    #[test]
808    fn test_gitignore_match_details() {
809        let mut matcher = GitignoreMatcher::new();
810        matcher.add_pattern("*.tmp").unwrap();
811        matcher.add_pattern("!keep.tmp").unwrap();
812
813        let result = matcher.match_path("test.tmp").unwrap();
814        assert!(result.ignored);
815        assert!(result.matched_pattern.is_some());
816        assert_eq!(result.rule_type, GitignoreRule::Exclude);
817
818        let result = matcher.match_path("keep.tmp").unwrap();
819        assert!(!result.ignored);
820        assert!(result.matched_pattern.is_some());
821        assert_eq!(result.rule_type, GitignoreRule::Include);
822
823        let result = matcher.match_path("test.rs").unwrap();
824        assert!(!result.ignored);
825        assert!(result.matched_pattern.is_none());
826    }
827
828    #[test]
829    fn test_gitignore_discovery() {
830        let temp_dir = TempDir::new().unwrap();
831        let root = temp_dir.path();
832
833        // Create directory structure with multiple .gitignore files
834        fs::create_dir_all(root.join("src")).unwrap();
835        fs::create_dir_all(root.join("tests")).unwrap();
836        fs::create_dir_all(root.join("docs")).unwrap();
837
838        fs::write(root.join(".gitignore"), "*.tmp\nbuild/").unwrap();
839        fs::write(root.join("src/.gitignore"), "*.o").unwrap();
840        fs::write(root.join("tests/.gitignore"), "fixtures/").unwrap();
841
842        let gitignore_files = GitignoreMatcher::discover_gitignore_files(root).unwrap();
843        assert_eq!(gitignore_files.len(), 3);
844
845        // Check that all expected files are found
846        let filenames: Vec<String> = gitignore_files.iter()
847            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
848            .collect();
849        assert!(filenames.iter().all(|name| name == ".gitignore"));
850    }
851
852    #[test]
853    fn test_gitignore_from_directory() {
854        let temp_dir = TempDir::new().unwrap();
855        let root = temp_dir.path();
856
857        // Create .gitignore files
858        fs::write(root.join(".gitignore"), "*.tmp\n*.log").unwrap();
859        fs::create_dir_all(root.join("subdir")).unwrap();
860        fs::write(root.join("subdir/.gitignore"), "*.bak").unwrap();
861
862        let matcher = GitignoreMatcher::from_directory(root).unwrap();
863        let stats = matcher.stats();
864        
865        assert_eq!(stats.ignore_files, 2);
866        assert!(stats.total_patterns >= 3); // At least the 3 patterns we added
867    }
868
869    #[test]
870    fn test_gitignore_defaults() {
871        let matcher = GitignoreMatcher::with_defaults();
872        let stats = matcher.stats();
873        
874        assert!(stats.total_patterns > 0);
875        assert!(stats.exclude_patterns > 0);
876        
877        // Test some common patterns
878        let mut matcher = matcher;
879        assert!(matcher.is_ignored("node_modules/package.json").unwrap());
880        assert!(matcher.is_ignored("target/debug/main").unwrap());
881        assert!(matcher.is_ignored(".DS_Store").unwrap());
882        assert!(matcher.is_ignored("__pycache__/module.pyc").unwrap());
883    }
884
885    #[test]
886    fn test_gitignore_case_sensitivity() {
887        let mut matcher = GitignoreMatcher::new();
888        matcher.add_pattern("*.TMP").unwrap();
889
890        // Case-sensitive by default
891        assert!(matcher.is_ignored("file.TMP").unwrap());
892        assert!(!matcher.is_ignored("file.tmp").unwrap());
893
894        let mut matcher = GitignoreMatcher::case_insensitive();
895        matcher.add_pattern("*.TMP").unwrap();
896
897        // Case-insensitive matcher
898        assert!(matcher.is_ignored("file.TMP").unwrap());
899        assert!(matcher.is_ignored("file.tmp").unwrap());
900        assert!(matcher.is_ignored("file.Tmp").unwrap());
901    }
902
903    #[test]
904    fn test_gitignore_pattern_precedence() {
905        let mut matcher = GitignoreMatcher::new();
906        
907        // Add patterns in order - later ones should override earlier ones
908        matcher.add_pattern("*.txt").unwrap(); // Exclude all .txt files
909        matcher.add_pattern("!important.txt").unwrap(); // But include important.txt
910        matcher.add_pattern("important.txt").unwrap(); // But exclude it again
911
912        // The last pattern should win
913        assert!(matcher.is_ignored("important.txt").unwrap());
914        assert!(matcher.is_ignored("other.txt").unwrap());
915    }
916
917    #[test]
918    fn test_complex_gitignore_patterns() {
919        let mut matcher = GitignoreMatcher::new();
920        
921        // Test various gitignore pattern types
922        matcher.add_pattern("**/*.tmp").unwrap(); // Recursive pattern
923        matcher.add_pattern("build/**/output").unwrap(); // Pattern with ** in middle
924        matcher.add_pattern("logs/*.log").unwrap(); // Single level wildcard
925        matcher.add_pattern("cache/*/data").unwrap(); // Single directory wildcard
926
927        assert!(matcher.is_ignored("file.tmp").unwrap());
928        assert!(matcher.is_ignored("deep/nested/file.tmp").unwrap());
929        assert!(matcher.is_ignored("logs/error.log").unwrap());
930        assert!(!matcher.is_ignored("logs/nested/error.log").unwrap()); // Single level only
931    }
932
933    #[test]
934    fn test_gitignore_filter_paths() {
935        let mut matcher = GitignoreMatcher::new();
936        matcher.add_pattern("*.tmp").unwrap();
937        matcher.add_pattern("build/").unwrap();
938
939        let paths = vec![
940            "src/lib.rs",
941            "temp.tmp",
942            "build/output",
943            "README.md",
944            "test.tmp",
945        ];
946
947        let filtered = matcher.filter_paths(&paths).unwrap();
948        
949        assert_eq!(filtered.len(), 2);
950        assert!(filtered.contains(&"src/lib.rs"));
951        assert!(filtered.contains(&"README.md"));
952        assert!(!filtered.contains(&"temp.tmp"));
953        assert!(!filtered.contains(&"test.tmp"));
954        assert!(!filtered.contains(&"build/output"));
955    }
956
957    #[test]
958    fn test_gitignore_empty_and_comments() {
959        let mut matcher = GitignoreMatcher::new();
960        matcher.add_pattern("").unwrap(); // Empty line
961        matcher.add_pattern("   ").unwrap(); // Whitespace only
962        matcher.add_pattern("# Comment").unwrap(); // Comment
963        matcher.add_pattern("*.rs").unwrap(); // Actual pattern
964
965        let stats = matcher.stats();
966        assert_eq!(stats.exclude_patterns, 1); // Only *.rs counts
967        assert!(stats.comment_lines >= 1);
968        
969        assert!(matcher.is_ignored("test.rs").unwrap());
970        assert!(!matcher.is_ignored("test.py").unwrap());
971    }
972}