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