use std::io;
use std::path::{Path, PathBuf};
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::WalkBuilder;
use crate::default_ignores::DEFAULT_IGNORES;
pub struct FileFilter {
root: PathBuf,
default_ignore_matcher: Gitignore,
git_ignore_matcher: Option<Gitignore>,
contextignore_matcher: Option<Gitignore>,
include_globset: Option<GlobSet>,
include_literals_rel: Vec<PathBuf>,
include_literals_abs: Vec<PathBuf>,
use_gitignore: bool,
}
impl FileFilter {
pub fn new(root: &Path, config: &WalkerConfig) -> io::Result<Self> {
let root = if root.exists() {
root.canonicalize()?
} else {
root.to_path_buf()
};
let default_ignore_matcher = build_ignore_matcher(&root, config);
let git_ignore_matcher = if config.use_gitignore {
build_combined_git_ignore_matcher(&root)
} else {
None
};
let contextignore_matcher = build_all_contextignore_matcher(&root);
let include_globset = build_include_globset(&root, &config.include_patterns)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let literal_paths = get_literal_paths(&config.include_patterns);
let mut include_literals_rel = Vec::new();
let mut include_literals_abs = Vec::new();
for p in literal_paths {
if p.is_absolute() {
let abs = p.canonicalize().unwrap_or(p);
include_literals_abs.push(abs);
} else {
include_literals_rel.push(p);
}
}
Ok(Self {
root,
default_ignore_matcher,
git_ignore_matcher,
contextignore_matcher,
include_globset,
include_literals_rel,
include_literals_abs,
use_gitignore: config.use_gitignore,
})
}
pub fn should_include(&self, file_path: &Path) -> bool {
let rel_path = match file_path.strip_prefix(&self.root) {
Ok(p) => p,
Err(_) => return false,
};
let is_dir = file_path.is_dir();
for component in rel_path.components() {
if let std::path::Component::Normal(name) = component {
if let Some(name_str) = name.to_str() {
if name_str.starts_with('.') {
return false;
}
}
}
}
if self
.default_ignore_matcher
.matched_path_or_any_parents(rel_path, is_dir)
.is_ignore()
{
return false;
}
if self.use_gitignore {
if let Some(ref matcher) = self.git_ignore_matcher {
if matcher
.matched_path_or_any_parents(rel_path, is_dir)
.is_ignore()
{
return false;
}
}
}
if let Some(ref matcher) = self.contextignore_matcher {
if matcher
.matched_path_or_any_parents(rel_path, is_dir)
.is_ignore()
{
return false;
}
}
let has_includes = self.include_globset.is_some()
|| !self.include_literals_rel.is_empty()
|| !self.include_literals_abs.is_empty();
if has_includes {
let mut matches = false;
for literal in &self.include_literals_rel {
if rel_path.starts_with(literal) {
matches = true;
break;
}
}
if !matches {
let abs_path = self.root.join(rel_path);
for literal in &self.include_literals_abs {
if abs_path.starts_with(literal) || abs_path == *literal {
matches = true;
break;
}
}
}
if !matches {
if let Some(ref globset) = self.include_globset {
matches = globset.is_match(rel_path);
}
}
if !matches {
return false;
}
}
true
}
}
fn build_combined_git_ignore_matcher(root: &Path) -> Option<Gitignore> {
let mut builder = GitignoreBuilder::new(root);
for excludes_file in get_all_git_excludes_files(root) {
let _ = builder.add(&excludes_file);
}
if let Some(global_gitignore) = find_default_global_gitignore() {
let _ = builder.add(&global_gitignore);
}
if let Some(git_dir) = find_git_dir(root) {
let exclude_path = git_dir.join("info").join("exclude");
if exclude_path.exists() {
let _ = builder.add(&exclude_path);
}
}
let mut current = root.parent();
while let Some(parent) = current {
for filename in &[".gitignore", ".ignore"] {
let ignore_path = parent.join(filename);
if ignore_path.exists() {
let _ = builder.add(&ignore_path);
}
}
if parent.join(".git").exists() {
break;
}
current = parent.parent();
}
for filename in &[".gitignore", ".ignore"] {
let ignore_path = root.join(filename);
if ignore_path.exists() {
let _ = builder.add(&ignore_path);
}
}
add_nested_ignore_files(&mut builder, root, &[".gitignore", ".ignore"]);
builder.build().ok()
}
fn build_all_contextignore_matcher(root: &Path) -> Option<Gitignore> {
let mut builder = GitignoreBuilder::new(root);
let mut found_any = false;
let contextignore_path = root.join(".contextignore");
if contextignore_path.exists() && builder.add(&contextignore_path).is_none() {
found_any = true;
}
fn add_nested(builder: &mut GitignoreBuilder, dir: &Path, found: &mut bool) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
let contextignore = path.join(".contextignore");
if contextignore.exists() && builder.add(&contextignore).is_none() {
*found = true;
}
add_nested(builder, &path, found);
}
}
}
add_nested(&mut builder, root, &mut found_any);
if found_any {
builder.build().ok()
} else {
None
}
}
fn find_default_global_gitignore() -> Option<PathBuf> {
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
let path = PathBuf::from(xdg_config).join("git").join("ignore");
if path.exists() {
return Some(path);
}
}
let home = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))?;
let path = home.join(".config").join("git").join("ignore");
if path.exists() {
return Some(path);
}
let legacy_path = home.join(".gitignore_global");
if legacy_path.exists() {
return Some(legacy_path);
}
None
}
fn get_all_git_excludes_files(root: &Path) -> Vec<PathBuf> {
use std::process::Command;
let mut excludes_files = Vec::new();
for scope in &["--system", "--global", "--local"] {
let mut cmd = Command::new("git");
cmd.args(["config", scope, "core.excludesFile"]);
if *scope == "--local" {
cmd.current_dir(root);
}
if let Ok(output) = cmd.output() {
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let path_str = path_str.trim();
if !path_str.is_empty() {
if let Some(path) = expand_tilde(path_str) {
if path.exists() {
excludes_files.push(path);
}
}
}
}
}
}
excludes_files
}
fn expand_tilde(path_str: &str) -> Option<PathBuf> {
if let Some(stripped) = path_str.strip_prefix("~/") {
let home = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())?;
Some(PathBuf::from(home).join(stripped))
} else {
Some(PathBuf::from(path_str))
}
}
fn find_git_dir(root: &Path) -> Option<PathBuf> {
let git_dir = root.join(".git");
if git_dir.is_dir() {
return Some(git_dir);
}
let mut current = root.parent();
while let Some(parent) = current {
let git_dir = parent.join(".git");
if git_dir.is_dir() {
return Some(git_dir);
}
current = parent.parent();
}
None
}
fn add_nested_ignore_files(builder: &mut GitignoreBuilder, dir: &Path, filenames: &[&str]) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
for filename in filenames {
let ignore_path = path.join(filename);
if ignore_path.exists() {
let _ = builder.add(&ignore_path);
}
}
add_nested_ignore_files(builder, &path, filenames);
}
}
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub absolute_path: PathBuf,
pub relative_path: PathBuf,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct WalkerConfig {
pub use_gitignore: bool,
pub use_default_ignores: bool,
pub custom_ignores: Vec<String>,
pub include_patterns: Vec<String>,
}
impl Default for WalkerConfig {
fn default() -> Self {
Self {
use_gitignore: true,
use_default_ignores: true,
custom_ignores: Vec::new(),
include_patterns: Vec::new(),
}
}
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
fn build_include_globset(
root: &Path,
patterns: &[String],
) -> Result<Option<GlobSet>, globset::Error> {
let glob_patterns: Vec<&String> = patterns.iter().filter(|p| is_glob_pattern(p)).collect();
if glob_patterns.is_empty() {
return Ok(None);
}
let root_str = root.to_string_lossy();
let mut builder = GlobSetBuilder::new();
for pattern in glob_patterns {
let normalized = if pattern.starts_with('/') {
if let Some(rel) = pattern.strip_prefix(root_str.as_ref()) {
rel.trim_start_matches('/').to_string()
} else if let Some(rel) = pattern.strip_prefix(&format!("{}/", root_str)) {
rel.to_string()
} else {
pattern.clone()
}
} else {
pattern.clone()
};
let glob = GlobBuilder::new(&normalized)
.literal_separator(true)
.build()?;
builder.add(glob);
}
Ok(Some(builder.build()?))
}
fn get_literal_paths(patterns: &[String]) -> Vec<PathBuf> {
patterns
.iter()
.filter(|p| !is_glob_pattern(p))
.map(PathBuf::from)
.collect()
}
fn build_ignore_matcher(root: &Path, config: &WalkerConfig) -> Gitignore {
let mut builder = GitignoreBuilder::new(root);
if config.use_default_ignores {
for pattern in DEFAULT_IGNORES {
let _ = builder.add_line(None, pattern);
}
}
for pattern in &config.custom_ignores {
let _ = builder.add_line(None, pattern);
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
#[cfg(test)]
pub fn should_include_file(root: &Path, file_path: &Path, config: &WalkerConfig) -> bool {
match FileFilter::new(root, config) {
Ok(filter) => filter.should_include(file_path),
Err(_) => false,
}
}
pub fn discover_files(root: &Path, config: &WalkerConfig) -> io::Result<Vec<FileEntry>> {
let root = root.canonicalize()?;
let mut entries = Vec::new();
let ignore_matcher = build_ignore_matcher(&root, config);
let include_globset = build_include_globset(&root, &config.include_patterns)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
let literal_paths: Vec<PathBuf> = get_literal_paths(&config.include_patterns)
.into_iter()
.map(|p| if p.is_absolute() { p } else { root.join(p) })
.collect();
let has_globs = config.include_patterns.iter().any(|p| is_glob_pattern(p));
let mut process_walk = |start_path: &Path, apply_glob_filter: bool| -> io::Result<()> {
if !start_path.exists() {
eprintln!("Warning: path does not exist: {}", start_path.display());
return Ok(());
}
let mut builder = WalkBuilder::new(start_path);
builder.git_ignore(config.use_gitignore);
builder.git_global(config.use_gitignore);
builder.git_exclude(config.use_gitignore);
builder.add_custom_ignore_filename(".contextignore");
for result in builder.build() {
let entry = match result {
Ok(e) => e,
Err(err) => {
eprintln!("Warning: {}", err);
continue;
}
};
let file_type = match entry.file_type() {
Some(ft) => ft,
None => continue,
};
if file_type.is_dir() {
continue;
}
let abs_path = entry.path().to_path_buf();
let rel_path = match abs_path.strip_prefix(&root) {
Ok(p) => p.to_path_buf(),
Err(_) => continue,
};
if ignore_matcher
.matched_path_or_any_parents(&rel_path, false)
.is_ignore()
{
continue;
}
if apply_glob_filter {
if let Some(ref globset) = include_globset {
if !globset.is_match(&rel_path) {
continue;
}
}
}
if is_binary_file(&abs_path) {
continue;
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
entries.push(FileEntry {
absolute_path: abs_path,
relative_path: rel_path,
size,
});
}
Ok(())
};
if config.include_patterns.is_empty() {
process_walk(&root, false)?;
} else {
if has_globs {
process_walk(&root, true)?;
}
for literal_path in &literal_paths {
process_walk(literal_path, false)?;
}
}
entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
entries.dedup_by(|a, b| a.absolute_path == b.absolute_path);
Ok(entries)
}
fn is_binary_file(path: &Path) -> bool {
use std::fs::File;
use std::io::Read;
let mut file = match File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut buffer = [0u8; 8192];
let bytes_read = match file.read(&mut buffer) {
Ok(n) => n,
Err(_) => return false,
};
buffer[..bytes_read].contains(&0)
}
pub fn format_size(size: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if size >= GB {
format!("{:.1} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1} KB", size as f64 / KB as f64)
} else {
format!("{} B", size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_pattern_matching() {
let root = Path::new("/project");
let patterns = vec!["src/*.rs".to_string()];
let globset = build_include_globset(root, &patterns).unwrap().unwrap();
assert!(globset.is_match(Path::new("src/main.rs")));
assert!(globset.is_match(Path::new("src/cli.rs")));
assert!(!globset.is_match(Path::new("src/analytics/mod.rs")));
assert!(!globset.is_match(Path::new("src/db/mod.rs")));
}
#[test]
fn test_glob_pattern_recursive() {
let root = Path::new("/project");
let patterns = vec!["src/**/*.rs".to_string()];
let globset = build_include_globset(root, &patterns).unwrap().unwrap();
assert!(globset.is_match(Path::new("src/main.rs")));
assert!(globset.is_match(Path::new("src/analytics/mod.rs")));
assert!(globset.is_match(Path::new("src/db/mod.rs")));
assert!(!globset.is_match(Path::new("tests/test.rs")));
}
#[test]
fn test_glob_pattern_absolute() {
let root = Path::new("/project");
let patterns = vec!["/project/src/**/*.rs".to_string()];
let globset = build_include_globset(root, &patterns).unwrap().unwrap();
assert!(globset.is_match(Path::new("src/main.rs")));
assert!(globset.is_match(Path::new("src/db/mod.rs")));
assert!(!globset.is_match(Path::new("tests/test.rs")));
}
#[test]
fn test_should_include_file_default_ignores() {
let root = Path::new("/project");
let config = WalkerConfig::default();
assert!(!should_include_file(
root,
Path::new("/project/node_modules/foo.js"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/.git/config"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/target/debug/main"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/main.rs"),
&config
));
}
#[test]
fn test_should_include_file_custom_ignores() {
let root = Path::new("/project");
let config = WalkerConfig {
use_gitignore: true,
use_default_ignores: false, custom_ignores: vec!["vendor/".to_string(), "generated/".to_string()],
include_patterns: vec![],
};
assert!(!should_include_file(
root,
Path::new("/project/vendor/lib.rs"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/generated/types.rs"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/main.rs"),
&config
));
}
#[test]
fn test_should_include_file_include_patterns() {
let root = Path::new("/project");
let config = WalkerConfig {
use_gitignore: true,
use_default_ignores: true,
custom_ignores: vec![],
include_patterns: vec!["src/**/*.rs".to_string()],
};
assert!(should_include_file(
root,
Path::new("/project/src/main.rs"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/db/mod.rs"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/tests/test.rs"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/build.rs"),
&config
));
}
#[test]
fn test_should_include_file_wildcard_ignores() {
let root = Path::new("/project");
let config = WalkerConfig::default();
assert!(!should_include_file(
root,
Path::new("/project/assets/logo.png"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/Cargo.lock"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/package-lock.json"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/lockfile.rs"),
&config
));
}
#[test]
fn test_should_include_file_no_false_positives() {
let root = Path::new("/project");
let config = WalkerConfig::default();
assert!(!should_include_file(
root,
Path::new("/project/.env"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/.envrc"),
&config
));
assert!(!should_include_file(
root,
Path::new("/project/.github/workflows/ci.yml"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/env.rs"),
&config
));
assert!(should_include_file(
root,
Path::new("/project/src/dotenv.rs"),
&config
));
}
}