1use scribe_core::{Result, ScribeError};
10use std::path::{Path, PathBuf};
11use std::fs;
12use std::io::{BufRead, BufReader};
13use ignore::{WalkBuilder, overrides::OverrideBuilder};
15use serde::{Serialize, Deserialize};
16
17#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub enum GitignoreRule {
41 Include,
42 Exclude,
43 Comment,
44 Empty,
45}
46
47#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub enum IgnoreType {
59 Gitignore,
60 GlobalGitignore,
61 CustomIgnore,
62 DotIgnore,
63}
64
65#[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 pub fn new(line: &str) -> Result<Self> {
78 let trimmed = line.trim();
79
80 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 if pattern.starts_with('!') {
110 negated = true;
111 pattern = pattern[1..].to_string();
112 }
113
114 if pattern.ends_with('/') {
116 directory_only = true;
117 pattern = pattern.trim_end_matches('/').to_string();
118 }
119
120 if pattern.starts_with('/') {
122 anchored = true;
123 pattern = pattern[1..].to_string();
124 }
125
126 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 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 fn to_glob_pattern(&self) -> String {
155 let pattern = self.pattern.clone();
156
157 if self.anchored {
159 pattern
161 } else {
162 if pattern.contains('/') {
164 format!("**/{}", pattern)
166 } else {
167 format!("**/{}", pattern)
169 }
170 }
171 }
172
173 fn matches_glob(&self, pattern: &str, path: &str, is_directory: bool, case_sensitive: bool) -> bool {
175 if pattern.contains("**") {
179 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 if suffix.contains('*') {
188 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 return path.starts_with(prefix.trim_end_matches('/'));
197 } else {
198 return path.starts_with(prefix.trim_end_matches('/')) &&
200 (path.ends_with(suffix) || path.contains(&format!("/{}", suffix)));
201 }
202 }
203 }
204
205 if pattern.contains('*') {
207 return self.wildcard_match(pattern, path, case_sensitive);
208 }
209
210 if self.directory_only {
212 if case_sensitive {
217 if self.anchored {
218 let dir_pattern = format!("{}/", pattern);
220 path.starts_with(&dir_pattern) || (path == pattern && is_directory)
221 } else {
222 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 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 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 if self.wildcard_match_recursive(pattern, text, p + 1, t, case_sensitive) {
274 return true;
275 }
276 for i in t..text.len() {
278 if text[i] == '/' {
279 break; }
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 pub fn is_comment(&self) -> bool {
312 self.rule_type == GitignoreRule::Comment
313 }
314
315 pub fn is_empty(&self) -> bool {
317 self.rule_type == GitignoreRule::Empty
318 }
319
320 pub fn effective_pattern(&self) -> &str {
322 &self.pattern
323 }
324}
325
326impl GitignoreMatcher {
327 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 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 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 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 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 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 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 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 pub fn match_path<P: AsRef<Path>>(&mut self, path: P) -> Result<IgnoreMatchResult> {
405 let path = path.as_ref();
406 let path_str = path.to_string_lossy();
409 let is_directory = path_str.ends_with('/') || path.is_dir();
410
411 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 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 match pattern.rule_type {
438 GitignoreRule::Exclude => {
439 result.ignored = true;
440 }
441 GitignoreRule::Include => {
442 result.ignored = false; }
444 _ => continue, }
446
447 break;
449 }
450 }
451
452 Ok(result)
453 }
454
455 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 pub fn ignore_files(&self) -> &[IgnoreFile] {
477 &self.ignore_files
478 }
479
480 pub fn patterns(&self) -> &[GitignorePattern] {
482 &self.patterns
483 }
484
485 pub fn clear(&mut self) {
487 self.patterns.clear();
488 self.ignore_files.clear();
489 self.invalidate_overrides();
490 }
491
492 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 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 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 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 fn invalidate_overrides(&mut self) {
586 self.overrides = None;
587 }
588
589 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 let walker = WalkBuilder::new(root)
596 .hidden(false) .git_ignore(false) .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 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 pub fn with_defaults() -> Self {
635 let mut matcher = Self::new();
636
637 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#[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#[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 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 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 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 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 let pattern = GitignorePattern::new("# This is a comment").unwrap();
719 assert_eq!(pattern.rule_type, GitignoreRule::Comment);
720
721 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)); assert!(!pattern.matches("lib.py", false, true));
732
733 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)); assert!(!pattern.matches("build", false, true)); 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 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(); 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 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 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 assert!(matcher.is_ignored("test.o").unwrap());
801 assert!(matcher.is_ignored("libtest.so").unwrap());
802 assert!(matcher.is_ignored("build/").unwrap()); assert!(!matcher.is_ignored("important.txt").unwrap()); assert!(!matcher.is_ignored("source.c").unwrap()); }
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 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 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 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); }
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 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 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 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 matcher.add_pattern("*.txt").unwrap(); matcher.add_pattern("!important.txt").unwrap(); matcher.add_pattern("important.txt").unwrap(); 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 matcher.add_pattern("**/*.tmp").unwrap(); matcher.add_pattern("build/**/output").unwrap(); matcher.add_pattern("logs/*.log").unwrap(); matcher.add_pattern("cache/*/data").unwrap(); 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()); }
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(); matcher.add_pattern(" ").unwrap(); matcher.add_pattern("# Comment").unwrap(); matcher.add_pattern("*.rs").unwrap(); let stats = matcher.stats();
966 assert_eq!(stats.exclude_patterns, 1); assert!(stats.comment_lines >= 1);
968
969 assert!(matcher.is_ignored("test.rs").unwrap());
970 assert!(!matcher.is_ignored("test.py").unwrap());
971 }
972}