use std::path::Path;
use crate::error::{MindError, Result};
pub const BUILTIN_IGNORES: [&str; 4] = [".git", ".hg", ".svn", ".bzr"];
#[derive(Debug, Clone)]
struct Rule {
pattern: glob::Pattern,
dir_only: bool,
}
#[derive(Debug, Clone, Default)]
pub struct IgnoreSet {
rules: Vec<Rule>,
}
impl IgnoreSet {
pub fn new(entries: &[String], item: &str) -> Result<Self> {
let mut rules = Vec::new();
for raw in entries {
let entry = raw.trim();
if entry.is_empty() {
return Err(bad_ignore(item, raw, "it is empty"));
}
if entry.contains('\0') {
return Err(bad_ignore(item, raw, "it contains a NUL byte"));
}
if entry.starts_with('/') || entry.starts_with('~') {
return Err(bad_ignore(
item,
raw,
"it is not relative; an ignore pattern is matched against paths \
inside the item, so it must not start with '/' or '~'",
));
}
let dir_only = entry.ends_with('/');
let mut body = entry.trim_end_matches('/');
while let Some(stripped) = body.strip_prefix("./") {
body = stripped;
}
if body.is_empty() {
return Err(bad_ignore(
item,
raw,
"it is empty after removing leading './' components",
));
}
if body.split('/').any(|c| c == "..") {
return Err(bad_ignore(
item,
raw,
"it has a '..' component, which would reach outside the item",
));
}
let pattern = glob::Pattern::new(body)
.map_err(|e| bad_ignore(item, raw, &format!("it is not a valid glob ({e})")))?;
rules.push(Rule { pattern, dir_only });
}
for name in BUILTIN_IGNORES {
rules.push(Rule {
pattern: glob::Pattern::new(name).expect("built-in ignore is a valid glob"),
dir_only: false,
});
}
Ok(IgnoreSet { rules })
}
pub fn builtin() -> Self {
IgnoreSet::new(&[], "").expect("built-in-only set always compiles")
}
pub fn is_ignored(&self, rel: &Path, is_dir: bool) -> bool {
let rel: String = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if rel.is_empty() {
return false;
}
let opts = glob::MatchOptions {
require_literal_separator: true,
..glob::MatchOptions::new()
};
self.rules.iter().any(|r| {
if r.dir_only && !is_dir {
return false;
}
r.pattern.matches_with(&rel, opts)
})
}
pub fn is_under_ignored(&self, rel: &Path) -> bool {
let mut prefix = std::path::PathBuf::new();
let mut comps: Vec<_> = rel.components().collect();
comps.pop();
for c in comps {
prefix.push(c);
if self.is_ignored(&prefix, true) {
return true;
}
}
self.is_ignored(rel, false)
}
}
fn bad_ignore(item: &str, entry: &str, reason: &str) -> MindError {
MindError::BadIgnorePattern {
item: crate::sanitize::strip_ansi(item),
entry: crate::sanitize::strip_ansi(entry),
reason: reason.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn set(entries: &[&str]) -> IgnoreSet {
IgnoreSet::new(
&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
"skill:t",
)
.expect("compiles")
}
#[test]
fn builtin_vcs_dirs_are_ignored_without_any_declaration() {
let s = IgnoreSet::builtin();
for name in BUILTIN_IGNORES {
assert!(
s.is_ignored(&PathBuf::from(name), true),
"{name} must be ignored as a directory"
);
assert!(
s.is_ignored(&PathBuf::from(name), false),
"{name} must be ignored as a FILE too (a submodule/worktree/\
separate-git-dir `.git` is a file, not a directory)"
);
}
assert!(!s.is_ignored(&PathBuf::from("target"), true));
assert!(!s.is_ignored(&PathBuf::from("node_modules"), true));
}
#[test]
fn trailing_slash_marks_a_directory_only_rule() {
let dir_only = set(&["scratch/"]);
assert!(dir_only.is_ignored(&PathBuf::from("scratch"), true));
assert!(
!dir_only.is_ignored(&PathBuf::from("scratch"), false),
"a file named `scratch` is not what `scratch/` names"
);
let either = set(&["scratch"]);
assert!(either.is_ignored(&PathBuf::from("scratch"), true));
assert!(either.is_ignored(&PathBuf::from("scratch"), false));
}
#[test]
fn glob_semantics_match_the_item_selectors() {
let s = set(&["*.tmp", "docs/**"]);
assert!(s.is_ignored(&PathBuf::from("a.tmp"), false));
assert!(
!s.is_ignored(&PathBuf::from("sub/a.tmp"), false),
"`*` must not cross a `/`"
);
assert!(s.is_ignored(&PathBuf::from("docs/guide/x.md"), false));
}
#[test]
fn a_file_under_an_ignored_directory_is_ignored() {
let s = set(&["scratch/"]);
assert!(s.is_under_ignored(&PathBuf::from("scratch/notes/a.md")));
assert!(!s.is_under_ignored(&PathBuf::from("keep/a.md")));
assert!(IgnoreSet::builtin().is_under_ignored(&PathBuf::from(".git/config")));
}
#[test]
fn unsafe_or_malformed_patterns_are_refused() {
for bad in [
"",
" ",
"/etc/passwd",
"~/secrets",
"../outside",
"a/../b",
"[bad",
] {
let err = IgnoreSet::new(&[bad.to_string()], "skill:t")
.expect_err(&format!("{bad:?} must be refused"));
assert!(
matches!(err, MindError::BadIgnorePattern { .. }),
"{bad:?} must be a BadIgnorePattern, got {err}"
);
}
assert!(IgnoreSet::new(&["a..b".to_string()], "skill:t").is_ok());
}
#[test]
fn the_item_root_is_never_ignored() {
let s = set(&["*"]);
assert!(!s.is_ignored(&PathBuf::from(""), true));
}
#[test]
fn a_leading_cur_dir_component_is_normalized_not_left_inert() {
let s = set(&["./scratch/"]);
assert!(
s.is_ignored(&PathBuf::from("scratch"), true),
"`./scratch/` must behave exactly like `scratch/`, not match nothing"
);
let s2 = set(&["././nested/file.md"]);
assert!(
s2.is_ignored(&PathBuf::from("nested/file.md"), false),
"repeated leading './' components must all be stripped"
);
}
}