use crate::error::ZlobError;
use crate::{ZlobFlags, ffi};
use bitflags::bitflags;
use std::ffi::{CStr, CString};
use std::marker::PhantomData;
use std::os::raw::{c_int, c_void};
use std::path::Path;
type Result<T> = std::result::Result<T, ZlobError>;
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WalkFlags: u32 {
const GITIGNORE = 1 << 0;
const SKIP_HIDDEN = 1 << 1;
const FOLLOW_SYMLINKS = 1 << 2;
const NO_REPORT_DIRS = 1 << 3;
const SORT = 1 << 4;
const ABORT_ON_ERROR = 1 << 5;
const KEEP_GIT_DIR = 1 << 6;
const RECOMMENDED = Self::GITIGNORE.bits() | Self::SKIP_HIDDEN.bits();
}
}
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct WalkMetadata: u32 {
const SIZE = 1 << 0;
const MTIME = 1 << 1;
const ATIME = 1 << 2;
const CTIME = 1 << 3;
const BTIME = 1 << 4;
const INODE = 1 << 5;
const NLINK = 1 << 6;
const MODE = 1 << 7;
const UID = 1 << 8;
const GID = 1 << 9;
const ALL = 0x3FF;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalkState {
Continue,
SkipDir,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalkEntryKind {
File,
Dir,
Symlink,
Unknown,
}
#[derive(Debug, Clone)]
pub struct WalkBuilder {
extra_ignore: Option<CString>,
flags: WalkFlags,
max_depth: u16,
meta: WalkMetadata,
pattern: Option<CString>,
pattern_flags: i32,
root: CString,
threads: u16,
}
impl WalkBuilder {
pub fn new(root: impl AsRef<Path>) -> Result<Self> {
let bytes = path_to_bytes(root.as_ref());
Ok(Self {
root: CString::new(bytes).map_err(|_| ZlobError::InvalidInput)?,
flags: WalkFlags::RECOMMENDED,
meta: WalkMetadata::empty(),
threads: 0,
max_depth: 0,
pattern_flags: 0,
pattern: None,
extra_ignore: None,
})
}
pub fn options(&mut self, flags: WalkFlags) -> &mut Self {
self.flags = flags;
self
}
pub fn metadata(&mut self, meta: WalkMetadata) -> &mut Self {
self.meta = meta;
self
}
pub fn include(&mut self, pattern: impl AsRef<str>) -> Result<&mut Self> {
self.pattern = Some(CString::new(pattern.as_ref()).map_err(|_| ZlobError::InvalidInput)?);
self.pattern_flags = ZlobFlags::RECOMMENDED.bits();
Ok(self)
}
pub fn include_flags(&mut self, flags: ZlobFlags) -> &mut Self {
self.pattern_flags = flags.bits();
self
}
pub fn extra_ignore<S: AsRef<str>>(&mut self, patterns: &[S]) -> Result<&mut Self> {
let mut joined = String::new();
for p in patterns {
joined.push_str(p.as_ref());
joined.push('\n');
}
self.extra_ignore = Some(CString::new(joined).map_err(|_| ZlobError::InvalidInput)?);
Ok(self)
}
pub fn threads(&mut self, n: usize) -> &mut Self {
self.threads = n.min(u16::MAX as usize) as u16;
self
}
pub fn max_depth(&mut self, depth: Option<usize>) -> &mut Self {
self.max_depth = depth.unwrap_or(0).min(u16::MAX as usize) as u16;
self
}
fn checked_options(&self) -> Result<(&CStr, ffi::zlob_walk_options_t)> {
let root = self.root.as_ref();
let pattern = match self.pattern.as_ref() {
Some(p) => p.as_ptr(),
None => std::ptr::null(),
};
let extra_ignore = match self.extra_ignore.as_ref() {
Some(p) => p.as_ptr(),
None => std::ptr::null(),
};
Ok((
root,
ffi::zlob_walk_options_t {
flags: self.flags.bits(),
meta_mask: self.meta.bits(),
threads: self.threads,
max_depth: self.max_depth,
errfunc: None,
pattern,
pattern_flags: self.pattern_flags as u32,
extra_ignore,
},
))
}
pub fn collect(&self) -> Result<WalkResults> {
let (root, opts) = self.checked_options()?;
let mut out = ffi::zlob_walk_result_t {
entries: std::ptr::null_mut(),
count: 0,
_storage: std::ptr::null_mut(),
};
let rc = unsafe { ffi::zlob_walk_collect(root.as_ptr(), &opts, &mut out) };
match rc {
0 => Ok(WalkResults { raw: out }),
_ => Err(rc_to_error(rc)),
}
}
pub fn run<F>(&self, visitor: F) -> Result<WalkerOutcomeRules>
where
F: Fn(WalkEntry<'_>) -> WalkState + Sync,
{
self.run_inner(self.threads, &move |e| visitor(e))
}
pub fn run_serial<F>(&self, mut visitor: F) -> Result<WalkerOutcomeRules>
where
F: FnMut(WalkEntry<'_>) -> WalkState,
{
debug_assert!(self.threads == 1);
struct SerialBridge<F> {
ptr: *mut F,
}
unsafe impl<F> Sync for SerialBridge<F> {}
let bridge = SerialBridge {
ptr: &mut visitor as *mut F,
};
let bridge_ref = &bridge;
self.run_inner(1, &move |e| {
let f = unsafe { &mut *bridge_ref.ptr };
f(e)
})
}
fn run_inner<F>(&self, threads: u16, visitor: &F) -> Result<WalkerOutcomeRules>
where
F: Fn(WalkEntry<'_>) -> WalkState + Sync,
{
unsafe extern "C" fn zlob_callback<F>(
entry: *const ffi::zlob_walk_entry_t,
ctx: *mut c_void,
) -> c_int
where
F: Fn(WalkEntry<'_>) -> WalkState + Sync,
{
let visitor = unsafe { &*(ctx as *const F) };
let entry = WalkEntry {
raw: unsafe { &*entry },
_marker: PhantomData,
};
match visitor(entry) {
WalkState::Continue => 0,
WalkState::SkipDir => 1,
WalkState::Quit => 2,
}
}
let (root, mut opts) = self.checked_options()?;
opts.threads = threads;
let mut raw_rules: *mut c_void = std::ptr::null_mut();
let rc = unsafe {
ffi::zlob_walk(
root.as_ptr(),
&opts,
Some(zlob_callback::<F>),
visitor as *const F as *mut c_void,
&mut raw_rules,
)
};
match rc {
0 => Ok(WalkerOutcomeRules { raw: raw_rules }),
_ => Err(rc_to_error(rc)),
}
}
}
fn rc_to_error(rc: c_int) -> ZlobError {
match ZlobError::from_code(rc) {
Err(e) => e,
Ok(_) => ZlobError::Aborted,
}
}
#[cfg(unix)]
fn path_to_bytes(p: &Path) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
p.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
fn path_to_bytes(p: &Path) -> Vec<u8> {
p.to_string_lossy().into_owned().into_bytes()
}
#[derive(Clone, Copy)]
pub struct IgnoreRules<'a> {
handle: *mut c_void,
_marker: PhantomData<&'a ()>,
}
unsafe impl Send for IgnoreRules<'_> {}
unsafe impl Sync for IgnoreRules<'_> {}
impl IgnoreRules<'_> {
pub fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
let Ok(cpath) = CString::new(path_to_bytes(path.as_ref())) else {
return false; };
unsafe { ffi::zlob_ignore_rules_match_path(self.handle, cpath.as_ptr()) != 0 }
}
}
pub struct WalkResults {
raw: ffi::zlob_walk_result_t,
}
unsafe impl Send for WalkResults {}
unsafe impl Sync for WalkResults {}
impl WalkResults {
pub fn len(&self) -> usize {
self.raw.count
}
pub fn is_empty(&self) -> bool {
self.raw.count == 0
}
fn raw_entries(&self) -> &[ffi::zlob_walk_entry_t] {
if self.raw.count == 0 || self.raw.entries.is_null() {
return &[];
}
unsafe { std::slice::from_raw_parts(self.raw.entries, self.raw.count) }
}
pub fn get(&self, index: usize) -> Option<WalkEntry<'_>> {
self.raw_entries().get(index).map(|raw| WalkEntry {
raw,
_marker: PhantomData,
})
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = WalkEntry<'_>> {
self.raw_entries().iter().map(|raw| WalkEntry {
raw,
_marker: PhantomData,
})
}
pub fn ignore_rules(&self) -> Option<IgnoreRules<'_>> {
let handle = unsafe { ffi::zlob_walk_result_ignore_rules(&self.raw) };
if handle.is_null() {
None
} else {
Some(IgnoreRules {
handle,
_marker: PhantomData,
})
}
}
}
impl Drop for WalkResults {
fn drop(&mut self) {
unsafe { ffi::zlob_walk_result_free(&mut self.raw) };
}
}
impl std::fmt::Debug for WalkResults {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalkResults")
.field("count", &self.raw.count)
.finish()
}
}
pub struct WalkerOutcomeRules {
raw: *mut c_void,
}
unsafe impl Send for WalkerOutcomeRules {}
unsafe impl Sync for WalkerOutcomeRules {}
impl WalkerOutcomeRules {
pub fn rules(&self) -> Option<IgnoreRules<'_>> {
if self.raw.is_null() {
None
} else {
Some(IgnoreRules {
handle: self.raw,
_marker: PhantomData,
})
}
}
}
impl Drop for WalkerOutcomeRules {
fn drop(&mut self) {
if !self.raw.is_null() {
unsafe { ffi::zlob_ignore_rules_free(self.raw) };
self.raw = std::ptr::null_mut();
}
}
}
impl std::fmt::Debug for WalkerOutcomeRules {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalkRulesHandle").finish_non_exhaustive()
}
}
#[derive(Clone, Copy)]
pub struct WalkEntry<'a> {
raw: &'a ffi::zlob_walk_entry_t,
_marker: PhantomData<&'a ()>,
}
impl<'a> WalkEntry<'a> {
#[inline]
pub fn path_bytes(&self) -> &'a [u8] {
unsafe { std::slice::from_raw_parts(self.raw.path as *const u8, self.raw.path_len) }
}
#[inline]
pub fn path(&self) -> &'a Path {
bytes_to_path(self.path_bytes())
}
#[inline]
pub fn relative_path(&self) -> &'a Path {
bytes_to_path(&self.path_bytes()[self.raw.relative_offset as usize..])
}
#[inline]
pub fn relative_path_bytes(&self) -> &'a [u8] {
&self.path_bytes()[self.raw.relative_offset as usize..]
}
#[inline]
pub fn basename(&self) -> Option<&'a str> {
if !self.is_file() {
return None;
}
std::str::from_utf8(&self.path_bytes()[self.raw.basename_offset as usize..]).ok()
}
#[inline]
pub fn basename_offset_in_relative(&self) -> u16 {
(self
.raw
.basename_offset
.saturating_sub(self.raw.relative_offset)) as u16
}
#[inline]
pub fn depth(&self) -> usize {
self.raw.depth as usize
}
#[inline]
pub fn kind(&self) -> WalkEntryKind {
match self.raw.kind {
1 => WalkEntryKind::File,
2 => WalkEntryKind::Dir,
3 => WalkEntryKind::Symlink,
_ => WalkEntryKind::Unknown,
}
}
#[inline]
pub fn is_file(&self) -> bool {
self.raw.kind == 1
}
#[inline]
pub fn is_dir(&self) -> bool {
self.raw.kind == 2
}
#[inline]
pub fn is_symlink(&self) -> bool {
self.raw.kind == 3
}
fn meta(&self, bit: WalkMetadata) -> bool {
self.raw.meta_valid & bit.bits() != 0
}
pub fn size(&self) -> Option<u64> {
self.meta(WalkMetadata::SIZE).then_some(self.raw.size)
}
pub fn modified_ns(&self) -> Option<i64> {
self.meta(WalkMetadata::MTIME).then_some(self.raw.mtime_ns)
}
pub fn accessed_ns(&self) -> Option<i64> {
self.meta(WalkMetadata::ATIME).then_some(self.raw.atime_ns)
}
pub fn changed_ns(&self) -> Option<i64> {
self.meta(WalkMetadata::CTIME).then_some(self.raw.ctime_ns)
}
pub fn created_ns(&self) -> Option<i64> {
self.meta(WalkMetadata::BTIME).then_some(self.raw.btime_ns)
}
pub fn inode(&self) -> Option<u64> {
self.meta(WalkMetadata::INODE).then_some(self.raw.inode)
}
pub fn nlink(&self) -> Option<u32> {
self.meta(WalkMetadata::NLINK).then_some(self.raw.nlink)
}
pub fn mode(&self) -> Option<u32> {
self.meta(WalkMetadata::MODE).then_some(self.raw.mode)
}
pub fn uid(&self) -> Option<u32> {
self.meta(WalkMetadata::UID).then_some(self.raw.uid)
}
pub fn gid(&self) -> Option<u32> {
self.meta(WalkMetadata::GID).then_some(self.raw.gid)
}
}
impl std::fmt::Debug for WalkEntry<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalkEntry")
.field("path", &self.path())
.field("kind", &self.kind())
.field("depth", &self.depth())
.finish()
}
}
#[cfg(unix)]
fn bytes_to_path(b: &[u8]) -> &Path {
use std::os::unix::ffi::OsStrExt;
Path::new(std::ffi::OsStr::from_bytes(b))
}
#[cfg(not(unix))]
fn bytes_to_path(b: &[u8]) -> &Path {
Path::new(std::str::from_utf8(b).unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
fn make_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir(root.join("target")).unwrap();
fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap();
fs::write(root.join("Cargo.toml"), "x").unwrap();
fs::write(root.join("debug.log"), "").unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
fs::write(root.join("target/out.bin"), "bin").unwrap();
dir
}
#[test]
fn build_respects_gitignore() {
let dir = make_tree();
let results = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::RECOMMENDED | WalkFlags::SORT)
.threads(1)
.collect()
.unwrap();
let names: Vec<String> = results
.iter()
.map(|e| e.relative_path().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["Cargo.toml", "src", "src/main.rs"]);
}
#[test]
fn build_plain_walkdir_mode() {
let dir = make_tree();
let results = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::SORT)
.threads(1)
.collect()
.unwrap();
let names: Vec<String> = results
.iter()
.map(|e| e.relative_path().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec![
".gitignore",
"Cargo.toml",
"debug.log",
"src",
"src/main.rs",
"target",
"target/out.bin",
]
);
let main_rs = results
.iter()
.find(|e| e.basename() == Some("main.rs"))
.unwrap();
assert!(main_rs.is_file());
assert_eq!(main_rs.depth(), 2);
assert!(main_rs.path().ends_with("src/main.rs"));
assert_eq!(main_rs.size(), None);
}
#[test]
fn metadata_size_and_mtime() {
let dir = make_tree();
let results = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.threads(1)
.metadata(WalkMetadata::SIZE | WalkMetadata::MTIME | WalkMetadata::INODE)
.collect()
.unwrap();
let main_rs = results
.iter()
.find(|e| e.basename() == Some("main.rs"))
.unwrap();
assert_eq!(main_rs.size(), Some(12)); assert!(main_rs.modified_ns().unwrap() > 0);
assert!(main_rs.inode().unwrap() != 0);
}
#[test]
fn parallel_run_visits_everything() {
let dir = make_tree();
let count = AtomicUsize::new(0);
WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.run(|_entry| {
count.fetch_add(1, Ordering::Relaxed);
WalkState::Continue
})
.unwrap();
assert_eq!(count.load(Ordering::Relaxed), 7);
}
#[test]
fn run_returns_queryable_rules_handle() {
let dir = make_tree();
let handle = WalkBuilder::new(dir.path())
.unwrap()
.run(|_| WalkState::Continue)
.unwrap();
let rules = handle.rules().expect("rules always present after a walk");
assert!(rules.is_ignored("target"));
assert!(rules.is_ignored("debug.log"));
assert!(!rules.is_ignored("Cargo.toml"));
drop(handle);
}
#[test]
fn run_serial_accepts_non_sync_fnmut() {
use std::rc::Rc;
let dir = make_tree();
let mut names: Vec<String> = Vec::new();
let not_sync: Rc<u8> = Rc::new(0); WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.threads(8)
.run_serial(|entry| {
let _ = ¬_sync;
let rel = entry.relative_path_bytes();
let leaf = &rel[entry.basename_offset_in_relative() as usize..];
names.push(String::from_utf8_lossy(leaf).into_owned());
WalkState::Continue
})
.unwrap();
assert_eq!(names.len(), 7);
}
#[test]
fn run_serial_skip_dir_and_quit() {
let dir = make_tree();
let mut count = 0usize;
WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.run_serial(|entry| {
count += 1;
if entry.is_dir() {
WalkState::SkipDir
} else {
WalkState::Continue
}
})
.unwrap();
assert_eq!(count, 5);
}
#[test]
fn run_skip_dir_and_quit() {
let dir = make_tree();
let count = AtomicUsize::new(0);
WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.threads(1)
.run(|entry| {
count.fetch_add(1, Ordering::Relaxed);
if entry.is_dir() {
WalkState::SkipDir
} else {
WalkState::Continue
}
})
.unwrap();
assert_eq!(count.load(Ordering::Relaxed), 5);
let quit_count = AtomicUsize::new(0);
WalkBuilder::new(dir.path())
.unwrap()
.threads(1)
.run(|_| {
quit_count.fetch_add(1, Ordering::Relaxed);
WalkState::Quit
})
.unwrap();
assert_eq!(quit_count.load(Ordering::Relaxed), 1);
}
#[test]
fn max_depth_limits() {
let dir = make_tree();
let results = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.threads(1)
.max_depth(Some(1))
.collect()
.unwrap();
assert_eq!(results.len(), 5);
assert!(results.iter().all(|e| e.depth() == 1));
}
#[test]
fn extra_ignore_un_ignores_via_negation() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir(root.join("src/important")).unwrap();
fs::write(root.join(".gitignore"), "*.rs\n").unwrap();
fs::write(root.join("keep.rs"), "fn k() {}").unwrap();
fs::write(root.join("drop.rs"), "fn d() {}").unwrap();
fs::write(root.join("src/main.rs"), "fn m() {}").unwrap();
fs::write(root.join("src/lib.rs"), "fn l() {}").unwrap();
fs::write(root.join("src/important/special.rs"), "fn s() {}").unwrap();
fs::write(root.join("src/important/other.rs"), "fn o() {}").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.options(WalkFlags::RECOMMENDED | WalkFlags::SORT)
.threads(1)
.extra_ignore(&["!keep.rs", "!src/important/**"])
.unwrap()
.collect()
.unwrap();
let names: Vec<String> = results
.iter()
.map(|e| e.relative_path().to_string_lossy().into_owned())
.collect();
assert!(
names.contains(&"keep.rs".to_string()),
"keep.rs should be re-included, got {names:?}"
);
assert!(
names.iter().any(|n| n == "src/important/special.rs"),
"src/important/special.rs should be re-included, got {names:?}"
);
assert!(
names.iter().any(|n| n == "src/important/other.rs"),
"src/important/other.rs should be re-included, got {names:?}"
);
assert!(
!names.contains(&"drop.rs".to_string()),
"drop.rs should remain ignored, got {names:?}"
);
assert!(
!names.iter().any(|n| n == "src/main.rs"),
"src/main.rs should remain ignored, got {names:?}"
);
assert!(
!names.iter().any(|n| n == "src/lib.rs"),
"src/lib.rs should remain ignored, got {names:?}"
);
let rules = results.ignore_rules().expect("rules always present");
assert!(!rules.is_ignored("keep.rs"));
assert!(rules.is_ignored("drop.rs"));
assert!(!rules.is_ignored("src/important/special.rs"));
assert!(rules.is_ignored("src/main.rs"));
}
#[test]
fn extra_ignore_adds_new_rules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("node_modules")).unwrap();
fs::create_dir(root.join("target")).unwrap();
fs::write(root.join("node_modules/a.js"), "").unwrap();
fs::write(root.join("target/out.o"), "").unwrap();
fs::write(root.join("README.md"), "").unwrap();
fs::write(root.join("main.rs"), "").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.options(WalkFlags::empty()) .threads(1)
.extra_ignore(&["node_modules", "/target"])
.unwrap()
.collect()
.unwrap();
let names: Vec<String> = results
.iter()
.map(|e| e.relative_path().to_string_lossy().into_owned())
.collect();
assert!(
!names.iter().any(|n| n.starts_with("node_modules")),
"node_modules subtree should be pruned, got {names:?}"
);
assert!(
!names.iter().any(|n| n.starts_with("target")),
"target/ subtree should be pruned at root, got {names:?}"
);
assert!(names.contains(&"README.md".to_string()));
assert!(names.contains(&"main.rs".to_string()));
let rules = results.ignore_rules().expect("rules always present");
assert!(rules.is_ignored("node_modules/"));
assert!(rules.is_ignored("target/"));
assert!(!rules.is_ignored("main.rs"));
}
#[test]
fn interior_nul_is_an_error_not_a_silent_default() {
let err = WalkBuilder::new("bad\0root").unwrap_err();
assert_eq!(err, ZlobError::InvalidInput);
let dir = make_tree();
let mut b = WalkBuilder::new(dir.path()).unwrap();
let err = b.include("*.\0rs").unwrap_err();
assert_eq!(err, ZlobError::InvalidInput);
let ok = WalkBuilder::new(dir.path())
.unwrap()
.include("**/*.rs")
.unwrap()
.collect();
assert!(ok.is_ok());
}
#[test]
fn empty_and_missing_roots() {
let dir = tempfile::tempdir().unwrap();
let results = WalkBuilder::new(dir.path()).unwrap().collect().unwrap();
assert!(results.is_empty());
let missing = WalkBuilder::new("/definitely/not/a/real/path/zlob")
.unwrap()
.collect()
.unwrap();
assert!(missing.is_empty());
let err = WalkBuilder::new("/definitely/not/a/real/path/zlob")
.unwrap()
.options(WalkFlags::RECOMMENDED | WalkFlags::ABORT_ON_ERROR)
.collect();
assert!(err.is_err());
}
#[test]
fn is_ignored_accepts_windows_style_backslash_paths() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap();
fs::create_dir_all(root.join("target/foo")).unwrap();
fs::write(root.join("target/foo/bar.rs"), "").unwrap();
fs::create_dir_all(root.join("src/deeply/nested")).unwrap();
fs::write(root.join("src/deeply/nested/scratch.log"), "").unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.threads(1)
.collect()
.unwrap();
let rules = results.ignore_rules().expect("rules always present");
assert!(rules.is_ignored("target\\foo\\bar.rs"));
assert!(rules.is_ignored("src\\deeply\\nested\\scratch.log"));
assert!(!rules.is_ignored("src\\main.rs"));
assert!(rules.is_ignored("target/foo\\bar.rs"));
}
#[test]
fn is_ignored_walks_ancestors_for_unvisited_subtrees() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir_all(root.join("target/foo")).unwrap();
fs::create_dir_all(root.join("target/deeply/nested")).unwrap();
fs::create_dir_all(root.join("src/deeply/nested")).unwrap();
fs::write(root.join(".gitignore"), "target/\n*.log\n").unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("target/foo/bar.rs"), "").unwrap();
fs::write(root.join("target/deeply/nested/thing.o"), "").unwrap();
fs::write(root.join("src/deeply/nested/scratch.log"), "").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.threads(1)
.collect()
.unwrap();
let rules = results.ignore_rules().expect("rules always present");
assert!(
rules.is_ignored("target/foo/bar.rs"),
"target/ at root should catch descendants even when target/ is pruned"
);
assert!(rules.is_ignored("target/deeply/nested/thing.o"));
assert!(rules.is_ignored("src/deeply/nested/scratch.log"));
assert!(!rules.is_ignored("src/main.rs"));
assert!(!rules.is_ignored("src/lib.rs"));
}
#[test]
fn retained_ignore_rules_are_reusable() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::write(root.join(".gitignore"), "*.log\nbuild/\n*.tmp\n").unwrap();
fs::write(root.join(".ignore"), "!important.tmp\n").unwrap();
fs::write(root.join("src/.gitignore"), "*.bak\n").unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("app.log"), "").unwrap();
fs::create_dir(root.join("build")).unwrap();
fs::write(root.join("scratch.tmp"), "").unwrap();
fs::write(root.join("important.tmp"), "").unwrap();
fs::write(root.join("src/old.bak"), "").unwrap();
fs::write(root.join("old.bak"), "").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.threads(1)
.collect()
.unwrap();
let rules = results.ignore_rules().expect("rules always present");
assert!(rules.is_ignored("app.log"));
assert!(rules.is_ignored("build"));
assert!(rules.is_ignored("scratch.tmp"));
assert!(!rules.is_ignored("important.tmp"));
assert!(rules.is_ignored("src/old.bak"));
assert!(!rules.is_ignored("old.bak"));
assert!(!rules.is_ignored("src/main.rs"));
assert!(rules.is_ignored("this-does-not-exist"));
let abs = root.join("app.log");
assert!(rules.is_ignored(abs.to_str().unwrap()));
assert!(rules.is_ignored("/etc/passwd"));
}
#[test]
fn ignore_rules_always_available() {
let dir = make_tree();
let root = dir.path();
let results = WalkBuilder::new(root).unwrap().collect().unwrap();
let rules = results.ignore_rules().expect("rules always present");
assert!(rules.is_ignored("target"));
assert!(rules.is_ignored("debug.log"));
assert!(!rules.is_ignored("Cargo.toml"));
}
#[cfg(unix)]
#[test]
fn permission_denied_surfaces_with_abort_on_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let locked = dir.path().join("locked");
fs::create_dir(&locked).unwrap();
fs::write(locked.join("inner.txt"), "x").unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
struct Restore<'a>(&'a std::path::Path);
impl Drop for Restore<'_> {
fn drop(&mut self) {
let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
}
}
let _restore = Restore(&locked);
if std::fs::read_dir(&locked).is_ok() {
return;
}
let ok = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::empty())
.collect();
assert!(ok.is_ok());
let err = WalkBuilder::new(dir.path())
.unwrap()
.options(WalkFlags::ABORT_ON_ERROR)
.threads(1)
.collect()
.unwrap_err();
assert_eq!(err, ZlobError::PermissionDenied);
}
#[cfg(not(docsrs))]
#[test]
fn flag_values_match_c_header() {
assert_eq!(WalkFlags::GITIGNORE.bits(), ffi::ZLOB_WALK_GITIGNORE as u32);
assert_eq!(
WalkFlags::SKIP_HIDDEN.bits(),
ffi::ZLOB_WALK_SKIP_HIDDEN as u32
);
assert_eq!(
WalkFlags::FOLLOW_SYMLINKS.bits(),
ffi::ZLOB_WALK_FOLLOW_SYMLINKS as u32
);
assert_eq!(
WalkFlags::NO_REPORT_DIRS.bits(),
ffi::ZLOB_WALK_NO_REPORT_DIRS as u32
);
assert_eq!(WalkFlags::SORT.bits(), ffi::ZLOB_WALK_SORT as u32);
assert_eq!(
WalkFlags::ABORT_ON_ERROR.bits(),
ffi::ZLOB_WALK_ABORT_ON_ERROR as u32
);
assert_eq!(
WalkFlags::KEEP_GIT_DIR.bits(),
ffi::ZLOB_WALK_KEEP_GIT_DIR as u32
);
assert_eq!(
WalkFlags::RECOMMENDED,
WalkFlags::GITIGNORE | WalkFlags::SKIP_HIDDEN
);
assert_eq!(WalkMetadata::SIZE.bits(), ffi::ZLOB_META_SIZE as u32);
assert_eq!(WalkMetadata::MTIME.bits(), ffi::ZLOB_META_MTIME as u32);
assert_eq!(WalkMetadata::ATIME.bits(), ffi::ZLOB_META_ATIME as u32);
assert_eq!(WalkMetadata::CTIME.bits(), ffi::ZLOB_META_CTIME as u32);
assert_eq!(WalkMetadata::BTIME.bits(), ffi::ZLOB_META_BTIME as u32);
assert_eq!(WalkMetadata::INODE.bits(), ffi::ZLOB_META_INODE as u32);
assert_eq!(WalkMetadata::NLINK.bits(), ffi::ZLOB_META_NLINK as u32);
assert_eq!(WalkMetadata::MODE.bits(), ffi::ZLOB_META_MODE as u32);
assert_eq!(WalkMetadata::UID.bits(), ffi::ZLOB_META_UID as u32);
assert_eq!(WalkMetadata::GID.bits(), ffi::ZLOB_META_GID as u32);
assert_eq!(WalkMetadata::ALL.bits(), ffi::ZLOB_META_ALL as u32);
}
}
#[cfg(test)]
mod glob_tests {
use super::*;
use std::fs;
#[test]
fn glob_filters_and_prunes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir(root.join("docs")).unwrap();
fs::write(root.join("src/main.rs"), "").unwrap();
fs::write(root.join("src/lib.rs"), "").unwrap();
fs::write(root.join("docs/guide.md"), "").unwrap();
fs::write(root.join("top.rs"), "").unwrap();
let results = WalkBuilder::new(root)
.unwrap()
.options(WalkFlags::SORT)
.threads(1)
.include("**/*.rs")
.unwrap()
.collect()
.unwrap();
let names: Vec<String> = results
.iter()
.map(|e| e.relative_path().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["src/lib.rs", "src/main.rs", "top.rs"]);
let braced = WalkBuilder::new(root)
.unwrap()
.options(WalkFlags::empty())
.threads(1)
.include("**/*.{md,rs}")
.unwrap()
.collect()
.unwrap();
assert_eq!(braced.len(), 4);
let scoped = WalkBuilder::new(root)
.unwrap()
.options(WalkFlags::empty())
.threads(1)
.include("src/**/*.rs")
.unwrap()
.collect()
.unwrap();
assert_eq!(scoped.len(), 2);
}
#[test]
fn glob_composes_with_gitignore_and_parallel_run() {
use std::sync::atomic::{AtomicUsize, Ordering};
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join("src")).unwrap();
fs::create_dir(root.join("target")).unwrap();
fs::write(root.join(".gitignore"), "target/\n").unwrap();
fs::write(root.join("src/a.rs"), "").unwrap();
fs::write(root.join("target/gen.rs"), "").unwrap();
let count = AtomicUsize::new(0);
WalkBuilder::new(root)
.unwrap()
.include("**/*.rs")
.unwrap()
.run(|entry| {
assert!(entry.path().extension().is_some_and(|e| e == "rs"));
count.fetch_add(1, Ordering::Relaxed);
WalkState::Continue
})
.unwrap();
assert_eq!(count.load(Ordering::Relaxed), 1);
}
}