use std::path::Path;
const IGNORE_FILENAME: &str = ".impactignore";
#[derive(Debug, Clone, Default)]
pub struct IgnoreSet {
patterns: Vec<Pattern>,
}
#[derive(Debug, Clone)]
enum Pattern {
Literal(String),
Glob(String),
}
impl IgnoreSet {
pub fn empty() -> Self {
Self::default()
}
pub fn load(root: &Path) -> Self {
let path = root.join(IGNORE_FILENAME);
let Ok(src) = std::fs::read_to_string(&path) else {
return Self::empty();
};
Self::parse(&src)
}
pub fn parse(src: &str) -> Self {
let patterns = src
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| {
if l.contains('*') {
Pattern::Glob(l.to_string())
} else {
Pattern::Literal(l.to_string())
}
})
.collect();
Self { patterns }
}
pub fn is_ignored(&self, path: &Path) -> bool {
if self.patterns.is_empty() {
return false;
}
let s = path.to_string_lossy();
let s_normalized = s.replace('\\', "/");
self.patterns.iter().any(|p| match p {
Pattern::Literal(needle) => s_normalized.contains(needle),
Pattern::Glob(pat) => {
s_normalized
.split('/')
.any(|component| glob_matches(pat, component))
|| glob_matches(pat, &s_normalized)
}
})
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
#[cfg(test)]
fn len(&self) -> usize {
self.patterns.len()
}
}
fn glob_matches(pattern: &str, input: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let i: Vec<char> = input.chars().collect();
glob_inner(&p, &i, 0, 0)
}
fn glob_inner(p: &[char], i: &[char], pi: usize, ii: usize) -> bool {
if pi == p.len() {
return ii == i.len();
}
if p[pi] == '*' {
for skip in ii..=i.len() {
if glob_inner(p, i, pi + 1, skip) {
return true;
}
}
false
} else if ii < i.len() && p[pi] == i[ii] {
glob_inner(p, i, pi + 1, ii + 1)
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn empty_matcher_never_matches() {
let m = IgnoreSet::empty();
assert!(!m.is_ignored(Path::new("src/lib.rs")));
assert!(!m.is_ignored(Path::new("anything/at/all")));
assert!(m.is_empty());
}
#[test]
fn literal_pattern_matches_substring() {
let m = IgnoreSet::parse("vendor\n");
assert!(m.is_ignored(Path::new("vendor/foo.rs")));
assert!(m.is_ignored(Path::new("src/vendor/bar.rs")));
assert!(!m.is_ignored(Path::new("src/lib.rs")));
}
#[test]
fn glob_matches_single_component() {
let m = IgnoreSet::parse("*.generated.rs\n");
assert!(m.is_ignored(Path::new("src/api.generated.rs")));
assert!(m.is_ignored(Path::new("target/api.generated.rs")));
assert!(!m.is_ignored(Path::new("src/api.rs")));
}
#[test]
fn comments_and_blank_lines_ignored() {
let src = "\
# this is a comment
\n\
# leading whitespace comment
\n\
vendor\n\
\n\
# another comment\n\
";
let m = IgnoreSet::parse(src);
assert_eq!(m.len(), 1);
}
#[test]
fn missing_file_yields_empty_matcher() {
let dir = tempfile::TempDir::new().unwrap();
let m = IgnoreSet::load(dir.path());
assert!(m.is_empty());
}
#[test]
fn load_reads_file_at_root() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join(".impactignore"),
"vendor\ntarget\n*.generated.rs\n",
)
.unwrap();
let m = IgnoreSet::load(dir.path());
assert_eq!(m.len(), 3);
assert!(m.is_ignored(Path::new("vendor/lib.rs")));
assert!(m.is_ignored(Path::new("target/debug/build.rs")));
assert!(m.is_ignored(Path::new("api.generated.rs")));
}
#[test]
fn windows_style_paths_are_normalized() {
let m = IgnoreSet::parse("vendor\n");
let p = PathBuf::from("src\\vendor\\mod.rs");
assert!(m.is_ignored(&p));
}
#[test]
fn glob_matches_helper_covers_wildcard_semantics() {
assert!(glob_matches("*", "anything"));
assert!(glob_matches("*.rs", "lib.rs"));
assert!(glob_matches("a*b", "axxxb"));
assert!(glob_matches("a*b", "ab"));
assert!(!glob_matches("a*b", "ac"));
assert!(glob_matches("prefix*", "prefix_and_more"));
assert!(!glob_matches("exact", "exactly"));
}
}