1use std::io;
13use std::path::{Path, PathBuf};
14
15use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
16use ignore::gitignore::{Gitignore, GitignoreBuilder};
17use ignore::WalkBuilder;
18
19use crate::default_ignores::DEFAULT_IGNORES;
20
21pub struct FileFilter {
34 root: PathBuf,
35 default_ignore_matcher: Gitignore,
37 git_ignore_matcher: Option<Gitignore>,
39 contextignore_matcher: Option<Gitignore>,
41 include_globset: Option<GlobSet>,
42 include_literals_rel: Vec<PathBuf>,
44 include_literals_abs: Vec<PathBuf>,
46 use_gitignore: bool,
47}
48
49impl FileFilter {
50 pub fn new(root: &Path, config: &WalkerConfig) -> io::Result<Self> {
55 let root = if root.exists() {
56 root.canonicalize()?
57 } else {
58 root.to_path_buf()
59 };
60
61 let default_ignore_matcher = build_ignore_matcher(&root, config);
63
64 let git_ignore_matcher = if config.use_gitignore {
67 build_combined_git_ignore_matcher(&root)
68 } else {
69 None
70 };
71
72 let contextignore_matcher = build_all_contextignore_matcher(&root);
74
75 let include_globset = build_include_globset(&root, &config.include_patterns)
77 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
78
79 let literal_paths = get_literal_paths(&config.include_patterns);
81 let mut include_literals_rel = Vec::new();
82 let mut include_literals_abs = Vec::new();
83 for p in literal_paths {
84 if p.is_absolute() {
85 let abs = p.canonicalize().unwrap_or(p);
87 include_literals_abs.push(abs);
88 } else {
89 include_literals_rel.push(p);
90 }
91 }
92
93 Ok(Self {
94 root,
95 default_ignore_matcher,
96 git_ignore_matcher,
97 contextignore_matcher,
98 include_globset,
99 include_literals_rel,
100 include_literals_abs,
101 use_gitignore: config.use_gitignore,
102 })
103 }
104
105 pub fn should_include(&self, file_path: &Path) -> bool {
107 let rel_path = match file_path.strip_prefix(&self.root) {
109 Ok(p) => p,
110 Err(_) => return false,
111 };
112
113 let is_dir = file_path.is_dir();
114
115 for component in rel_path.components() {
118 if let std::path::Component::Normal(name) = component {
119 if let Some(name_str) = name.to_str() {
120 if name_str.starts_with('.') {
121 return false;
122 }
123 }
124 }
125 }
126
127 if self
129 .default_ignore_matcher
130 .matched_path_or_any_parents(rel_path, is_dir)
131 .is_ignore()
132 {
133 return false;
134 }
135
136 if self.use_gitignore {
138 if let Some(ref matcher) = self.git_ignore_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
148 if let Some(ref matcher) = self.contextignore_matcher {
150 if matcher
151 .matched_path_or_any_parents(rel_path, is_dir)
152 .is_ignore()
153 {
154 return false;
155 }
156 }
157
158 let has_includes = self.include_globset.is_some()
160 || !self.include_literals_rel.is_empty()
161 || !self.include_literals_abs.is_empty();
162
163 if has_includes {
164 let mut matches = false;
165
166 for literal in &self.include_literals_rel {
168 if rel_path.starts_with(literal) {
169 matches = true;
170 break;
171 }
172 }
173
174 if !matches {
176 let abs_path = self.root.join(rel_path);
177 for literal in &self.include_literals_abs {
178 if abs_path.starts_with(literal) || abs_path == *literal {
179 matches = true;
180 break;
181 }
182 }
183 }
184
185 if !matches {
187 if let Some(ref globset) = self.include_globset {
188 matches = globset.is_match(rel_path);
189 }
190 }
191
192 if !matches {
193 return false;
194 }
195 }
196
197 true
198 }
199}
200
201fn build_combined_git_ignore_matcher(root: &Path) -> Option<Gitignore> {
209 let mut builder = GitignoreBuilder::new(root);
210
211 for excludes_file in get_all_git_excludes_files(root) {
213 let _ = builder.add(&excludes_file);
214 }
215
216 if let Some(global_gitignore) = find_default_global_gitignore() {
218 let _ = builder.add(&global_gitignore);
219 }
220
221 if let Some(git_dir) = find_git_dir(root) {
223 let exclude_path = git_dir.join("info").join("exclude");
224 if exclude_path.exists() {
225 let _ = builder.add(&exclude_path);
226 }
227 }
228
229 let mut current = root.parent();
231 while let Some(parent) = current {
232 for filename in &[".gitignore", ".ignore"] {
233 let ignore_path = parent.join(filename);
234 if ignore_path.exists() {
235 let _ = builder.add(&ignore_path);
236 }
237 }
238 if parent.join(".git").exists() {
240 break;
241 }
242 current = parent.parent();
243 }
244
245 for filename in &[".gitignore", ".ignore"] {
247 let ignore_path = root.join(filename);
248 if ignore_path.exists() {
249 let _ = builder.add(&ignore_path);
250 }
251 }
252
253 add_nested_ignore_files(&mut builder, root, &[".gitignore", ".ignore"]);
255
256 builder.build().ok()
257}
258
259fn build_all_contextignore_matcher(root: &Path) -> Option<Gitignore> {
261 let mut builder = GitignoreBuilder::new(root);
262 let mut found_any = false;
263
264 let contextignore_path = root.join(".contextignore");
266 if contextignore_path.exists() && builder.add(&contextignore_path).is_none() {
267 found_any = true;
268 }
269
270 fn add_nested(builder: &mut GitignoreBuilder, dir: &Path, found: &mut bool) {
272 let Ok(entries) = std::fs::read_dir(dir) else {
273 return;
274 };
275
276 for entry in entries.flatten() {
277 let path = entry.path();
278 if path.is_dir() {
279 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
280 if name.starts_with('.') || name == "node_modules" || name == "target" {
281 continue;
282 }
283
284 let contextignore = path.join(".contextignore");
285 if contextignore.exists() && builder.add(&contextignore).is_none() {
286 *found = true;
287 }
288
289 add_nested(builder, &path, found);
290 }
291 }
292 }
293
294 add_nested(&mut builder, root, &mut found_any);
295
296 if found_any {
297 builder.build().ok()
298 } else {
299 None
300 }
301}
302
303fn find_default_global_gitignore() -> Option<PathBuf> {
309 if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
311 let path = PathBuf::from(xdg_config).join("git").join("ignore");
312 if path.exists() {
313 return Some(path);
314 }
315 }
316
317 let home = std::env::var("HOME")
319 .ok()
320 .map(PathBuf::from)
321 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))?;
322
323 let path = home.join(".config").join("git").join("ignore");
325 if path.exists() {
326 return Some(path);
327 }
328
329 let legacy_path = home.join(".gitignore_global");
331 if legacy_path.exists() {
332 return Some(legacy_path);
333 }
334
335 None
336}
337
338fn get_all_git_excludes_files(root: &Path) -> Vec<PathBuf> {
341 use std::process::Command;
342
343 let mut excludes_files = Vec::new();
344
345 for scope in &["--system", "--global", "--local"] {
348 let mut cmd = Command::new("git");
349 cmd.args(["config", scope, "core.excludesFile"]);
350
351 if *scope == "--local" {
353 cmd.current_dir(root);
354 }
355
356 if let Ok(output) = cmd.output() {
357 if output.status.success() {
358 let path_str = String::from_utf8_lossy(&output.stdout);
359 let path_str = path_str.trim();
360
361 if !path_str.is_empty() {
362 if let Some(path) = expand_tilde(path_str) {
363 if path.exists() {
364 excludes_files.push(path);
365 }
366 }
367 }
368 }
369 }
370 }
371
372 excludes_files
373}
374
375fn expand_tilde(path_str: &str) -> Option<PathBuf> {
377 if let Some(stripped) = path_str.strip_prefix("~/") {
378 let home = std::env::var("HOME")
379 .ok()
380 .or_else(|| std::env::var("USERPROFILE").ok())?;
381 Some(PathBuf::from(home).join(stripped))
382 } else {
383 Some(PathBuf::from(path_str))
384 }
385}
386
387fn find_git_dir(root: &Path) -> Option<PathBuf> {
389 let git_dir = root.join(".git");
390 if git_dir.is_dir() {
391 return Some(git_dir);
392 }
393
394 let mut current = root.parent();
396 while let Some(parent) = current {
397 let git_dir = parent.join(".git");
398 if git_dir.is_dir() {
399 return Some(git_dir);
400 }
401 current = parent.parent();
402 }
403
404 None
405}
406
407fn add_nested_ignore_files(builder: &mut GitignoreBuilder, dir: &Path, filenames: &[&str]) {
409 let Ok(entries) = std::fs::read_dir(dir) else {
410 return;
411 };
412
413 for entry in entries.flatten() {
414 let path = entry.path();
415 if path.is_dir() {
416 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
417 if name.starts_with('.') || name == "node_modules" || name == "target" {
419 continue;
420 }
421
422 for filename in filenames {
424 let ignore_path = path.join(filename);
425 if ignore_path.exists() {
426 let _ = builder.add(&ignore_path);
427 }
428 }
429
430 add_nested_ignore_files(builder, &path, filenames);
432 }
433 }
434}
435
436#[derive(Debug, Clone)]
438pub struct FileEntry {
439 pub absolute_path: PathBuf,
440 pub relative_path: PathBuf,
441 pub size: u64,
442}
443
444#[derive(Debug, Clone)]
446pub struct WalkerConfig {
447 pub use_gitignore: bool,
448 pub use_default_ignores: bool,
449 pub custom_ignores: Vec<String>,
450 pub include_patterns: Vec<String>,
451}
452
453impl Default for WalkerConfig {
454 fn default() -> Self {
455 Self {
456 use_gitignore: true,
457 use_default_ignores: true,
458 custom_ignores: Vec::new(),
459 include_patterns: Vec::new(),
460 }
461 }
462}
463
464fn is_glob_pattern(pattern: &str) -> bool {
466 pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
467}
468
469fn build_include_globset(
472 root: &Path,
473 patterns: &[String],
474) -> Result<Option<GlobSet>, globset::Error> {
475 let glob_patterns: Vec<&String> = patterns.iter().filter(|p| is_glob_pattern(p)).collect();
476
477 if glob_patterns.is_empty() {
478 return Ok(None);
479 }
480
481 let root_str = root.to_string_lossy();
482
483 let mut builder = GlobSetBuilder::new();
484 for pattern in glob_patterns {
485 let normalized = if pattern.starts_with('/') {
488 if let Some(rel) = pattern.strip_prefix(root_str.as_ref()) {
490 rel.trim_start_matches('/').to_string()
491 } else if let Some(rel) = pattern.strip_prefix(&format!("{}/", root_str)) {
492 rel.to_string()
493 } else {
494 pattern.clone()
497 }
498 } else {
499 pattern.clone()
500 };
501
502 let glob = GlobBuilder::new(&normalized)
505 .literal_separator(true)
506 .build()?;
507 builder.add(glob);
508 }
509 Ok(Some(builder.build()?))
510}
511
512fn get_literal_paths(patterns: &[String]) -> Vec<PathBuf> {
514 patterns
515 .iter()
516 .filter(|p| !is_glob_pattern(p))
517 .map(PathBuf::from)
518 .collect()
519}
520
521fn build_ignore_matcher(root: &Path, config: &WalkerConfig) -> Gitignore {
524 let mut builder = GitignoreBuilder::new(root);
525
526 if config.use_default_ignores {
528 for pattern in DEFAULT_IGNORES {
529 let _ = builder.add_line(None, pattern);
531 }
532 }
533
534 for pattern in &config.custom_ignores {
536 let _ = builder.add_line(None, pattern);
537 }
538
539 builder.build().unwrap_or_else(|_| Gitignore::empty())
540}
541
542#[cfg(test)]
550pub fn should_include_file(root: &Path, file_path: &Path, config: &WalkerConfig) -> bool {
551 match FileFilter::new(root, config) {
554 Ok(filter) => filter.should_include(file_path),
555 Err(_) => false,
556 }
557}
558
559pub fn discover_files(root: &Path, config: &WalkerConfig) -> io::Result<Vec<FileEntry>> {
561 let root = root.canonicalize()?;
562 let mut entries = Vec::new();
563
564 let ignore_matcher = build_ignore_matcher(&root, config);
566
567 let include_globset = build_include_globset(&root, &config.include_patterns)
569 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
570
571 let literal_paths: Vec<PathBuf> = get_literal_paths(&config.include_patterns)
573 .into_iter()
574 .map(|p| if p.is_absolute() { p } else { root.join(p) })
575 .collect();
576
577 let has_globs = config.include_patterns.iter().any(|p| is_glob_pattern(p));
579
580 let mut process_walk = |start_path: &Path, apply_glob_filter: bool| -> io::Result<()> {
582 if !start_path.exists() {
583 eprintln!("Warning: path does not exist: {}", start_path.display());
584 return Ok(());
585 }
586
587 let mut builder = WalkBuilder::new(start_path);
588
589 builder.git_ignore(config.use_gitignore);
591 builder.git_global(config.use_gitignore);
592 builder.git_exclude(config.use_gitignore);
593
594 builder.add_custom_ignore_filename(".contextignore");
596
597 for result in builder.build() {
599 let entry = match result {
600 Ok(e) => e,
601 Err(err) => {
602 eprintln!("Warning: {}", err);
603 continue;
604 }
605 };
606
607 let file_type = match entry.file_type() {
609 Some(ft) => ft,
610 None => continue,
611 };
612 if file_type.is_dir() {
613 continue;
614 }
615
616 let abs_path = entry.path().to_path_buf();
617
618 let rel_path = match abs_path.strip_prefix(&root) {
620 Ok(p) => p.to_path_buf(),
621 Err(_) => continue,
622 };
623
624 if ignore_matcher
626 .matched_path_or_any_parents(&rel_path, false)
627 .is_ignore()
628 {
629 continue;
630 }
631
632 if apply_glob_filter {
635 if let Some(ref globset) = include_globset {
636 if !globset.is_match(&rel_path) {
637 continue;
638 }
639 }
640 }
641
642 if is_binary_file(&abs_path) {
644 continue;
645 }
646
647 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
649
650 entries.push(FileEntry {
651 absolute_path: abs_path,
652 relative_path: rel_path,
653 size,
654 });
655 }
656 Ok(())
657 };
658
659 if config.include_patterns.is_empty() {
660 process_walk(&root, false)?;
662 } else {
663 if has_globs {
665 process_walk(&root, true)?;
666 }
667
668 for literal_path in &literal_paths {
670 process_walk(literal_path, false)?;
671 }
672 }
673
674 entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
676
677 entries.dedup_by(|a, b| a.absolute_path == b.absolute_path);
679
680 Ok(entries)
681}
682
683fn is_binary_file(path: &Path) -> bool {
685 use std::fs::File;
686 use std::io::Read;
687
688 let mut file = match File::open(path) {
689 Ok(f) => f,
690 Err(_) => return false,
691 };
692
693 let mut buffer = [0u8; 8192];
694 let bytes_read = match file.read(&mut buffer) {
695 Ok(n) => n,
696 Err(_) => return false,
697 };
698
699 buffer[..bytes_read].contains(&0)
700}
701
702pub fn format_size(size: u64) -> String {
704 const KB: u64 = 1024;
705 const MB: u64 = KB * 1024;
706 const GB: u64 = MB * 1024;
707
708 if size >= GB {
709 format!("{:.1} GB", size as f64 / GB as f64)
710 } else if size >= MB {
711 format!("{:.1} MB", size as f64 / MB as f64)
712 } else if size >= KB {
713 format!("{:.1} KB", size as f64 / KB as f64)
714 } else {
715 format!("{} B", size)
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
724 fn test_glob_pattern_matching() {
725 let root = Path::new("/project");
727 let patterns = vec!["src/*.rs".to_string()];
728 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
729
730 assert!(globset.is_match(Path::new("src/main.rs")));
732 assert!(globset.is_match(Path::new("src/cli.rs")));
733
734 assert!(!globset.is_match(Path::new("src/analytics/mod.rs")));
736 assert!(!globset.is_match(Path::new("src/db/mod.rs")));
737 }
738
739 #[test]
740 fn test_glob_pattern_recursive() {
741 let root = Path::new("/project");
743 let patterns = vec!["src/**/*.rs".to_string()];
744 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
745
746 assert!(globset.is_match(Path::new("src/main.rs")));
748 assert!(globset.is_match(Path::new("src/analytics/mod.rs")));
749 assert!(globset.is_match(Path::new("src/db/mod.rs")));
750
751 assert!(!globset.is_match(Path::new("tests/test.rs")));
753 }
754
755 #[test]
756 fn test_glob_pattern_absolute() {
757 let root = Path::new("/project");
759 let patterns = vec!["/project/src/**/*.rs".to_string()];
760 let globset = build_include_globset(root, &patterns).unwrap().unwrap();
761
762 assert!(globset.is_match(Path::new("src/main.rs")));
764 assert!(globset.is_match(Path::new("src/db/mod.rs")));
765
766 assert!(!globset.is_match(Path::new("tests/test.rs")));
768 }
769
770 #[test]
771 fn test_should_include_file_default_ignores() {
772 let root = Path::new("/project");
773 let config = WalkerConfig::default();
774
775 assert!(!should_include_file(
777 root,
778 Path::new("/project/node_modules/foo.js"),
779 &config
780 ));
781 assert!(!should_include_file(
782 root,
783 Path::new("/project/.git/config"),
784 &config
785 ));
786 assert!(!should_include_file(
787 root,
788 Path::new("/project/target/debug/main"),
789 &config
790 ));
791
792 assert!(should_include_file(
794 root,
795 Path::new("/project/src/main.rs"),
796 &config
797 ));
798 }
799
800 #[test]
801 fn test_should_include_file_custom_ignores() {
802 let root = Path::new("/project");
803 let config = WalkerConfig {
804 use_gitignore: true,
805 use_default_ignores: false, custom_ignores: vec!["vendor/".to_string(), "generated/".to_string()],
807 include_patterns: vec![],
808 };
809
810 assert!(!should_include_file(
812 root,
813 Path::new("/project/vendor/lib.rs"),
814 &config
815 ));
816 assert!(!should_include_file(
817 root,
818 Path::new("/project/generated/types.rs"),
819 &config
820 ));
821
822 assert!(should_include_file(
824 root,
825 Path::new("/project/src/main.rs"),
826 &config
827 ));
828 }
829
830 #[test]
831 fn test_should_include_file_include_patterns() {
832 let root = Path::new("/project");
833 let config = WalkerConfig {
834 use_gitignore: true,
835 use_default_ignores: true,
836 custom_ignores: vec![],
837 include_patterns: vec!["src/**/*.rs".to_string()],
838 };
839
840 assert!(should_include_file(
842 root,
843 Path::new("/project/src/main.rs"),
844 &config
845 ));
846 assert!(should_include_file(
847 root,
848 Path::new("/project/src/db/mod.rs"),
849 &config
850 ));
851
852 assert!(!should_include_file(
854 root,
855 Path::new("/project/tests/test.rs"),
856 &config
857 ));
858 assert!(!should_include_file(
859 root,
860 Path::new("/project/build.rs"),
861 &config
862 ));
863 }
864
865 #[test]
866 fn test_should_include_file_wildcard_ignores() {
867 let root = Path::new("/project");
868 let config = WalkerConfig::default();
869
870 assert!(!should_include_file(
872 root,
873 Path::new("/project/assets/logo.png"),
874 &config
875 ));
876 assert!(!should_include_file(
877 root,
878 Path::new("/project/Cargo.lock"),
879 &config
880 ));
881 assert!(!should_include_file(
882 root,
883 Path::new("/project/package-lock.json"),
884 &config
885 ));
886
887 assert!(should_include_file(
889 root,
890 Path::new("/project/src/lockfile.rs"),
891 &config
892 ));
893 }
894
895 #[test]
896 fn test_should_include_file_no_false_positives() {
897 let root = Path::new("/project");
898 let config = WalkerConfig::default();
899
900 assert!(!should_include_file(
902 root,
903 Path::new("/project/.env"),
904 &config
905 ));
906 assert!(!should_include_file(
907 root,
908 Path::new("/project/.envrc"),
909 &config
910 ));
911 assert!(!should_include_file(
912 root,
913 Path::new("/project/.github/workflows/ci.yml"),
914 &config
915 ));
916
917 assert!(should_include_file(
919 root,
920 Path::new("/project/src/env.rs"),
921 &config
922 ));
923 assert!(should_include_file(
924 root,
925 Path::new("/project/src/dotenv.rs"),
926 &config
927 ));
928 }
929}