use std::path::Path;
struct Rule {
segments: Vec<String>,
negated: bool,
}
pub(crate) struct DockerIgnore {
rules: Vec<Rule>,
}
impl DockerIgnore {
pub(crate) fn load(context_dir: &Path) -> Self {
let contents =
std::fs::read_to_string(context_dir.join(".dockerignore")).unwrap_or_default();
Self::parse(&contents)
}
fn parse(contents: &str) -> Self {
let mut rules = Vec::new();
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (negated, body) = match line.strip_prefix('!') {
Some(rest) => (true, rest.trim()),
None => (false, line),
};
let cleaned = body.strip_prefix("./").unwrap_or(body).trim_matches('/');
if cleaned.is_empty() {
continue;
}
let segments = cleaned.split('/').map(|s| s.to_string()).collect();
rules.push(Rule { segments, negated });
}
Self { rules }
}
pub(crate) fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub(crate) fn is_excluded(&self, rel: &Path) -> bool {
let path_segs: Vec<&str> = rel
.to_str()
.unwrap_or_default()
.split('/')
.filter(|s| !s.is_empty())
.collect();
let mut excluded = false;
for rule in &self.rules {
if segments_match(&rule.segments, &path_segs) {
excluded = !rule.negated;
}
}
excluded
}
}
fn segments_match(pattern: &[String], path: &[&str]) -> bool {
match pattern.split_first() {
None => path.is_empty(),
Some((head, rest)) => {
if head == "**" {
(0..=path.len()).any(|i| segments_match(rest, &path[i..]))
} else {
match path.split_first() {
Some((ph, ptail)) if wildcard_match(head, ph) => segments_match(rest, ptail),
_ => false,
}
}
}
}
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let txt: Vec<char> = text.chars().collect();
let (mut p, mut t) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while t < txt.len() {
if p < pat.len() && (pat[p] == '?' || pat[p] == txt[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == '*' {
star = Some(p);
mark = t;
p += 1;
} else if let Some(sp) = star {
p = sp + 1;
mark += 1;
t = mark;
} else {
return false;
}
}
while p < pat.len() && pat[p] == '*' {
p += 1;
}
p == pat.len()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn ign(s: &str) -> DockerIgnore {
DockerIgnore::parse(s)
}
fn ex(d: &DockerIgnore, p: &str) -> bool {
d.is_excluded(&PathBuf::from(p))
}
#[test]
fn test_exact_and_subtree() {
let d = ign(".git\nnode_modules\n.env\n");
assert!(ex(&d, ".git"));
assert!(ex(&d, "node_modules"));
assert!(ex(&d, ".env"));
assert!(!ex(&d, "src"));
assert!(!ex(&d, "README.md"));
}
#[test]
fn test_star_within_segment() {
let d = ign("*.log\n");
assert!(ex(&d, "app.log"));
assert!(ex(&d, "x.log"));
assert!(!ex(&d, "app.txt"));
assert!(!ex(&d, "logs/app.log"));
}
#[test]
fn test_doublestar_crosses_segments() {
let d = ign("**/__pycache__\n");
assert!(ex(&d, "__pycache__"));
assert!(ex(&d, "a/__pycache__"));
assert!(ex(&d, "a/b/__pycache__"));
assert!(!ex(&d, "a/cache"));
}
#[test]
fn test_negation_last_match_wins() {
let d = ign("*.log\n!keep.log\n");
assert!(ex(&d, "app.log"));
assert!(!ex(&d, "keep.log"));
}
#[test]
fn test_comments_blanks_and_slashes() {
let d = ign("# a comment\n\n/build/\n./tmp\n");
assert!(ex(&d, "build"));
assert!(ex(&d, "tmp"));
assert!(d.rules.len() == 2);
}
#[test]
fn test_question_mark() {
let d = ign("file?.txt\n");
assert!(ex(&d, "file1.txt"));
assert!(ex(&d, "fileA.txt"));
assert!(!ex(&d, "file10.txt"));
assert!(!ex(&d, "file.txt"));
}
}