use std::path::{Path, PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use crate::service::{DirEntryInfo, EntryKind, FileSystemService};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct IgnoreOptions {
pub show_ignored: bool,
pub show_hidden: bool,
}
impl IgnoreOptions {
pub fn show_all() -> Self {
Self { show_ignored: true, show_hidden: true }
}
}
const IGNORE_FILES: &[&str] = &[".gitignore", ".ignore"];
pub struct IgnoreRules {
matchers: Vec<(PathBuf, Gitignore)>,
options: IgnoreOptions,
root: PathBuf,
}
impl IgnoreRules {
pub fn for_root(fs: &dyn FileSystemService, root: &Path, options: IgnoreOptions) -> Self {
let mut rules = Self { matchers: Vec::new(), options, root: root.to_path_buf() };
rules.load_dir(fs, root);
rules.load_file(fs, root, &root.join(".git/info/exclude"));
rules
}
pub fn disabled() -> Self {
Self { matchers: Vec::new(), options: IgnoreOptions::show_all(), root: PathBuf::new() }
}
pub fn options(&self) -> IgnoreOptions {
self.options
}
pub fn load_dir(&mut self, fs: &dyn FileSystemService, dir: &Path) {
if self.matchers.iter().any(|(d, _)| d == dir) {
return;
}
for name in IGNORE_FILES {
let path = dir.join(name);
self.load_file(fs, dir, &path);
}
}
fn load_file(&mut self, fs: &dyn FileSystemService, anchor: &Path, path: &Path) {
let Ok(bytes) = fs.read_file(path) else { return };
let Ok(text) = String::from_utf8(bytes) else { return };
let mut builder = GitignoreBuilder::new(anchor);
let mut added = false;
for line in text.lines() {
if builder.add_line(None, line).is_ok() {
added = true;
}
}
if !added {
return;
}
if let Ok(matcher) = builder.build() {
self.matchers.push((anchor.to_path_buf(), matcher));
}
}
pub fn is_hidden(&self, path: &Path, is_dir: bool) -> bool {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if !self.options.show_hidden && name.starts_with('.') {
return true;
}
if self.options.show_ignored {
return false;
}
self.is_ignored(path, is_dir)
}
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
let mut candidates: Vec<&(PathBuf, Gitignore)> =
self.matchers.iter().filter(|(dir, _)| path.starts_with(dir)).collect();
candidates.sort_by_key(|(dir, _)| std::cmp::Reverse(dir.components().count()));
for (_, matcher) in candidates {
let m = matcher.matched_path_or_any_parents(path, is_dir);
if m.is_ignore() {
return true;
}
if m.is_whitelist() {
return false;
}
}
false
}
pub fn filter(&self, entries: Vec<DirEntryInfo>) -> Vec<DirEntryInfo> {
entries.into_iter().filter(|e| !self.is_hidden(&e.path, e.kind == EntryKind::Dir)).collect()
}
pub fn root(&self) -> &Path {
&self.root
}
}
pub fn matches_exclusion(root: &Path, patterns: &[String], path: &Path, is_dir: bool) -> bool {
if patterns.is_empty() {
return false;
}
let mut builder = GitignoreBuilder::new(root);
for pattern in patterns {
let _ = builder.add_line(None, pattern);
}
let Ok(matcher) = builder.build() else { return false };
matcher.matched(path, is_dir).is_ignore()
}
impl std::fmt::Debug for IgnoreRules {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IgnoreRules")
.field("root", &self.root)
.field("options", &self.options)
.field("matchers", &self.matchers.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use termesh_core::{FsError, FsResult};
#[derive(Default)]
struct Files(Vec<(PathBuf, Vec<u8>)>);
impl Files {
fn with(pairs: &[(&str, &str)]) -> Self {
Self(pairs.iter().map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())).collect())
}
}
impl FileSystemService for Files {
fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
self.0
.iter()
.find(|(p, _)| p == path)
.map(|(_, c)| c.clone())
.ok_or_else(|| FsError::NotFound(path.to_path_buf()))
}
fn read_dir(&self, _: &Path) -> FsResult<Vec<DirEntryInfo>> {
Ok(Vec::new())
}
fn create_file(&self, _: &Path) -> FsResult<()> {
Ok(())
}
fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
Ok(())
}
fn create_dir(&self, _: &Path) -> FsResult<()> {
Ok(())
}
fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
Ok(())
}
fn remove_file(&self, _: &Path) -> FsResult<()> {
Ok(())
}
fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
Ok(())
}
fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
Ok(p.to_path_buf())
}
}
fn rules(files: &[(&str, &str)], options: IgnoreOptions) -> IgnoreRules {
IgnoreRules::for_root(&Files::with(files), Path::new("/r"), options)
}
fn default_rules(files: &[(&str, &str)]) -> IgnoreRules {
rules(files, IgnoreOptions::default())
}
#[test]
fn an_ignored_directory_hides_the_files_underneath_it() {
let rules = default_rules(&[("/r/.gitignore", "target\n")]);
assert!(rules.is_hidden(Path::new("/r/target"), true), "the directory itself");
assert!(rules.is_hidden(Path::new("/r/target/debug"), true), "a directory inside it");
assert!(
rules.is_hidden(Path::new("/r/target/debug/deps/orders.rlib"), false),
"a file several levels down"
);
assert!(!rules.is_hidden(Path::new("/r/src/main.rs"), false), "and nothing else");
}
#[test]
fn a_configured_exclusion_matches_like_a_gitignore_pattern() {
assert!(matches_exclusion(
Path::new("/r"),
&["*.log".to_string()],
Path::new("/r/debug.log"),
false,
));
assert!(!matches_exclusion(
Path::new("/r"),
&["*.log".to_string()],
Path::new("/r/src"),
true,
));
}
#[test]
fn no_patterns_excludes_nothing() {
assert!(!matches_exclusion(Path::new("/r"), &[], Path::new("/r/anything"), false));
}
#[test]
fn a_malformed_pattern_costs_only_itself() {
let patterns = vec!["[".to_string(), "*.log".to_string()];
assert!(matches_exclusion(Path::new("/r"), &patterns, Path::new("/r/debug.log"), false));
}
#[test]
fn gitignore_patterns_hide_matching_entries() {
let r = default_rules(&[("/r/.gitignore", "target\nnode_modules\n*.log\n")]);
assert!(r.is_hidden(Path::new("/r/target"), true));
assert!(r.is_hidden(Path::new("/r/node_modules"), true));
assert!(r.is_hidden(Path::new("/r/debug.log"), false));
assert!(!r.is_hidden(Path::new("/r/src"), true));
}
#[test]
fn dotfiles_are_hidden_by_default() {
let r = default_rules(&[]);
assert!(r.is_hidden(Path::new("/r/.git"), true));
assert!(r.is_hidden(Path::new("/r/.env"), false));
assert!(!r.is_hidden(Path::new("/r/README.md"), false));
}
#[test]
fn show_hidden_reveals_dotfiles_but_still_honours_ignore_files() {
let r = rules(
&[("/r/.gitignore", "target\n")],
IgnoreOptions { show_hidden: true, show_ignored: false },
);
assert!(!r.is_hidden(Path::new("/r/.env"), false), "dotfile now visible");
assert!(r.is_hidden(Path::new("/r/target"), true), "ignore rules still apply");
}
#[test]
fn show_ignored_reveals_ignored_entries() {
let r = rules(
&[("/r/.gitignore", "target\n")],
IgnoreOptions { show_ignored: true, show_hidden: true },
);
assert!(!r.is_hidden(Path::new("/r/target"), true));
}
#[test]
fn whitelist_patterns_un_ignore() {
let r = default_rules(&[("/r/.gitignore", "*.log\n!keep.log\n")]);
assert!(r.is_hidden(Path::new("/r/debug.log"), false));
assert!(!r.is_hidden(Path::new("/r/keep.log"), false), "! should win");
}
#[test]
fn dot_ignore_files_are_honoured_alongside_gitignore() {
let r = default_rules(&[("/r/.ignore", "secrets\n")]);
assert!(r.is_hidden(Path::new("/r/secrets"), true));
}
#[test]
fn git_info_exclude_is_honoured() {
let r = default_rules(&[("/r/.git/info/exclude", "scratch\n")]);
assert!(r.is_hidden(Path::new("/r/scratch"), true));
}
#[test]
fn a_nested_gitignore_overrides_the_root() {
let fs = Files::with(&[("/r/.gitignore", "*.log\n"), ("/r/logs/.gitignore", "!*.log\n")]);
let mut r = IgnoreRules::for_root(&fs, Path::new("/r"), IgnoreOptions::default());
r.load_dir(&fs, Path::new("/r/logs"));
assert!(r.is_hidden(Path::new("/r/debug.log"), false), "root rule still applies");
assert!(!r.is_hidden(Path::new("/r/logs/debug.log"), false), "the deeper .gitignore wins");
}
#[test]
fn a_missing_gitignore_is_not_an_error() {
let r = default_rules(&[]);
assert!(!r.is_hidden(Path::new("/r/anything.txt"), false));
}
#[test]
fn comments_and_blank_lines_are_ignored() {
let r = default_rules(&[("/r/.gitignore", "# a comment\n\n \ntarget\n")]);
assert!(r.is_hidden(Path::new("/r/target"), true));
assert!(!r.is_hidden(Path::new("/r/src"), true));
}
#[test]
fn filter_drops_hidden_entries_and_keeps_the_rest() {
let r = default_rules(&[("/r/.gitignore", "target\n")]);
let entries = vec![
DirEntryInfo { name: "src".into(), path: "/r/src".into(), kind: EntryKind::Dir },
DirEntryInfo { name: "target".into(), path: "/r/target".into(), kind: EntryKind::Dir },
DirEntryInfo { name: ".git".into(), path: "/r/.git".into(), kind: EntryKind::Dir },
DirEntryInfo {
name: "README.md".into(),
path: "/r/README.md".into(),
kind: EntryKind::File,
},
];
let kept: Vec<String> =
r.filter(entries).iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
assert_eq!(kept, ["src", "README.md"]);
}
#[test]
fn disabled_rules_show_everything() {
let r = IgnoreRules::disabled();
assert!(!r.is_hidden(Path::new("/r/.git"), true));
assert!(!r.is_hidden(Path::new("/r/target"), true));
}
#[test]
fn a_directory_only_pattern_does_not_hide_a_file_of_the_same_name() {
let r = default_rules(&[("/r/.gitignore", "build/\n")]);
assert!(r.is_hidden(Path::new("/r/build"), true), "the directory is ignored");
assert!(!r.is_hidden(Path::new("/r/build"), false), "a file named build is not");
}
}