use anyhow::{Context, Result};
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::warn;
pub fn pattern_to_regex(pattern: &str) -> Result<Regex> {
let mut regex_pattern = String::new();
for ch in pattern.chars() {
match ch {
'*' => regex_pattern.push_str(".*"),
'?' => regex_pattern.push('.'),
'\\' | '^' | '$' | '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' => {
regex_pattern.push('\\');
regex_pattern.push(ch);
}
_ => regex_pattern.push(ch),
}
}
if !regex_pattern.starts_with('^') {
regex_pattern.insert(0, '^');
}
if !regex_pattern.ends_with('$') {
regex_pattern.push('$');
}
Regex::new(®ex_pattern)
.with_context(|| format!("failed to compile regex from pattern: {pattern}"))
}
pub fn read_ignore_patterns(ignore_file: &Path) -> Result<Vec<Regex>> {
let content = fs::read_to_string(ignore_file)
.with_context(|| format!("failed to read ignore file: {}", ignore_file.display()))?;
let patterns: Result<Vec<Regex>> = content
.lines()
.filter(|line| !line.trim().is_empty()) .map(|line| pattern_to_regex(line.trim()))
.collect();
patterns.with_context(|| format!("failed to parse patterns from: {}", ignore_file.display()))
}
pub fn is_path_ignored(rel_path: &str, patterns: &[Regex]) -> bool {
patterns.iter().any(|pattern| pattern.is_match(rel_path))
}
pub fn filter_with_ignore_patterns(
entries: &[super::files::DirectoryEntry],
base_dir: &Path,
dir_path: &Path,
ignore_file: Option<&PathBuf>,
) -> Result<Vec<super::files::DirectoryEntry>> {
let canonical_base = base_dir
.canonicalize()
.unwrap_or_else(|_| base_dir.to_path_buf());
let ignore_file = match ignore_file {
Some(file) => file,
None => return Ok(entries.to_vec()),
};
let ignore_path = if ignore_file.is_absolute() {
ignore_file.clone()
} else {
base_dir.join(ignore_file)
};
match fs::metadata(&ignore_path) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(entries.to_vec());
}
Err(err) => {
warn!(
"failed to read ignore file metadata for {}: {}",
ignore_path.display(),
err
);
return Ok(entries.to_vec());
}
}
let patterns = match read_ignore_patterns(&ignore_path) {
Ok(patterns) => patterns,
Err(err) => {
warn!(
"failed to read ignore patterns from {}: {}",
ignore_path.display(),
err
);
return Ok(entries.to_vec());
}
};
let filtered: Vec<_> = entries
.iter()
.filter(|entry| {
let entry_path = dir_path.join(&entry.name);
let rel_path = match entry_path.strip_prefix(&canonical_base) {
Ok(path) => path.to_string_lossy().to_string(),
Err(_) => entry.name.clone(), };
!is_path_ignored(&rel_path, &patterns)
})
.cloned()
.collect();
Ok(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_pattern_to_regex() {
let regex = pattern_to_regex("*.txt").unwrap();
assert!(regex.is_match("file.txt"));
assert!(regex.is_match("another.txt"));
assert!(!regex.is_match("file.doc"));
assert!(!regex.is_match("file.txtx"));
let regex = pattern_to_regex("test?").unwrap();
assert!(regex.is_match("test1"));
assert!(regex.is_match("testa"));
assert!(!regex.is_match("test"));
assert!(!regex.is_match("test12"));
let regex = pattern_to_regex("build").unwrap();
assert!(regex.is_match("build"));
assert!(!regex.is_match("builds"));
assert!(!regex.is_match("rebuild"));
let regex = pattern_to_regex("file.log").unwrap();
assert!(regex.is_match("file.log"));
assert!(!regex.is_match("filexlog")); }
#[test]
fn test_ignore_file_reading() {
let temp_dir = TempDir::new().unwrap();
let ignore_file = temp_dir.path().join(".gitignore");
fs::write(&ignore_file, "*.log\ntemp*\n\nbuild\n").unwrap();
let patterns = read_ignore_patterns(&ignore_file).unwrap();
assert_eq!(patterns.len(), 3);
assert!(is_path_ignored("debug.log", &patterns));
assert!(is_path_ignored("temp123", &patterns));
assert!(is_path_ignored("build", &patterns));
assert!(!is_path_ignored("source.rs", &patterns));
}
#[test]
fn test_filtering_directory_entries() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let dir_path = base_dir;
let ignore_file = base_dir.join(".gitignore");
fs::write(&ignore_file, "*.log\ntemp*\nbuild\n").unwrap();
let entries = vec![
super::super::files::DirectoryEntry {
name: "source.rs".to_string(),
size: 1000,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: false,
},
super::super::files::DirectoryEntry {
name: "debug.log".to_string(),
size: 500,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: false,
},
super::super::files::DirectoryEntry {
name: "temp_file".to_string(),
size: 200,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: false,
},
super::super::files::DirectoryEntry {
name: "build".to_string(),
size: 0,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: true,
},
];
let filtered = filter_with_ignore_patterns(
&entries,
base_dir,
dir_path,
Some(&PathBuf::from(".gitignore")),
)
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "source.rs");
}
#[test]
fn test_filtering_returns_entries_on_ignore_read_error() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let dir_path = base_dir;
let ignore_dir = base_dir.join("ignore_dir");
fs::create_dir(&ignore_dir).unwrap();
let entries = vec![
super::super::files::DirectoryEntry {
name: "source.rs".to_string(),
size: 1000,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: false,
},
super::super::files::DirectoryEntry {
name: "debug.log".to_string(),
size: 500,
modified: std::time::SystemTime::UNIX_EPOCH,
is_dir: false,
},
];
let filtered = filter_with_ignore_patterns(
&entries,
base_dir,
dir_path,
Some(&PathBuf::from("ignore_dir")),
)
.unwrap();
assert_eq!(filtered.len(), entries.len());
assert_eq!(filtered[0].name, "source.rs");
assert_eq!(filtered[1].name, "debug.log");
}
}