1use std::io;
2use std::path::{Path, PathBuf};
3
4use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
5use ignore::gitignore::{Gitignore, GitignoreBuilder};
6use ignore::WalkBuilder;
7
8use crate::default_ignores::DEFAULT_IGNORES;
9
10pub struct FileFilter {
23 root: PathBuf,
24 default_ignore_matcher: Gitignore,
26 git_ignore_matcher: Option<Gitignore>,
28 contextignore_matcher: Option<Gitignore>,
30 include_globset: Option<GlobSet>,
31 include_literals_rel: Vec<PathBuf>,
33 include_literals_abs: Vec<PathBuf>,
35 use_gitignore: bool,
36}
37
38impl FileFilter {
39 pub fn new(root: &Path, config: &WalkerConfig) -> io::Result<Self> {
44 let root = if root.exists() {
45 root.canonicalize()?
46 } else {
47 root.to_path_buf()
48 };
49
50 let default_ignore_matcher = build_ignore_matcher(&root, config);
52
53 let git_ignore_matcher = if config.use_gitignore {
56 build_combined_git_ignore_matcher(&root)
57 } else {
58 None
59 };
60
61 let contextignore_matcher = build_all_contextignore_matcher(&root);
63
64 let include_globset = build_include_globset(&root, &config.include_patterns)
66 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
67
68 let literal_paths = get_literal_paths(&config.include_patterns);
70 let mut include_literals_rel = Vec::new();
71 let mut include_literals_abs = Vec::new();
72 for p in literal_paths {
73 if p.is_absolute() {
74 let abs = p.canonicalize().unwrap_or(p);
76 include_literals_abs.push(abs);
77 } else {
78 include_literals_rel.push(p);
79 }
80 }
81
82 Ok(Self {
83 root,
84 default_ignore_matcher,
85 git_ignore_matcher,
86 contextignore_matcher,
87 include_globset,
88 include_literals_rel,
89 include_literals_abs,
90 use_gitignore: config.use_gitignore,
91 })
92 }
93
94 pub fn should_include(&self, file_path: &Path) -> bool {
96 let rel_path = match file_path.strip_prefix(&self.root) {
98 Ok(p) => p,
99 Err(_) => return false,
100 };
101
102 let is_dir = file_path.is_dir();
103
104 for component in rel_path.components() {
107 if let std::path::Component::Normal(name) = component {
108 if let Some(name_str) = name.to_str() {
109 if name_str.starts_with('.') {
110 return false;
111 }
112 }
113 }
114 }
115
116 if self
118 .default_ignore_matcher
119 .matched_path_or_any_parents(rel_path, is_dir)
120 .is_ignore()
121 {
122 return false;
123 }
124
125 if self.use_gitignore {
127 if let Some(ref matcher) = self.git_ignore_matcher {
128 if matcher
129 .matched_path_or_any_parents(rel_path, is_dir)
130 .is_ignore()
131 {
132 return false;
133 }
134 }
135 }
136
137 if let Some(ref matcher) = self.contextignore_matcher {
139 if matcher
140 .matched_path_or_any_parents(rel_path, is_dir)
141 .is_ignore()
142 {
143 return false;
144 }
145 }
146
147 let has_includes = self.include_globset.is_some()
149 || !self.include_literals_rel.is_empty()
150 || !self.include_literals_abs.is_empty();
151
152 if has_includes {
153 let mut matches = false;
154
155 for literal in &self.include_literals_rel {
157 if rel_path.starts_with(literal) {
158 matches = true;
159 break;
160 }
161 }
162
163 if !matches {
165 let abs_path = self.root.join(rel_path);
166 for literal in &self.include_literals_abs {
167 if abs_path.starts_with(literal) || abs_path == *literal {
168 matches = true;
169 break;
170 }
171 }
172 }
173
174 if !matches {
176 if let Some(ref globset) = self.include_globset {
177 matches = globset.is_match(rel_path);
178 }
179 }
180
181 if !matches {
182 return false;
183 }
184 }
185
186 true
187 }
188}
189
190fn build_combined_git_ignore_matcher(root: &Path) -> Option<Gitignore> {
198 let mut builder = GitignoreBuilder::new(root);
199
200 for excludes_file in get_all_git_excludes_files(root) {
202 let _ = builder.add(&excludes_file);
203 }
204
205 if let Some(global_gitignore) = find_default_global_gitignore() {
207 let _ = builder.add(&global_gitignore);
208 }
209
210 if let Some(git_dir) = find_git_dir(root) {
212 let exclude_path = git_dir.join("info").join("exclude");
213 if exclude_path.exists() {
214 let _ = builder.add(&exclude_path);
215 }
216 }
217
218 let mut current = root.parent();
220 while let Some(parent) = current {
221 for filename in &[".gitignore", ".ignore"] {
222 let ignore_path = parent.join(filename);
223 if ignore_path.exists() {
224 let _ = builder.add(&ignore_path);
225 }
226 }
227 if parent.join(".git").exists() {
229 break;
230 }
231 current = parent.parent();
232 }
233
234 for filename in &[".gitignore", ".ignore"] {
236 let ignore_path = root.join(filename);
237 if ignore_path.exists() {
238 let _ = builder.add(&ignore_path);
239 }
240 }
241
242 add_nested_ignore_files(&mut builder, root, &[".gitignore", ".ignore"]);
244
245 builder.build().ok()
246}
247
248fn build_all_contextignore_matcher(root: &Path) -> Option<Gitignore> {
250 let mut builder = GitignoreBuilder::new(root);
251 let mut found_any = false;
252
253 let contextignore_path = root.join(".contextignore");
255 if contextignore_path.exists() && builder.add(&contextignore_path).is_none() {
256 found_any = true;
257 }
258
259 fn add_nested(builder: &mut GitignoreBuilder, dir: &Path, found: &mut bool) {
261 let Ok(entries) = std::fs::read_dir(dir) else {
262 return;
263 };
264
265 for entry in entries.flatten() {
266 let path = entry.path();
267 if path.is_dir() {
268 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
269 if name.starts_with('.') || name == "node_modules" || name == "target" {
270 continue;
271 }
272
273 let contextignore = path.join(".contextignore");
274 if contextignore.exists() && builder.add(&contextignore).is_none() {
275 *found = true;
276 }
277
278 add_nested(builder, &path, found);
279 }
280 }
281 }
282
283 add_nested(&mut builder, root, &mut found_any);
284
285 if found_any {
286 builder.build().ok()
287 } else {
288 None
289 }
290}
291
292fn find_default_global_gitignore() -> Option<PathBuf> {
298 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
300 let path = PathBuf::from(xdg_config).join("git").join("ignore");
301 if path.exists() {
302 return Some(path);
303 }
304 }
305
306 let home = std::env::var("HOME")
308 .ok()
309 .map(PathBuf::from)
310 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))?;
311
312 let path = home.join(".config").join("git").join("ignore");
314 if path.exists() {
315 return Some(path);
316 }
317
318 let legacy_path = home.join(".gitignore_global");
320 if legacy_path.exists() {
321 return Some(legacy_path);
322 }
323
324 None
325}
326
327fn get_all_git_excludes_files(root: &Path) -> Vec<PathBuf> {
330 use std::process::Command;
331
332 let mut excludes_files = Vec::new();
333
334 for scope in &["--system", "--global", "--local"] {
337 let mut cmd = Command::new("git");
338 cmd.args(["config", scope, "core.excludesFile"]);
339
340 if *scope == "--local" {
342 cmd.current_dir(root);
343 }
344
345 if let Ok(output) = cmd.output() {
346 if output.status.success() {
347 let path_str = String::from_utf8_lossy(&output.stdout);
348 let path_str = path_str.trim();
349
350 if !path_str.is_empty() {
351 if let Some(path) = expand_tilde(path_str) {
352 if path.exists() {
353 excludes_files.push(path);
354 }
355 }
356 }
357 }
358 }
359 }
360
361 excludes_files
362}
363
364fn expand_tilde(path_str: &str) -> Option<PathBuf> {
366 if let Some(stripped) = path_str.strip_prefix("~/") {
367 let home = std::env::var("HOME")
368 .ok()
369 .or_else(|| std::env::var("USERPROFILE").ok())?;
370 Some(PathBuf::from(home).join(stripped))
371 } else {
372 Some(PathBuf::from(path_str))
373 }
374}
375
376fn find_git_dir(root: &Path) -> Option<PathBuf> {
378 let git_dir = root.join(".git");
379 if git_dir.is_dir() {
380 return Some(git_dir);
381 }
382
383 let mut current = root.parent();
385 while let Some(parent) = current {
386 let git_dir = parent.join(".git");
387 if git_dir.is_dir() {
388 return Some(git_dir);
389 }
390 current = parent.parent();
391 }
392
393 None
394}
395
396fn add_nested_ignore_files(builder: &mut GitignoreBuilder, dir: &Path, filenames: &[&str]) {
398 let Ok(entries) = std::fs::read_dir(dir) else {
399 return;
400 };
401
402 for entry in entries.flatten() {
403 let path = entry.path();
404 if path.is_dir() {
405 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
406 if name.starts_with('.') || name == "node_modules" || name == "target" {
408 continue;
409 }
410
411 for filename in filenames {
413 let ignore_path = path.join(filename);
414 if ignore_path.exists() {
415 let _ = builder.add(&ignore_path);
416 }
417 }
418
419 add_nested_ignore_files(builder, &path, filenames);
421 }
422 }
423}
424
425#[derive(Debug, Clone)]
427pub struct FileEntry {
428 pub absolute_path: PathBuf,
429 pub relative_path: PathBuf,
430 pub size: u64,
431}
432
433#[derive(Debug, Clone)]
435pub struct WalkerConfig {
436 pub use_gitignore: bool,
437 pub use_default_ignores: bool,
438 pub custom_ignores: Vec<String>,
439 pub include_patterns: Vec<String>,
440}
441
442impl Default for WalkerConfig {
443 fn default() -> Self {
444 Self {
445 use_gitignore: true,
446 use_default_ignores: true,
447 custom_ignores: Vec::new(),
448 include_patterns: Vec::new(),
449 }
450 }
451}
452
453fn is_glob_pattern(pattern: &str) -> bool {
455 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
456}
457
458fn build_include_globset(
461 root: &Path,
462 patterns: &[String],
463) -> Result<Option<GlobSet>, globset::Error> {
464 let glob_patterns: Vec<&String> = patterns.iter().filter(|p| is_glob_pattern(p)).collect();
465
466 if glob_patterns.is_empty() {
467 return Ok(None);
468 }
469
470 let root_str = root.to_string_lossy();
471
472 let mut builder = GlobSetBuilder::new();
473 for pattern in glob_patterns {
474 let normalized = if pattern.starts_with('/') {
477 if let Some(rel) = pattern.strip_prefix(root_str.as_ref()) {
479 rel.trim_start_matches('/').to_string()
480 } else if let Some(rel) = pattern.strip_prefix(&format!("{}/", root_str)) {
481 rel.to_string()
482 } else {
483 pattern.clone()
486 }
487 } else {
488 pattern.clone()
489 };
490
491 let glob = GlobBuilder::new(&normalized)
494 .literal_separator(true)
495 .build()?;
496 builder.add(glob);
497 }
498 Ok(Some(builder.build()?))
499}
500
501fn get_literal_paths(patterns: &[String]) -> Vec<PathBuf> {
503 patterns
504 .iter()
505 .filter(|p| !is_glob_pattern(p))
506 .map(PathBuf::from)
507 .collect()
508}
509
510fn build_ignore_matcher(root: &Path, config: &WalkerConfig) -> Gitignore {
513 let mut builder = GitignoreBuilder::new(root);
514
515 if config.use_default_ignores {
517 for pattern in DEFAULT_IGNORES {
518 let _ = builder.add_line(None, pattern);
520 }
521 }
522
523 for pattern in &config.custom_ignores {
525 let _ = builder.add_line(None, pattern);
526 }
527
528 builder.build().unwrap_or_else(|_| Gitignore::empty())
529}
530
531#[cfg(test)]
539pub fn should_include_file(root: &Path, file_path: &Path, config: &WalkerConfig) -> bool {
540 match FileFilter::new(root, config) {
543 Ok(filter) => filter.should_include(file_path),
544 Err(_) => false,
545 }
546}
547
548pub fn discover_files(root: &Path, config: &WalkerConfig) -> io::Result<Vec<FileEntry>> {
550 let root = root.canonicalize()?;
551 let mut entries = Vec::new();
552
553 let ignore_matcher = build_ignore_matcher(&root, config);
555
556 let include_globset = build_include_globset(&root, &config.include_patterns)
558 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
559
560 let literal_paths: Vec<PathBuf> = get_literal_paths(&config.include_patterns)
562 .into_iter()
563 .map(|p| if p.is_absolute() { p } else { root.join(p) })
564 .collect();
565
566 let has_globs = config.include_patterns.iter().any(|p| is_glob_pattern(p));
568
569 let mut process_walk = |start_path: &Path, apply_glob_filter: bool| -> io::Result<()> {
571 if !start_path.exists() {
572 eprintln!("Warning: path does not exist: {}", start_path.display());
573 return Ok(());
574 }
575
576 let mut builder = WalkBuilder::new(start_path);
577
578 builder.git_ignore(config.use_gitignore);
580 builder.git_global(config.use_gitignore);
581 builder.git_exclude(config.use_gitignore);
582
583 builder.add_custom_ignore_filename(".contextignore");
585
586 for result in builder.build() {
588 let entry = match result {
589 Ok(e) => e,
590 Err(err) => {
591 eprintln!("Warning: {}", err);
592 continue;
593 }
594 };
595
596 let file_type = match entry.file_type() {
598 Some(ft) => ft,
599 None => continue,
600 };
601 if file_type.is_dir() {
602 continue;
603 }
604
605 let abs_path = entry.path().to_path_buf();
606
607 let rel_path = match abs_path.strip_prefix(&root) {
609 Ok(p) => p.to_path_buf(),
610 Err(_) => continue,
611 };
612
613 if ignore_matcher
615 .matched_path_or_any_parents(&rel_path, false)
616 .is_ignore()
617 {
618 continue;
619 }
620
621 if apply_glob_filter {
624 if let Some(ref globset) = include_globset {
625 if !globset.is_match(&rel_path) {
626 continue;
627 }
628 }
629 }
630
631 if is_binary_file(&abs_path) {
633 continue;
634 }
635
636 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
638
639 entries.push(FileEntry {
640 absolute_path: abs_path,
641 relative_path: rel_path,
642 size,
643 });
644 }
645 Ok(())
646 };
647
648 if config.include_patterns.is_empty() {
649 process_walk(&root, false)?;
651 } else {
652 if has_globs {
654 process_walk(&root, true)?;
655 }
656
657 for literal_path in &literal_paths {
659 process_walk(literal_path, false)?;
660 }
661 }
662
663 entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
665
666 entries.dedup_by(|a, b| a.absolute_path == b.absolute_path);
668
669 Ok(entries)
670}
671
672fn is_binary_file(path: &Path) -> bool {
674 use std::fs::File;
675 use std::io::Read;
676
677 let mut file = match File::open(path) {
678 Ok(f) => f,
679 Err(_) => return false,
680 };
681
682 let mut buffer = [0u8; 8192];
683 let bytes_read = match file.read(&mut buffer) {
684 Ok(n) => n,
685 Err(_) => return false,
686 };
687
688 buffer[..bytes_read].contains(&0)
689}
690
691pub fn format_size(size: u64) -> String {
693 const KB: u64 = 1024;
694 const MB: u64 = KB * 1024;
695 const GB: u64 = MB * 1024;
696
697 if size >= GB {
698 format!("{:.1} GB", size as f64 / GB as f64)
699 } else if size >= MB {
700 format!("{:.1} MB", size as f64 / MB as f64)
701 } else if size >= KB {
702 format!("{:.1} KB", size as f64 / KB as f64)
703 } else {
704 format!("{} B", size)
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn test_glob_pattern_matching() {
714 let root = Path::new("/project");
716 let patterns = vec!["src/*.rs".to_string()];
717 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
718
719 assert!(globset.is_match(Path::new("src/main.rs")));
721 assert!(globset.is_match(Path::new("src/cli.rs")));
722
723 assert!(!globset.is_match(Path::new("src/analytics/mod.rs")));
725 assert!(!globset.is_match(Path::new("src/db/mod.rs")));
726 }
727
728 #[test]
729 fn test_glob_pattern_recursive() {
730 let root = Path::new("/project");
732 let patterns = vec!["src/**/*.rs".to_string()];
733 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
734
735 assert!(globset.is_match(Path::new("src/main.rs")));
737 assert!(globset.is_match(Path::new("src/analytics/mod.rs")));
738 assert!(globset.is_match(Path::new("src/db/mod.rs")));
739
740 assert!(!globset.is_match(Path::new("tests/test.rs")));
742 }
743
744 #[test]
745 fn test_glob_pattern_absolute() {
746 let root = Path::new("/project");
748 let patterns = vec!["/project/src/**/*.rs".to_string()];
749 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
750
751 assert!(globset.is_match(Path::new("src/main.rs")));
753 assert!(globset.is_match(Path::new("src/db/mod.rs")));
754
755 assert!(!globset.is_match(Path::new("tests/test.rs")));
757 }
758
759 #[test]
760 fn test_should_include_file_default_ignores() {
761 let root = Path::new("/project");
762 let config = WalkerConfig::default();
763
764 assert!(!should_include_file(
766 root,
767 Path::new("/project/node_modules/foo.js"),
768 &config
769 ));
770 assert!(!should_include_file(
771 root,
772 Path::new("/project/.git/config"),
773 &config
774 ));
775 assert!(!should_include_file(
776 root,
777 Path::new("/project/target/debug/main"),
778 &config
779 ));
780
781 assert!(should_include_file(
783 root,
784 Path::new("/project/src/main.rs"),
785 &config
786 ));
787 }
788
789 #[test]
790 fn test_should_include_file_custom_ignores() {
791 let root = Path::new("/project");
792 let config = WalkerConfig {
793 use_gitignore: true,
794 use_default_ignores: false, custom_ignores: vec!["vendor/".to_string(), "generated/".to_string()],
796 include_patterns: vec![],
797 };
798
799 assert!(!should_include_file(
801 root,
802 Path::new("/project/vendor/lib.rs"),
803 &config
804 ));
805 assert!(!should_include_file(
806 root,
807 Path::new("/project/generated/types.rs"),
808 &config
809 ));
810
811 assert!(should_include_file(
813 root,
814 Path::new("/project/src/main.rs"),
815 &config
816 ));
817 }
818
819 #[test]
820 fn test_should_include_file_include_patterns() {
821 let root = Path::new("/project");
822 let config = WalkerConfig {
823 use_gitignore: true,
824 use_default_ignores: true,
825 custom_ignores: vec![],
826 include_patterns: vec!["src/**/*.rs".to_string()],
827 };
828
829 assert!(should_include_file(
831 root,
832 Path::new("/project/src/main.rs"),
833 &config
834 ));
835 assert!(should_include_file(
836 root,
837 Path::new("/project/src/db/mod.rs"),
838 &config
839 ));
840
841 assert!(!should_include_file(
843 root,
844 Path::new("/project/tests/test.rs"),
845 &config
846 ));
847 assert!(!should_include_file(
848 root,
849 Path::new("/project/build.rs"),
850 &config
851 ));
852 }
853
854 #[test]
855 fn test_should_include_file_wildcard_ignores() {
856 let root = Path::new("/project");
857 let config = WalkerConfig::default();
858
859 assert!(!should_include_file(
861 root,
862 Path::new("/project/assets/logo.png"),
863 &config
864 ));
865 assert!(!should_include_file(
866 root,
867 Path::new("/project/Cargo.lock"),
868 &config
869 ));
870 assert!(!should_include_file(
871 root,
872 Path::new("/project/package-lock.json"),
873 &config
874 ));
875
876 assert!(should_include_file(
878 root,
879 Path::new("/project/src/lockfile.rs"),
880 &config
881 ));
882 }
883
884 #[test]
885 fn test_should_include_file_no_false_positives() {
886 let root = Path::new("/project");
887 let config = WalkerConfig::default();
888
889 assert!(!should_include_file(
891 root,
892 Path::new("/project/.env"),
893 &config
894 ));
895 assert!(!should_include_file(
896 root,
897 Path::new("/project/.envrc"),
898 &config
899 ));
900 assert!(!should_include_file(
901 root,
902 Path::new("/project/.github/workflows/ci.yml"),
903 &config
904 ));
905
906 assert!(should_include_file(
908 root,
909 Path::new("/project/src/env.rs"),
910 &config
911 ));
912 assert!(should_include_file(
913 root,
914 Path::new("/project/src/dotenv.rs"),
915 &config
916 ));
917 }
918}