1use scribe_core::{Result, ScribeError};
10use std::fs;
11use std::io::{BufRead, BufReader};
12use std::path::{Path, PathBuf};
13use ignore::{overrides::OverrideBuilder, WalkBuilder};
15use serde::{Deserialize, Serialize};
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>>(
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 fn to_glob_pattern(&self) -> String {
163 let pattern = self.pattern.clone();
164
165 if self.anchored {
167 pattern
169 } else {
170 if pattern.contains('/') {
172 format!("**/{}", pattern)
174 } else {
175 format!("**/{}", pattern)
177 }
178 }
179 }
180
181 fn matches_glob(
183 &self,
184 pattern: &str,
185 path: &str,
186 is_directory: bool,
187 case_sensitive: bool,
188 ) -> bool {
189 if pattern.contains("**") {
193 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 if suffix.contains('*') {
202 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 return path.starts_with(prefix.trim_end_matches('/'));
213 } else {
214 return path.starts_with(prefix.trim_end_matches('/'))
216 && (path.ends_with(suffix) || path.contains(&format!("/{}", suffix)));
217 }
218 }
219 }
220
221 if pattern.contains('*') {
223 return self.wildcard_match(pattern, path, case_sensitive);
224 }
225
226 if self.directory_only {
228 if case_sensitive {
233 if self.anchored {
234 let dir_pattern = format!("{}/", pattern);
236 path.starts_with(&dir_pattern) || (path == pattern && is_directory)
237 } else {
238 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 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 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 if self.wildcard_match_recursive(pattern, text, p + 1, t, case_sensitive) {
300 return true;
301 }
302 for i in t..text.len() {
304 if text[i] == '/' {
305 break; }
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 pub fn is_comment(&self) -> bool {
338 self.rule_type == GitignoreRule::Comment
339 }
340
341 pub fn is_empty(&self) -> bool {
343 self.rule_type == GitignoreRule::Empty
344 }
345
346 pub fn effective_pattern(&self) -> &str {
348 &self.pattern
349 }
350}
351
352impl GitignoreMatcher {
353 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 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 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 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 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 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 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 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 pub fn match_path<P: AsRef<Path>>(&mut self, path: P) -> Result<IgnoreMatchResult> {
431 let path = path.as_ref();
432 let path_str = path.to_string_lossy();
435 let is_directory = path_str.ends_with('/') || path.is_dir();
436
437 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 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 match pattern.rule_type {
464 GitignoreRule::Exclude => {
465 result.ignored = true;
466 }
467 GitignoreRule::Include => {
468 result.ignored = false; }
470 _ => continue, }
472
473 break;
475 }
476 }
477
478 Ok(result)
479 }
480
481 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 pub fn ignore_files(&self) -> &[IgnoreFile] {
503 &self.ignore_files
504 }
505
506 pub fn patterns(&self) -> &[GitignorePattern] {
508 &self.patterns
509 }
510
511 pub fn clear(&mut self) {
513 self.patterns.clear();
514 self.ignore_files.clear();
515 self.invalidate_overrides();
516 }
517
518 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 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 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 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 fn invalidate_overrides(&mut self) {
634 self.overrides = None;
635 }
636
637 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 let walker = WalkBuilder::new(root)
644 .hidden(false) .git_ignore(false) .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 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 pub fn with_defaults() -> Self {
683 let mut matcher = Self::new();
684
685 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#[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#[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 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 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 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 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 let pattern = GitignorePattern::new("# This is a comment").unwrap();
767 assert_eq!(pattern.rule_type, GitignoreRule::Comment);
768
769 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)); assert!(!pattern.matches("lib.py", false, true));
780
781 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)); assert!(!pattern.matches("build", false, true)); 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 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(); 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 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 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 assert!(matcher.is_ignored("test.o").unwrap());
849 assert!(matcher.is_ignored("libtest.so").unwrap());
850 assert!(matcher.is_ignored("build/").unwrap()); assert!(!matcher.is_ignored("important.txt").unwrap()); assert!(!matcher.is_ignored("source.c").unwrap()); }
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 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 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 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); }
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 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 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 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 matcher.add_pattern("*.txt").unwrap(); matcher.add_pattern("!important.txt").unwrap(); matcher.add_pattern("important.txt").unwrap(); 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 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());
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()); }
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(); matcher.add_pattern(" ").unwrap(); matcher.add_pattern("# Comment").unwrap(); matcher.add_pattern("*.rs").unwrap(); let stats = matcher.stats();
1015 assert_eq!(stats.exclude_patterns, 1); assert!(stats.comment_lines >= 1);
1017
1018 assert!(matcher.is_ignored("test.rs").unwrap());
1019 assert!(!matcher.is_ignored("test.py").unwrap());
1020 }
1021}