use rstest::rstest;
use super::*;
fn root(content: &[u8]) -> Ignore {
let mut rules = Ignore::new(Case::Sensitive, Limits::default());
rules.add(Source::Directory(b""), content).unwrap();
rules
}
#[rstest]
#[case::last_rule(b"*.log\n!keep.log\n", b"keep.log", false, Some(false))]
#[case::last_exclusion(b"!keep.log\n*.log\n", b"keep.log", false, Some(true))]
#[case::parent_wins(b"build/\n!build/keep\n", b"build/keep", false, Some(true))]
#[case::parent_reincluded(b"build/\n!build/\n!build/keep\n", b"build/keep", false, Some(false))]
#[case::basename(b"cache\n", b"a/cache/x", false, Some(true))]
#[case::anchored(b"/cache\n", b"a/cache", true, None)]
#[case::directory_file(b"cache/\n", b"cache", false, None)]
#[case::recursive_zero(b"a/**/z\n", b"a/z", false, Some(true))]
#[case::recursive_many(b"a/**/z\n", b"a/x/y/z", false, Some(true))]
#[case::single_star(b"a/*/z\n", b"a/x/y/z", false, None)]
#[case::escape_space(b"a\\ \n", b"a ", false, Some(true))]
#[case::comment(b"#a\n", b"#a", false, None)]
#[case::escaped_comment(b"\\#a\n", b"#a", false, Some(true))]
#[case::escaped_negation(b"\\!a\n", b"!a", false, Some(true))]
#[case::bytes(b"\xff?\n", b"\xff\xfe", false, Some(true))]
#[case::bom_crlf(b"\xef\xbb\xbffoo\r\n", b"foo", false, Some(true))]
#[case::nul(b"foo\0bar\n", b"foo", false, Some(true))]
#[case::escape_end(b"foo\\\n", b"foo", false, None)]
#[case::invalid_class(b"[![:invalid:]]\n", b"z", false, None)]
#[case::backslash_is_one_byte(b"a?\n", b"a\\", false, Some(true))]
#[case::literal_backslash(b"a\\\\\n", b"a\\", false, Some(true))]
#[case::range(b"[a-c][[:digit:]]\n", b"b7", false, Some(true))]
fn semantics(
#[case] content: &[u8],
#[case] path: &[u8],
#[case] directory: bool,
#[case] expected: Option<bool>,
) {
assert_eq!(
root(content)
.check(
path,
if directory {
InputKind::Directory
} else {
InputKind::File
}
)
.unwrap()
.map(|m| m.ignored),
expected
);
}
#[test]
fn hierarchy_uses_depth_and_source_precedence_with_provenance() {
let mut rules = Ignore::new(Case::Sensitive, Limits::default());
rules
.add(Source::Directory(b"a"), b"# comment\n!x\n")
.unwrap();
rules.add(Source::Global, b"x\n").unwrap();
rules.add(Source::Directory(b""), b"x\n").unwrap();
rules.add(Source::Info, b"x\n").unwrap();
assert_eq!(
rules.check(b"a/x", InputKind::File).unwrap(),
Some(Match {
ignored: false,
source: 0,
line: 2,
matched_bytes: 3
})
);
rules.add(Source::Command, b"x\n").unwrap();
assert_eq!(
rules
.check(b"a/x", InputKind::File)
.unwrap()
.unwrap()
.source,
4
);
}
#[test]
fn blocked_parent_reports_ancestor_and_ignores_nested_source() {
let mut rules = root(b"a/\n");
rules.add(Source::Directory(b"a"), b"!*\n").unwrap();
assert_eq!(
rules.check(b"a/x", InputKind::File).unwrap(),
Some(Match {
ignored: true,
source: 0,
line: 1,
matched_bytes: 1
})
);
}
#[test]
fn direct_query_leaves_ancestor_policy_to_the_caller() {
let mut rules = root(b"a/\n");
rules.add(Source::Directory(b"a"), b"!x\n").unwrap();
assert!(
rules
.check(b"a/x", InputKind::File)
.unwrap()
.unwrap()
.ignored
);
assert!(
!rules
.check_direct(b"a/x", InputKind::File)
.unwrap()
.unwrap()
.ignored
);
assert_eq!(rules.check_direct(b"a/y", InputKind::File).unwrap(), None);
assert_eq!(rules.check_direct(b"", InputKind::File), Err(Error::Path));
}
#[test]
fn equal_level_sources_append() {
let mut rules = root(b"x\n");
rules.add(Source::Directory(b""), b"!x\n").unwrap();
assert!(!rules.check(b"x", InputKind::File).unwrap().unwrap().ignored);
}
#[rstest]
#[case::empty(b"")]
#[case::absolute(b"/x")]
#[case::empty_component(b"a//b")]
#[case::dot(b"a/./b")]
#[case::parent(b"a/../b")]
#[case::trailing(b"a/")]
#[case::nul(b"a\0b")]
fn invalid_query(#[case] path: &[u8]) {
assert_eq!(root(b"*").check(path, InputKind::File), Err(Error::Path));
}
#[rstest]
#[case::source_bytes(Limits { bytes: 3, ..Limits::default() }, b"long", "source bytes")]
#[case::source_count(Limits { sources: 1, ..Limits::default() }, b"", "source count")]
#[case::pattern_count(Limits { patterns: 1, ..Limits::default() }, b"y", "pattern count")]
#[case::line_bytes(Limits { line_bytes: 1, ..Limits::default() }, b"yy", "line bytes")]
fn failed_add_is_atomic(
#[case] limits: Limits,
#[case] content: &[u8],
#[case] limit: &'static str,
) {
let mut rules = Ignore::new(Case::Sensitive, limits);
rules.add(Source::Global, b"x").unwrap();
assert_eq!(rules.add(Source::Info, content), Err(Error::Limit(limit)));
assert_eq!(
rules.check(b"x", InputKind::File).unwrap().unwrap().source,
0
);
assert_eq!(rules.check(b"y", InputKind::File).unwrap(), None);
}
#[test]
fn aggregate_bytes_include_directory_prefix() {
let mut rules = Ignore::new(
Case::Sensitive,
Limits {
bytes: 3,
..Limits::default()
},
);
assert_eq!(rules.add(Source::Directory(b"ab"), b"x"), Ok(0));
assert_eq!(
rules.add(Source::Info, b"y"),
Err(Error::Limit("source bytes"))
);
}
#[test]
fn failed_directory_does_not_consume_source_index() {
let mut rules = root(b"x");
assert_eq!(
rules.add(Source::Directory(b"../a"), b"x"),
Err(Error::Path)
);
assert_eq!(rules.add(Source::Info, b"x"), Ok(1));
}
#[rstest]
#[case::zero(0)]
#[case::adversarial(1000)]
fn query_budget_is_not_a_negative_decision(#[case] work: usize) {
let mut rules = Ignore::new(
Case::Sensitive,
Limits {
work,
..Limits::default()
},
);
rules
.add(Source::Global, b"*a*a*a*a*a*a*a*a*a*a*a*a*a*a*b")
.unwrap();
assert_eq!(
rules.check(&[b'a'; 100], InputKind::File),
Err(Error::Limit("matching work"))
);
assert_eq!(
rules.check(&[b'a'; 100], InputKind::File),
Err(Error::Limit("matching work"))
);
}
#[test]
fn query_work_is_shared_across_ancestors() {
let mut rules = Ignore::new(
Case::Sensitive,
Limits {
work: 5,
..Limits::default()
},
);
rules.add(Source::Global, b"z").unwrap();
assert_eq!(rules.check(b"a", InputKind::Directory), Ok(None));
assert_eq!(
rules.check(b"a/b", InputKind::File),
Err(Error::Limit("matching work"))
);
}
#[test]
fn path_budget_applies_to_both_boundaries() {
let mut rules = Ignore::new(
Case::Sensitive,
Limits {
path_bytes: 1,
..Limits::default()
},
);
assert_eq!(
rules.add(Source::Directory(b"ab"), b""),
Err(Error::Limit("path bytes"))
);
assert_eq!(
rules.check(b"ab", InputKind::File),
Err(Error::Limit("path bytes"))
);
}
#[test]
fn case_policy_includes_source_prefix_without_unicode_folding() {
let mut rules = Ignore::new(Case::AsciiInsensitive, Limits::default());
rules
.add(Source::Directory(b"DIR"), b"[A-Z]\n\xc3\x84")
.unwrap();
assert!(
rules
.check(b"dir/a", InputKind::File)
.unwrap()
.unwrap()
.ignored
);
assert_eq!(rules.check(b"dir/\xc3\xa4", InputKind::File).unwrap(), None);
assert_eq!(rules.check(b"directory/a", InputKind::File).unwrap(), None);
}
#[rstest]
#[case::bang(b"!".as_slice(), &[][..])]
#[case::slash(b"/", &[])]
#[case::bang_slash(b"!/", &[])]
#[case::escaped_space(b"\\ ", &[b"a/ ".as_slice()])]
#[case::trailing_escape(b"a\\", &[])]
#[case::open_class(b"[", &[])]
#[case::open_named_class(b"[[:", &[])]
#[case::unclosed_name_is_literal(b"[[:alpha]", &[b"a/[".as_slice()])]
#[case::empty_named_class(b"[[::]]", &[])]
#[case::stars(b"**", &[b"a/b".as_slice(), b"a/[", b"a/ ", b"a/b/c"])]
#[case::escaped_slash_after_stars(b"a/**\\/b", &[])]
fn degenerate_patterns_match_like_git(#[case] pattern: &[u8], #[case] ignored: &[&[u8]]) {
let mut rules = Ignore::new(Case::Sensitive, Limits::default());
rules.add(Source::Directory(b"a"), pattern).unwrap();
let actual: Vec<&[u8]> = [&b"a/b"[..], b"a/[", b"a/ ", b"a/b/c"]
.into_iter()
.filter(|path| {
rules
.check(path, InputKind::File)
.unwrap()
.is_some_and(|m| m.ignored)
})
.collect();
assert_eq!(actual, ignored);
}