use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IgnoreStatus {
Visible,
GitIgnored,
Hidden,
CustomIgnored,
}
#[derive(Debug)]
pub struct IgnorePatterns {
gitignores: Vec<(PathBuf, Gitignore)>,
gitignore_mtimes: HashMap<PathBuf, SystemTime>,
custom_patterns: Vec<String>,
show_hidden: bool,
show_gitignored: bool,
show_custom_ignored: bool,
}
impl IgnorePatterns {
pub fn new() -> Self {
Self {
gitignores: Vec::new(),
gitignore_mtimes: HashMap::new(),
custom_patterns: Vec::new(),
show_hidden: false,
show_gitignored: false,
show_custom_ignored: false,
}
}
pub fn load_gitignore_from_bytes(
&mut self,
dir: &Path,
contents: &[u8],
mtime: Option<SystemTime>,
) {
let mut builder = GitignoreBuilder::new(dir);
let source = dir.join(".gitignore");
for line in contents.split(|&b| b == b'\n') {
let line = std::str::from_utf8(line).unwrap_or("");
if let Err(e) = builder.add_line(Some(source.clone()), line) {
tracing::warn!("Malformed .gitignore line in {:?}: {}", source, e);
}
}
match builder.build() {
Ok(gitignore) => {
self.gitignores.retain(|(path, _)| path != dir);
self.gitignores.push((dir.to_path_buf(), gitignore));
if let Some(mtime) = mtime {
self.gitignore_mtimes.insert(dir.to_path_buf(), mtime);
} else {
self.gitignore_mtimes.remove(dir);
}
}
Err(e) => {
tracing::warn!("Failed to build .gitignore for {:?}: {}", dir, e);
}
}
}
pub fn remove_gitignore(&mut self, dir: &Path) {
self.gitignores.retain(|(d, _)| d != dir);
self.gitignore_mtimes.remove(dir);
}
pub fn loaded_gitignore_dirs(&self) -> Vec<PathBuf> {
self.gitignores.iter().map(|(d, _)| d.clone()).collect()
}
pub fn stored_gitignore_mtime(&self, dir: &Path) -> Option<SystemTime> {
self.gitignore_mtimes.get(dir).copied()
}
pub fn add_custom_pattern(&mut self, pattern: String) {
if !self.custom_patterns.contains(&pattern) {
self.custom_patterns.push(pattern);
}
}
pub fn remove_custom_pattern(&mut self, pattern: &str) {
self.custom_patterns.retain(|p| p != pattern);
}
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
if !self.show_hidden && is_hidden_name(path) {
return true;
}
if !self.show_custom_ignored && self.matches_custom_pattern(path) {
return true;
}
if !self.show_gitignored && self.matches_gitignore(path, is_dir) {
return true;
}
false
}
pub fn get_status(&self, path: &Path, is_dir: bool) -> IgnoreStatus {
if is_hidden_name(path) {
return IgnoreStatus::Hidden;
}
if self.matches_custom_pattern(path) {
return IgnoreStatus::CustomIgnored;
}
if self.matches_gitignore(path, is_dir) {
return IgnoreStatus::GitIgnored;
}
IgnoreStatus::Visible
}
fn matches_gitignore(&self, path: &Path, is_dir: bool) -> bool {
for (gitignore_dir, gitignore) in &self.gitignores {
if path.starts_with(gitignore_dir) {
let relative_path = path.strip_prefix(gitignore_dir).unwrap_or(path);
let matched = gitignore.matched(relative_path, is_dir);
if matched.is_ignore() {
return true;
}
}
}
false
}
fn matches_custom_pattern(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for pattern in &self.custom_patterns {
if pattern.ends_with('/') {
if path_str.contains(pattern.trim_end_matches('/')) {
return true;
}
} else if pattern.starts_with('*') {
let ext = pattern.trim_start_matches('*');
if path_str.ends_with(ext) {
return true;
}
} else {
if path_str.contains(pattern) {
return true;
}
}
}
false
}
pub fn set_show_hidden(&mut self, show: bool) {
self.show_hidden = show;
}
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
pub fn set_show_gitignored(&mut self, show: bool) {
self.show_gitignored = show;
}
pub fn show_gitignored(&self) -> bool {
self.show_gitignored
}
pub fn set_show_custom_ignored(&mut self, show: bool) {
self.show_custom_ignored = show;
}
pub fn toggle_show_gitignored(&mut self) {
self.show_gitignored = !self.show_gitignored;
}
pub fn toggle_show_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
}
pub fn clear_gitignores(&mut self) {
self.gitignores.clear();
self.gitignore_mtimes.clear();
}
pub fn clear_custom_patterns(&mut self) {
self.custom_patterns.clear();
}
pub fn gitignore_count(&self) -> usize {
self.gitignores.len()
}
}
impl Default for IgnorePatterns {
fn default() -> Self {
Self::new()
}
}
fn is_hidden_name(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.') && n != "." && n != "..")
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hidden_file_detection() {
let patterns = IgnorePatterns::new();
assert_eq!(
patterns.get_status(Path::new("/foo/.hidden"), false),
IgnoreStatus::Hidden
);
assert_eq!(
patterns.get_status(Path::new("/foo/visible.txt"), false),
IgnoreStatus::Visible
);
assert_eq!(
patterns.get_status(Path::new("."), true),
IgnoreStatus::Visible
);
assert_eq!(
patterns.get_status(Path::new(".."), true),
IgnoreStatus::Visible
);
}
#[test]
fn test_custom_patterns() {
let mut patterns = IgnorePatterns::new();
patterns.add_custom_pattern("*.o".to_string());
patterns.add_custom_pattern("target/".to_string());
assert_eq!(
patterns.get_status(Path::new("/foo/main.o"), false),
IgnoreStatus::CustomIgnored
);
assert_eq!(
patterns.get_status(Path::new("/foo/target/debug"), true),
IgnoreStatus::CustomIgnored
);
assert_eq!(
patterns.get_status(Path::new("/foo/src/main.rs"), false),
IgnoreStatus::Visible
);
}
#[test]
fn test_gitignore_loading() {
let mut patterns = IgnorePatterns::new();
patterns.load_gitignore_from_bytes(
Path::new("/foo"),
b"*.log\nbuild/\n# Comment\n!important.log\n",
None,
);
assert_eq!(patterns.gitignore_count(), 1);
}
#[test]
fn test_show_hidden_toggle() {
let mut patterns = IgnorePatterns::new();
let hidden_path = Path::new("/foo/.hidden");
assert!(!patterns.show_hidden());
assert!(patterns.is_ignored(hidden_path, false));
patterns.toggle_show_hidden();
assert!(patterns.show_hidden());
assert!(!patterns.is_ignored(hidden_path, false));
}
#[test]
fn test_show_gitignored_toggle() {
let mut patterns = IgnorePatterns::new();
assert!(!patterns.show_gitignored());
patterns.toggle_show_gitignored();
assert!(patterns.show_gitignored());
patterns.set_show_gitignored(false);
assert!(!patterns.show_gitignored());
}
#[test]
fn test_hidden_gitignored_respects_gitignore_filter() {
let root = Path::new("/repo");
let mut patterns = IgnorePatterns::new();
patterns.load_gitignore_from_bytes(root, b".DS_Store\n", None);
patterns.set_show_hidden(true);
patterns.set_show_gitignored(false);
let ds_store = root.join(".DS_Store");
assert!(
patterns.is_ignored(&ds_store, false),
".DS_Store is gitignored; should be hidden despite show_hidden=true"
);
let gitignore_file = root.join(".gitignore");
assert!(
!patterns.is_ignored(&gitignore_file, false),
".gitignore is hidden but not gitignored; should be visible \
when show_hidden=true"
);
patterns.set_show_hidden(false);
patterns.set_show_gitignored(true);
assert!(
patterns.is_ignored(&ds_store, false),
"show_hidden=false still hides .DS_Store (hidden filter)"
);
patterns.set_show_hidden(true);
patterns.set_show_gitignored(true);
assert!(!patterns.is_ignored(&ds_store, false));
}
#[test]
fn test_multiple_gitignores() {
let root = Path::new("/repo");
let sub = root.join("subdir");
let mut patterns = IgnorePatterns::new();
patterns.load_gitignore_from_bytes(root, b"*.tmp\n", None);
patterns.load_gitignore_from_bytes(&sub, b"*.bak\n", None);
assert_eq!(patterns.gitignore_count(), 2);
}
}