mod common;
use dirwalk::{Sort, Threads, WalkBuilder, scan_dir};
use std::fs;
use std::str::FromStr;
#[test]
fn walk_full() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.hidden(true)
.stats(true)
.build()
.unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"b.rs"));
assert!(names.contains(&".hidden"));
assert!(names.contains(&"Makefile"));
assert!(names.contains(&"sub"));
assert!(names.contains(&"c.txt"));
assert!(names.contains(&"deep"));
assert!(names.contains(&"d.md"));
assert!(names.contains(&"empty"));
let stats = result.stats.unwrap();
assert_eq!(stats.dir_count, 3);
#[cfg(unix)]
{
assert_eq!(stats.file_count, 7);
}
#[cfg(not(unix))]
{
assert_eq!(stats.file_count, 6);
assert_eq!(stats.total_size, 10 + 20 + 5 + 4 + 15 + 8);
}
}
#[test]
fn walk_hidden_excluded_by_default() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).build().unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(
!names.contains(&".hidden"),
"hidden files should be excluded by default"
);
assert!(!result.entries.iter().any(|e| e.is_hidden));
}
#[test]
fn walk_hidden_toggle() {
let root = common::fixture_path();
let without = WalkBuilder::new(&root).hidden(false).build().unwrap();
let with = WalkBuilder::new(&root).hidden(true).build().unwrap();
assert!(
with.entries.len() > without.entries.len(),
"hidden(true) should return more entries"
);
assert!(with.entries.iter().any(|e| e.name() == ".hidden"));
assert!(!without.entries.iter().any(|e| e.name() == ".hidden"));
}
#[test]
fn walk_max_depth() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.hidden(true)
.max_depth(1)
.stats(true)
.build()
.unwrap();
assert!(result.entries.iter().all(|e| e.depth == 1));
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"sub"));
assert!(!names.contains(&"c.txt"), "c.txt is depth 2");
assert!(!names.contains(&"d.md"), "d.md is depth 3");
}
#[test]
fn walk_max_depth_limits_recursion() {
let root = common::fixture_path();
let shallow = WalkBuilder::new(&root).max_depth(1).build().unwrap();
let deep = WalkBuilder::new(&root).build().unwrap();
assert!(deep.entries.len() >= shallow.entries.len());
}
#[test]
fn walk_relative_paths() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).build().unwrap();
for entry in &result.entries {
let components: usize = entry.relative_path.split(['/', '\\']).count();
assert_eq!(
components,
entry.depth as usize,
"depth mismatch for {}",
entry.name()
);
}
}
#[test]
fn walk_empty_dir() {
let root = common::fixture_path();
let entries = scan_dir(&root.join("empty")).unwrap();
assert!(entries.is_empty());
}
#[test]
fn walk_invalid_path() {
let result = WalkBuilder::new("nonexistent_path_xyz").build();
assert!(result.is_err());
}
#[test]
fn walk_iter_collects_all_entries() {
let root = common::fixture_path();
let iter = WalkBuilder::new(&root).hidden(true).iter().unwrap();
let entries: Vec<_> = iter.filter_map(|r| r.ok()).collect();
let names: Vec<&str> = entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"d.md"));
#[cfg(unix)]
assert_eq!(entries.iter().filter(|e| !e.is_dir).count(), 7);
#[cfg(not(unix))]
assert_eq!(entries.iter().filter(|e| !e.is_dir).count(), 6);
}
#[test]
fn walk_iter_stats() {
let root = common::fixture_path();
let mut iter = WalkBuilder::new(&root).hidden(true).iter().unwrap();
for _ in &mut iter {}
let stats = iter.stats();
assert_eq!(stats.dir_count, 3);
#[cfg(unix)]
assert_eq!(stats.file_count, 7);
#[cfg(not(unix))]
{
assert_eq!(stats.file_count, 6);
assert_eq!(stats.total_size, 62);
}
}
#[test]
fn filter_by_extension() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.extensions(&["txt"])
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert!(files.contains(&"a.txt"));
assert!(files.contains(&"c.txt"));
assert!(!files.contains(&"b.rs"));
assert!(!files.contains(&"d.md"));
}
#[test]
fn filter_by_extension_keeps_dirs() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.extensions(&["txt"])
.stats(true)
.build()
.unwrap();
let stats = result.stats.as_ref().unwrap();
assert_eq!(
stats.file_count + stats.dir_count,
result.entries.len() as u64,
"stats counts should match returned entries"
);
let expected_file_count = result.entries.iter().filter(|e| !e.is_dir).count() as u64;
assert_eq!(stats.file_count, expected_file_count);
}
#[test]
fn filter_by_extension_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("photo.PNG"), "img").unwrap();
fs::write(dir.path().join("doc.txt"), "txt").unwrap();
let result = WalkBuilder::new(dir.path())
.extensions(&["png"])
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert!(
files.contains(&"photo.PNG"),
"case-insensitive extension match failed"
);
assert!(!files.contains(&"doc.txt"));
}
#[test]
fn filter_by_multiple_extensions() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.extensions(&["txt", "md"])
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert!(files.contains(&"a.txt"));
assert!(files.contains(&"c.txt"));
assert!(files.contains(&"d.md"));
assert!(!files.contains(&"b.rs"));
assert!(!files.contains(&"Makefile"));
}
#[test]
fn filter_by_glob() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.glob("*.txt")
.unwrap()
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert!(files.contains(&"a.txt"));
assert!(files.contains(&"c.txt"));
assert!(!files.contains(&"b.rs"));
}
#[test]
fn filter_by_size() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.hidden(true)
.min_size(10)
.max_size(15)
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert!(files.contains(&"a.txt"));
assert!(files.contains(&"c.txt"));
assert!(!files.contains(&"b.rs"));
assert!(!files.contains(&".hidden"));
assert!(!files.contains(&"Makefile"));
}
#[test]
fn filter_gitignore() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "*.rs\n").unwrap();
fs::write(dir.path().join("a.txt"), "x").unwrap();
fs::write(dir.path().join("b.rs"), "x").unwrap();
let result = WalkBuilder::new(dir.path())
.hidden(true)
.gitignore(true)
.build()
.unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(!names.contains(&"b.rs"), ".gitignore should exclude *.rs");
assert!(names.contains(&"a.txt"));
}
#[test]
fn filter_malformed_gitignore() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join(".gitignore"),
"[invalid\n\0\nweird**//stuff\n",
)
.unwrap();
fs::write(dir.path().join("a.txt"), "x").unwrap();
let result = WalkBuilder::new(dir.path())
.hidden(true)
.gitignore(true)
.build();
assert!(result.is_ok(), "malformed .gitignore should not crash");
}
#[test]
fn sort_by_name() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.max_depth(1)
.hidden(true)
.sort(Sort::Name)
.build()
.unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
let mut sorted = names.clone();
sorted.sort_by(|a, b| natord::compare(a, b));
assert_eq!(names, sorted);
}
#[test]
fn sort_by_name_per_parent_group() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
fs::create_dir(p.join("sub")).unwrap();
fs::write(p.join("b.txt"), "x").unwrap();
fs::write(p.join("a.txt"), "x").unwrap();
fs::write(p.join("sub/z.txt"), "x").unwrap();
fs::write(p.join("sub/c.txt"), "x").unwrap();
let result = WalkBuilder::new(p).sort(Sort::Name).build().unwrap();
let root_files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir && e.depth == 1)
.map(|e| e.name())
.collect();
assert_eq!(root_files, vec!["a.txt", "b.txt"]);
let sub_files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir && e.depth == 2)
.map(|e| e.name())
.collect();
assert_eq!(sub_files, vec!["c.txt", "z.txt"]);
}
#[test]
fn sort_by_size() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.max_depth(1)
.hidden(true)
.sort(Sort::Size)
.build()
.unwrap();
let files: Vec<_> = result.entries.iter().filter(|e| !e.is_dir).collect();
for w in files.windows(2) {
assert!(
w[0].size >= w[1].size,
"expected descending size: {} ({}) >= {} ({})",
w[0].name(),
w[0].size,
w[1].name(),
w[1].size
);
}
let names: Vec<&str> = files.iter().map(|e| e.name()).collect();
let b_pos = names.iter().position(|&n| n == "b.rs").unwrap();
let a_pos = names.iter().position(|&n| n == "a.txt").unwrap();
let hidden_pos = names.iter().position(|&n| n == ".hidden").unwrap();
let makefile_pos = names.iter().position(|&n| n == "Makefile").unwrap();
assert!(b_pos < a_pos, "b.rs (20) should come before a.txt (10)");
assert!(
a_pos < hidden_pos,
"a.txt (10) should come before .hidden (5)"
);
assert!(
hidden_pos < makefile_pos,
".hidden (5) should come before Makefile (4)"
);
}
#[test]
fn sort_by_modified() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.max_depth(1)
.hidden(true)
.sort(Sort::Modified)
.build()
.unwrap();
let modified: Vec<i64> = result.entries.iter().map(|e| e.modified).collect();
for w in modified.windows(2) {
assert!(
w[0] >= w[1],
"expected descending mtime: {} < {}",
w[0],
w[1]
);
}
}
#[test]
fn sort_by_extension() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.max_depth(1)
.sort(Sort::Extension)
.build()
.unwrap();
let files: Vec<_> = result.entries.iter().filter(|e| !e.is_dir).collect();
for w in files.windows(2) {
let a = w[0].extension().unwrap_or("");
let b = w[1].extension().unwrap_or("");
assert!(
a.cmp(b) != std::cmp::Ordering::Greater,
"extension sort violated: '{a}' > '{b}'"
);
}
assert_eq!(files[0].name(), "Makefile");
}
#[test]
fn sort_by_extension_case_ordering() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
fs::write(p.join("a.rs"), "x").unwrap();
fs::write(p.join("b.RS"), "x").unwrap();
let result = WalkBuilder::new(p)
.max_depth(1)
.sort(Sort::Extension)
.build()
.unwrap();
let files: Vec<&str> = result
.entries
.iter()
.filter(|e| !e.is_dir)
.map(|e| e.name())
.collect();
assert_eq!(files[0], "b.RS");
assert_eq!(files[1], "a.rs");
}
#[test]
fn sort_dirs_first() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.max_depth(1)
.sort(Sort::Name)
.dirs_first(true)
.build()
.unwrap();
let mut seen_file = false;
for entry in &result.entries {
if !entry.is_dir {
seen_file = true;
}
assert!(
!(entry.is_dir && seen_file),
"dir '{}' after file",
entry.name()
);
}
}
#[test]
fn sort_unicode_filenames() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
fs::write(p.join("zebra.txt"), "x").unwrap();
fs::write(p.join("apple.txt"), "x").unwrap();
fs::write(p.join("über.txt"), "x").unwrap();
let result = WalkBuilder::new(p)
.max_depth(1)
.sort(Sort::Name)
.build()
.unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert_eq!(names.len(), 3);
assert_eq!(names[0], "apple.txt");
assert_eq!(names[1], "zebra.txt");
assert_eq!(names[2], "über.txt");
}
#[test]
fn group_by_directory() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).hidden(true).build().unwrap();
let groups = dirwalk::group::group_by_directory(&result.entries);
for (parent_key, entries) in &groups {
for entry in entries {
let computed_parent = match entry.relative_path.rfind(['/', '\\']) {
Some(pos) => &entry.relative_path[..pos],
None => "",
};
assert_eq!(
computed_parent, *parent_key,
"entry '{}' in wrong group '{}'",
entry.relative_path, parent_key
);
}
}
assert!(groups.contains_key(""), "should have a root group");
}
#[test]
fn group_by_extension() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).hidden(true).build().unwrap();
let groups = dirwalk::group::group_by_extension(&result.entries);
assert!(groups.contains_key(&Some("txt")));
assert!(groups.contains_key(&Some("rs")));
assert!(groups.contains_key(&Some("md")));
assert!(groups.contains_key(&None));
for (ext, entries) in &groups {
for entry in entries {
assert_eq!(entry.extension(), *ext);
}
}
}
#[test]
fn group_by_depth() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).hidden(true).build().unwrap();
let groups = dirwalk::group::group_by_depth(&result.entries);
assert!(groups.contains_key(&1)); assert!(groups.contains_key(&2)); assert!(groups.contains_key(&3));
for (depth, entries) in &groups {
for entry in entries {
assert_eq!(entry.depth, *depth);
}
}
}
#[test]
fn tree_empty() {
let tree = dirwalk::tree::to_tree::<dirwalk::Entry>(&[]);
assert!(tree.is_empty());
}
#[test]
fn tree_structure() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root).sort(Sort::Name).build().unwrap();
let tree = dirwalk::tree::to_tree(&result.entries);
assert!(!tree.is_empty());
for node in &tree {
assert_eq!(node.entry.depth, 1);
}
let sub = tree.iter().find(|n| n.entry.name() == "sub");
assert!(sub.is_some(), "sub directory should be in tree");
let sub = sub.unwrap();
assert!(
!sub.children.is_empty(),
"sub should have children (c.txt, deep)"
);
let deep = sub.children.iter().find(|n| n.entry.name() == "deep");
assert!(deep.is_some());
let deep = deep.unwrap();
assert!(deep.children.iter().any(|n| n.entry.name() == "d.md"));
}
#[test]
fn scan_dir_returns_entries() {
let root = common::fixture_path();
let entries = scan_dir(&root).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"b.rs"));
assert!(names.contains(&"Makefile"));
assert!(names.contains(&"sub"));
assert!(names.contains(&"empty"));
}
#[test]
fn scan_dir_metadata() {
let root = common::fixture_path();
let entries = scan_dir(&root).unwrap();
let a = entries.iter().find(|e| e.name() == "a.txt").unwrap();
assert!(!a.is_dir);
assert_eq!(a.size, 10);
assert!(a.modified > 0);
assert_eq!(a.extension(), Some("txt"));
assert_eq!(a.relative_path, a.name());
assert_eq!(a.depth, 0);
let sub = entries.iter().find(|e| e.name() == "sub").unwrap();
assert!(sub.is_dir);
assert_eq!(sub.extension(), None);
let makefile = entries.iter().find(|e| e.name() == "Makefile").unwrap();
assert_eq!(makefile.extension(), None);
assert_eq!(makefile.size, 4);
}
#[test]
fn scan_dir_excludes_dot_entries() {
let root = common::fixture_path();
let entries = scan_dir(&root).unwrap();
assert!(!entries.iter().any(|e| e.name() == "."));
assert!(!entries.iter().any(|e| e.name() == ".."));
}
#[test]
fn scan_dir_invalid_path() {
let result = scan_dir(std::path::Path::new("nonexistent_path_that_does_not_exist"));
assert!(result.is_err());
}
#[test]
fn parallel_matches_sequential() {
let root = common::fixture_path();
let mut sequential = WalkBuilder::new(&root)
.hidden(true)
.build()
.unwrap()
.entries;
let mut parallel = WalkBuilder::new(&root)
.hidden(true)
.threads(Threads::from(4))
.build()
.unwrap()
.entries;
sequential.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
parallel.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
assert_eq!(
sequential.len(),
parallel.len(),
"parallel found different count"
);
for (s, p) in sequential.iter().zip(parallel.iter()) {
assert_eq!(s.relative_path, p.relative_path);
assert_eq!(s.is_dir, p.is_dir);
assert_eq!(s.size, p.size);
}
}
#[cfg(unix)]
#[test]
fn follow_links_resolves_symlink() {
let root = common::fixture_path();
let without = WalkBuilder::new(&root).hidden(true).build().unwrap();
let link_entry = without.entries.iter().find(|e| e.name() == "link.txt");
assert!(link_entry.is_some(), "link.txt should be discovered");
assert!(link_entry.unwrap().is_symlink);
let with = WalkBuilder::new(&root)
.hidden(true)
.follow_links(true)
.build()
.unwrap();
let link_entry = with
.entries
.iter()
.find(|e| e.name() == "link.txt")
.unwrap();
assert_eq!(link_entry.size, 0);
}
#[cfg(unix)]
#[test]
fn follow_links_descends_symlinked_dir() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
fs::create_dir(p.join("real")).unwrap();
fs::write(p.join("real/inside.txt"), "hello").unwrap();
std::os::unix::fs::symlink(p.join("real"), p.join("linked")).unwrap();
let result = WalkBuilder::new(p).follow_links(true).build().unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"linked"));
assert_eq!(
names.iter().filter(|&&n| n == "inside.txt").count(),
2,
"inside.txt should appear under both real/ and linked/"
);
}
#[cfg(unix)]
#[test]
fn follow_links_cycle_detection() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
fs::create_dir(p.join("a")).unwrap();
fs::write(p.join("a/file.txt"), "x").unwrap();
std::os::unix::fs::symlink(p, p.join("a/loop")).unwrap();
let result = WalkBuilder::new(p).follow_links(true).build().unwrap();
let names: Vec<&str> = result.entries.iter().map(|e| e.name()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"file.txt"));
}
#[test]
fn threads_zero_auto() {
let root = common::fixture_path();
let result = WalkBuilder::new(&root)
.threads(Threads::from(0))
.build()
.unwrap();
assert!(!result.entries.is_empty());
}
#[test]
fn threads_from_str_valid() {
let _: Threads = "4".parse().unwrap();
let _: Threads = "1/2".parse().unwrap();
let _: Threads = " 8 ".parse().unwrap();
let _: Threads = "3/4".parse().unwrap();
}
#[test]
fn threads_from_str_invalid() {
assert!(Threads::from_str("abc").is_err());
assert!(Threads::from_str("0/4").is_err());
assert!(Threads::from_str("1/0").is_err());
assert!(Threads::from_str("3/2").is_err());
}
#[test]
fn error_display_and_source() {
use std::error::Error as StdError;
let err = match WalkBuilder::new("nonexistent_path_xyz").build() {
Err(e) => e,
Ok(_) => panic!("expected error for nonexistent path"),
};
let display = format!("{err}");
assert!(
display.contains("nonexistent_path_xyz"),
"Display should include the path: {display}"
);
assert!(
err.source().is_some(),
"source() should return the io::Error"
);
}