#[cfg(feature = "cli")]
use crate::cli::global::GlobalFlags;
#[cfg(any(feature = "cli", feature = "files"))]
use globset::{Glob, GlobSet, GlobSetBuilder};
#[cfg(any(feature = "cli", feature = "files"))]
use ignore::WalkBuilder;
#[cfg(feature = "cli")]
use ignore::WalkState;
use std::path::Path;
#[cfg(any(feature = "cli", feature = "files"))]
use std::path::PathBuf;
#[cfg(feature = "cli")]
use std::sync::Mutex;
#[cfg(any(feature = "cli", feature = "files"))]
pub fn relative_display<'a>(path: &'a Path, base: &Path) -> &'a Path {
path.strip_prefix(base).unwrap_or(path)
}
#[cfg(feature = "cli")]
pub(crate) fn has_regex_metacharacters(s: &str) -> bool {
s.contains('\\')
|| s.contains('[')
|| s.contains('(')
|| s.contains('{')
|| s.contains('*')
|| s.contains('+')
|| s.contains('?')
|| s.contains('|')
|| s.contains('^')
|| s.contains('$')
}
pub fn is_binary(data: &[u8]) -> bool {
let check_len = data.len().min(8192);
memchr::memchr(0, &data[..check_len]).is_some()
}
#[cfg(test)]
pub(crate) fn is_binary_file(path: &Path) -> bool {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut buf = [0u8; 8192];
let n = match std::io::Read::read(&mut file, &mut buf) {
Ok(n) => n,
Err(_) => return false,
};
is_binary(&buf[..n])
}
#[cfg(feature = "cli")]
pub(crate) fn collect_file_paths_opts(
paths: &[String],
global: &GlobalFlags,
include_hidden: bool,
root: Option<&Path>,
) -> anyhow::Result<Vec<PathBuf>> {
if let Some(files) = global.read_files_from()? {
if let Some(r) = root {
global.check_paths_contained(r, &files)?;
}
return Ok(files
.iter()
.map(|f| match root {
Some(r) => r.join(f),
None => PathBuf::from(f),
})
.collect());
}
let defaults;
let effective: &[String] = if paths.is_empty() {
defaults = [".".to_string()];
&defaults
} else {
paths
};
let resolve = |p: &str| -> PathBuf {
match root {
Some(r) => r.join(p),
None => PathBuf::from(p),
}
};
if let Some(r) = root {
global.check_paths_contained(r, effective)?;
}
for p in effective {
let resolved = resolve(p);
if !resolved.exists() {
eprintln!(
"patchloom: {}: No such file or directory",
resolved.display()
);
}
}
let first = resolve(&effective[0]);
let mut builder = WalkBuilder::new(&first);
for p in &effective[1..] {
builder.add(resolve(p));
}
if include_hidden {
builder.hidden(false);
}
for name in &global.ignore_file {
builder.add_custom_ignore_filename(name);
}
let collected: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
struct FlushOnDrop<'a> {
batch: Vec<PathBuf>,
target: &'a Mutex<Vec<PathBuf>>,
}
impl Drop for FlushOnDrop<'_> {
fn drop(&mut self) {
if !self.batch.is_empty() {
self.target
.lock()
.expect("file list mutex")
.append(&mut self.batch);
}
}
}
builder.build_parallel().run(|| {
let mut state = FlushOnDrop {
batch: Vec::with_capacity(256),
target: &collected,
};
Box::new(move |result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
if entry.file_name() == ".patchloom" {
return WalkState::Skip;
}
if entry.file_type().is_some_and(|ft| ft.is_file()) {
state.batch.push(entry.into_path());
if state.batch.len() >= 256 {
state
.target
.lock()
.expect("file list mutex")
.append(&mut state.batch);
}
}
WalkState::Continue
})
});
let mut paths = collected.into_inner().expect("all walkers done");
apply_exclude_globs(&mut paths, &global.exclude, root)?;
Ok(paths)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn build_glob_matcher(globs: &[String]) -> anyhow::Result<Option<GlobSet>> {
if globs.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in globs {
builder.add(Glob::new(pattern)?);
}
Ok(Some(builder.build()?))
}
#[cfg(feature = "cli")]
pub(crate) fn build_glob_matcher_from_global(
global: &GlobalFlags,
) -> anyhow::Result<Option<GlobSet>> {
build_glob_matcher(&global.glob)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_glob_roots(paths: &[PathBuf], root: Option<&Path>) -> Vec<PathBuf> {
let mut roots = Vec::new();
for path in paths {
let resolved = match root {
Some(r) => r.join(path),
None => path.clone(),
};
let glob_root = if resolved.is_file() {
resolved
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| resolved.clone())
} else {
resolved.clone()
};
let glob_root = normalize_glob_root(glob_root);
if !roots.contains(&glob_root) {
roots.push(glob_root);
}
}
roots
}
#[cfg(feature = "cli")]
pub(crate) fn collect_glob_roots_from_global(
paths: &[String],
global: &GlobalFlags,
root: Option<&Path>,
) -> anyhow::Result<Vec<PathBuf>> {
if global.files_from.is_some() {
return Ok(root.map(|r| vec![r.to_path_buf()]).unwrap_or_default());
}
let defaults;
let effective: &[String] = if paths.is_empty() {
defaults = [".".to_string()];
&defaults
} else {
paths
};
let paths_buf: Vec<PathBuf> = effective
.iter()
.map(|p| match root {
Some(r) => r.join(p),
None => PathBuf::from(p),
})
.collect();
Ok(collect_glob_roots(&paths_buf, root))
}
#[cfg(any(feature = "cli", feature = "files"))]
fn normalize_glob_root(path: PathBuf) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
_ => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_path(path: &Path, matcher: &GlobSet) -> bool {
matcher.is_match(path) || path.file_name().is_some_and(|name| matcher.is_match(name))
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn matches_glob_with_roots(path: &Path, matcher: Option<&GlobSet>, roots: &[PathBuf]) -> bool {
match matcher {
None => true,
Some(m) => {
matches_glob(path, Some(m))
|| roots.iter().any(|root| {
path.strip_prefix(root).ok().is_some_and(|relative| {
!relative.as_os_str().is_empty() && matches_glob(relative, Some(m))
})
})
}
}
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn matches_glob(path: &Path, matcher: Option<&GlobSet>) -> bool {
match matcher {
None => true,
Some(m) => glob_matches_path(path, m),
}
}
pub fn read_text_file(path: &Path) -> Option<String> {
read_text_file_inner(path, None)
}
#[cfg(feature = "cli")]
pub(crate) fn read_text_file_logged(path: &Path, cmd: &str, quiet: bool) -> Option<String> {
if quiet {
read_text_file_inner(path, None)
} else {
read_text_file_inner(path, Some(cmd))
}
}
fn read_text_file_inner(path: &Path, log_label: Option<&str>) -> Option<String> {
use std::io::Read;
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => {
if let Some(label) = log_label {
eprintln!("{label}: skipping {}: {e}", path.display());
}
return None;
}
};
let file_len = match file.metadata() {
Ok(m) => m.len() as usize,
Err(_) => return None,
};
if file_len == 0 {
return Some(String::new());
}
const BINARY_CHECK_LEN: usize = 8192;
if file_len > BINARY_CHECK_LEN {
let mut header = [0u8; BINARY_CHECK_LEN];
let n = match file.read(&mut header) {
Ok(n) => n,
Err(e) => {
if let Some(label) = log_label {
eprintln!("{label}: skipping {}: {e}", path.display());
}
return None;
}
};
if is_binary(&header[..n]) {
return None;
}
let mut bytes = Vec::with_capacity(file_len);
bytes.extend_from_slice(&header[..n]);
if let Err(e) = file.read_to_end(&mut bytes) {
if let Some(label) = log_label {
eprintln!("{label}: skipping {}: {e}", path.display());
}
return None;
}
return match String::from_utf8(bytes) {
Ok(s) => Some(s),
Err(_) => {
if let Some(label) = log_label {
eprintln!("{label}: skipping {} (invalid UTF-8)", path.display());
}
None
}
};
}
let mut bytes = Vec::with_capacity(file_len);
if let Err(e) = file.read_to_end(&mut bytes) {
if let Some(label) = log_label {
eprintln!("{label}: skipping {}: {e}", path.display());
}
return None;
}
if is_binary(&bytes) {
return None;
}
match String::from_utf8(bytes) {
Ok(s) => Some(s),
Err(_) => {
if let Some(label) = log_label {
eprintln!("{label}: skipping {} (invalid UTF-8)", path.display());
}
None
}
}
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_file_paths(root: &Path, include_hidden: bool) -> anyhow::Result<Vec<PathBuf>> {
collect_file_paths_with_ignores(root, &[], &[], include_hidden)
}
#[cfg(any(feature = "cli", feature = "files"))]
fn apply_exclude_globs(
paths: &mut Vec<PathBuf>,
patterns: &[String],
root: Option<&Path>,
) -> anyhow::Result<()> {
if patterns.is_empty() {
return Ok(());
}
let mut exb = globset::GlobSetBuilder::new();
for pat in patterns {
exb.add(globset::Glob::new(pat)?);
}
let ex = exb.build()?;
paths.retain(|p| {
if glob_matches_path(p, &ex) {
return false;
}
if let Some(r) = root
&& let Ok(rel) = p.strip_prefix(r)
&& !rel.as_os_str().is_empty()
&& ex.is_match(rel)
{
return false;
}
true
});
Ok(())
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_file_paths_with_ignores(
root: &Path,
custom_ignore_filenames: &[String],
exclude_patterns: &[String],
include_hidden: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let mut builder = WalkBuilder::new(root);
if include_hidden {
builder.hidden(false);
}
for name in custom_ignore_filenames {
builder.add_custom_ignore_filename(name);
}
let mut paths: Vec<PathBuf> = Vec::new();
for entry in builder.build().filter_map(Result::ok) {
if entry.file_name() == ".patchloom" {
continue;
}
if entry.file_type().is_some_and(|ft| ft.is_file()) {
paths.push(entry.into_path());
}
}
apply_exclude_globs(&mut paths, exclude_patterns, Some(root))?;
Ok(paths)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn par_process_files<T, F>(
paths: &[PathBuf],
glob_matcher: Option<&GlobSet>,
glob_roots: &[PathBuf],
f: F,
) -> Vec<T>
where
T: Send,
F: Fn(&Path) -> Option<T> + Sync,
{
fn process_slice<T, F>(
paths: &[PathBuf],
glob_matcher: Option<&GlobSet>,
glob_roots: &[PathBuf],
f: &F,
) -> Vec<T>
where
T: Send,
F: Fn(&Path) -> Option<T> + Sync,
{
paths
.iter()
.filter(|p| matches_glob_with_roots(p, glob_matcher, glob_roots))
.filter_map(|p| f(p.as_path()))
.collect()
}
let num_splits = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(paths.len());
if num_splits <= 1 {
return process_slice(paths, glob_matcher, glob_roots, &f);
}
let chunk_size = paths.len().div_ceil(num_splits);
let chunks: Vec<&[PathBuf]> = paths.chunks(chunk_size).collect();
std::thread::scope(|s| {
let handles: Vec<_> = chunks[1..]
.iter()
.map(|chunk| s.spawn(|| process_slice(chunk, glob_matcher, glob_roots, &f)))
.collect();
let mut results = process_slice(chunks[0], glob_matcher, glob_roots, &f);
for handle in handles {
results.extend(handle.join().expect("worker thread panicked"));
}
results
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "cli")]
fn plain_text_has_no_regex_meta() {
assert!(!has_regex_metacharacters("hello world"));
assert!(!has_regex_metacharacters("foo-bar_baz"));
}
#[test]
#[cfg(feature = "cli")]
fn regex_patterns_detected() {
assert!(has_regex_metacharacters("fn\\s+main"));
assert!(has_regex_metacharacters("v1\\.0"));
assert!(has_regex_metacharacters("[a-z]+"));
assert!(has_regex_metacharacters("(group)"));
assert!(has_regex_metacharacters("a|b"));
assert!(has_regex_metacharacters("^start"));
assert!(has_regex_metacharacters("end$"));
}
#[test]
fn text_is_not_binary() {
assert!(!is_binary(b"hello world\n"));
}
#[test]
fn empty_is_not_binary() {
assert!(!is_binary(b""));
}
#[test]
fn nul_byte_makes_binary() {
assert!(is_binary(b"hello\x00world"));
}
#[test]
fn nul_at_8k_boundary_is_binary() {
let mut data = vec![b'a'; 8191];
data.push(0);
assert!(is_binary(&data));
}
#[test]
fn nul_past_8k_is_not_binary() {
let mut data = vec![b'a'; 8192];
data.push(0);
assert!(!is_binary(&data));
}
#[test]
fn is_binary_file_detects_nul_in_real_file() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("bin.dat");
std::fs::write(&p, b"hello\x00world").unwrap();
assert!(is_binary_file(&p));
}
#[test]
fn is_binary_file_returns_false_for_text_and_nonexistent() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("text.txt");
std::fs::write(&p, b"hello world\n").unwrap();
assert!(!is_binary_file(&p));
assert!(!is_binary_file(&dir.path().join("nope.bin"))); }
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn no_matcher_matches_everything() {
assert!(matches_glob(Path::new("any/file.rs"), None));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_extension() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
assert!(matches_glob(Path::new("src/main.rs"), Some(&matcher)));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_rejects_non_matching() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
assert!(!matches_glob(Path::new("src/main.py"), Some(&matcher)));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_nested_relative_pattern_with_root() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("sub/*.txt").unwrap());
let matcher = builder.build().unwrap();
let roots = vec![PathBuf::from("/tmp/project")];
assert!(matches_glob_with_roots(
Path::new("/tmp/project/sub/file.txt"),
Some(&matcher),
&roots,
));
assert!(!matches_glob_with_roots(
Path::new("/tmp/project/other.txt"),
Some(&matcher),
&roots,
));
}
#[test]
#[cfg(feature = "cli")]
fn collect_glob_roots_normalizes_current_directory_segments() {
let global = GlobalFlags::test_default();
let roots =
collect_glob_roots_from_global(&[], &global, Some(Path::new("/tmp/project"))).unwrap();
assert_eq!(roots, vec![PathBuf::from("/tmp/project")]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_single_file() {
let paths = vec![PathBuf::from("a.txt")];
let results = par_process_files(&paths, None, &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results, vec!["a.txt"]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_filters_with_glob() {
let paths = vec![
PathBuf::from("a.rs"),
PathBuf::from("b.py"),
PathBuf::from("c.rs"),
];
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
let results = par_process_files(&paths, Some(&matcher), &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results.len(), 2);
assert!(results.contains(&"a.rs".to_string()));
assert!(results.contains(&"c.rs".to_string()));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_filters_with_relative_glob_root() {
let paths = vec![
PathBuf::from("/tmp/project/sub/a.txt"),
PathBuf::from("/tmp/project/other.txt"),
];
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("sub/*.txt").unwrap());
let matcher = builder.build().unwrap();
let roots = vec![PathBuf::from("/tmp/project")];
let results = par_process_files(&paths, Some(&matcher), &roots, |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results, vec!["/tmp/project/sub/a.txt".to_string()]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_empty_paths() {
let paths: Vec<PathBuf> = vec![];
let results: Vec<String> = par_process_files(&paths, None, &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert!(results.is_empty());
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_closure_can_filter() {
let paths = vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")];
let results = par_process_files(&paths, None, &[], |p| {
if p.to_string_lossy().contains('a') {
Some(1)
} else {
None
}
});
assert_eq!(results, vec![1]);
}
#[test]
fn read_text_file_returns_content_for_utf8_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
std::fs::write(&file, "hello world\n").unwrap();
let result = read_text_file(&file);
assert_eq!(result.unwrap(), "hello world\n");
}
#[test]
fn read_text_file_returns_none_for_binary() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("binary.bin");
std::fs::write(&file, b"hello\x00world").unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_returns_empty_string_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.txt");
std::fs::write(&file, b"").unwrap();
let result = read_text_file(&file);
assert_eq!(result, Some(String::new()));
}
#[test]
fn read_text_file_returns_none_for_invalid_utf8() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bad.txt");
std::fs::write(&file, b"hello \xff world\n").unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_returns_none_for_missing_file() {
assert!(read_text_file(Path::new("/tmp/patchloom_nonexistent_xyz.txt")).is_none());
}
#[test]
fn read_text_file_large_file_two_phase_read() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("large.txt");
let content = "a".repeat(10_000) + "\n";
std::fs::write(&file, &content).unwrap();
let result = read_text_file(&file);
assert_eq!(result.unwrap(), content);
}
#[test]
fn read_text_file_large_binary_rejected_via_header_probe() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("large.bin");
let mut data = vec![b'a'; 10_000];
data[4096] = 0; std::fs::write(&file, &data).unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_large_file_invalid_utf8_past_header() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bad_tail.txt");
let mut data = vec![b'a'; 10_000];
data[9000] = 0xff;
std::fs::write(&file, &data).unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_binary_past_8k_still_read_as_text() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("mostly_text.txt");
let mut data = vec![b'a'; 8192];
data.push(0);
data.push(b'\n');
std::fs::write(&file, &data).unwrap();
let result = read_text_file(&file).expect("NUL past 8KiB should still read as text");
assert_eq!(result.len(), 8194);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_respects_ignore_file_and_exclude() {
use crate::cli::global::GlobalFlags;
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join("target")).unwrap();
fs::write(root.join("src/lib.rs"), "pub fn foo() {}\n").unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(root.join("target/debug"), "binary").unwrap(); fs::write(root.join("README.md"), "# hi\n").unwrap();
fs::write(root.join("Cargo.toml"), "[package]\n").unwrap(); fs::write(root.join(".blineignore"), "target/\n*.md\n").unwrap();
let mut global = GlobalFlags::test_default();
global.cwd = Some(root.to_string_lossy().into_owned());
global.ignore_file = vec![".blineignore".to_string()];
global.exclude = vec!["*.rs".to_string()];
let paths =
collect_file_paths_opts(&[".".to_string()], &global, false, Some(root)).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"Cargo.toml".to_string()),
"surviving file missing: {:?}",
rels
);
assert!(
!rels
.iter()
.any(|r| r.starts_with("target") || r.ends_with(".md") || r.ends_with(".rs")),
"advanced ignores not applied: {:?}",
rels
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn collect_file_paths_skips_patchloom_directory() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.txt"), "hello\n").unwrap();
fs::create_dir_all(root.join(".patchloom/backups/12345")).unwrap();
fs::write(root.join(".patchloom/backups/12345/manifest.json"), "{}\n").unwrap();
let paths = collect_file_paths(root, false).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"real.txt".to_string()),
"real.txt should be collected: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains(".patchloom")),
".patchloom files should be excluded: {rels:?}"
);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_skips_patchloom_directory() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.txt"), "hello\n").unwrap();
fs::create_dir_all(root.join(".patchloom/backups/12345")).unwrap();
fs::write(root.join(".patchloom/backups/12345/manifest.json"), "{}\n").unwrap();
let global = GlobalFlags::test_with_cwd(root);
let paths = collect_file_paths_opts(&[".".to_string()], &global, true, Some(root)).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"real.txt".to_string()),
"real.txt should be collected: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains(".patchloom")),
".patchloom files should be excluded even with include_hidden=true: {rels:?}"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn exclude_glob_matches_files_in_subdirs() {
let mut paths = vec![
PathBuf::from("src/main.rs"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
];
apply_exclude_globs(&mut paths, &["*.rs".into()], None).unwrap();
assert_eq!(
paths,
vec![PathBuf::from("README.md")],
"*.rs should exclude files in subdirs"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn exclude_glob_matches_directory_pattern_with_root() {
let root = PathBuf::from("/project");
let mut paths = vec![
PathBuf::from("/project/src/main.rs"),
PathBuf::from("/project/vendor/lib.rs"),
PathBuf::from("/project/vendor/sub/dep.rs"),
];
apply_exclude_globs(&mut paths, &["vendor/**".into()], Some(&root)).unwrap();
assert_eq!(
paths,
vec![PathBuf::from("/project/src/main.rs")],
"vendor/** with root should exclude vendor files"
);
}
}