use std::fs;
use std::path::{Path, PathBuf};
use iced_swdir_tree::{DirectoryFilter, DirectoryTree, DirectoryTreeEvent, Error, TreeNode};
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let p = std::env::temp_dir().join(format!(
"iced-swdir-tree-it-{}-{}-{}",
std::process::id(),
nanos,
tag
));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).expect("create tmpdir");
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn files_and_folders_filter_shows_both() {
let td = TmpDir::new("fandf");
fs::create_dir(td.path().join("sub")).unwrap();
fs::write(td.path().join("visible.txt"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FilesAndFolders);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let names = child_names(&tree);
assert!(names.contains(&"sub".into()), "folder must be listed");
assert!(names.contains(&"visible.txt".into()), "file must be listed");
assert_eq!(names.len(), 2);
}
#[test]
fn folders_only_filter_drops_files() {
let td = TmpDir::new("fonly");
fs::create_dir(td.path().join("sub")).unwrap();
fs::write(td.path().join("file.txt"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FoldersOnly);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let names = child_names(&tree);
assert_eq!(names.len(), 1);
assert_eq!(names[0], "sub");
}
#[test]
fn all_including_hidden_shows_dotfiles() {
let td = TmpDir::new("hidden");
fs::write(td.path().join(".secret"), b"").unwrap();
fs::write(td.path().join("visible.txt"), b"").unwrap();
let mut tree = DirectoryTree::new(td.path().to_path_buf())
.with_filter(DirectoryFilter::AllIncludingHidden);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let names = child_names(&tree);
assert_eq!(names.len(), 2);
assert!(names.contains(&".secret".into()));
}
#[test]
fn default_filter_hides_dotfiles() {
let td = TmpDir::new("no-dot");
fs::write(td.path().join(".secret"), b"").unwrap();
fs::write(td.path().join("visible.txt"), b"").unwrap();
let mut tree = DirectoryTree::new(td.path().to_path_buf());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let names = child_names(&tree);
assert_eq!(names.len(), 1);
assert_eq!(names[0], "visible.txt");
}
#[test]
fn filter_change_rebuilds_from_cache() {
let td = TmpDir::new("refilter");
fs::create_dir(td.path().join("sub")).unwrap();
fs::write(td.path().join(".secret"), b"").unwrap();
fs::write(td.path().join("visible.txt"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FilesAndFolders);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
assert_eq!(child_names(&tree).len(), 2);
tree.set_filter(DirectoryFilter::AllIncludingHidden);
assert_eq!(child_names(&tree).len(), 3);
assert!(child_names(&tree).contains(&".secret".into()));
tree.set_filter(DirectoryFilter::FoldersOnly);
let names = child_names(&tree);
assert_eq!(names.len(), 1);
assert_eq!(names[0], "sub");
}
#[test]
fn collapse_then_reexpand_keeps_children() {
let td = TmpDir::new("collapse");
fs::create_dir(td.path().join("a")).unwrap();
fs::write(td.path().join("b.txt"), b"").unwrap();
let mut tree = DirectoryTree::new(td.path().to_path_buf());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
assert_eq!(child_names(&tree).len(), 2);
let _ = tree.update(DirectoryTreeEvent::Toggled(td.path().to_path_buf()));
let _ = tree.update(DirectoryTreeEvent::Toggled(td.path().to_path_buf()));
assert_eq!(child_names(&tree).len(), 2);
}
#[test]
fn selection_lands_on_the_target_path() {
let td = TmpDir::new("select");
fs::create_dir(td.path().join("keeps")).unwrap();
fs::create_dir(td.path().join("also")).unwrap();
let mut tree = DirectoryTree::new(td.path().to_path_buf());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let target = td.path().join("keeps");
let _ = tree.update(DirectoryTreeEvent::Selected(target.clone(), true));
assert_eq!(tree.selected_path(), Some(target.as_path()));
}
#[test]
fn filter_change_preserves_selection_cursor() {
let td = TmpDir::new("select-filter");
fs::create_dir(td.path().join("keeps")).unwrap();
fs::write(td.path().join("file.txt"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FilesAndFolders);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let target = td.path().join("file.txt");
let _ = tree.update(DirectoryTreeEvent::Selected(target.clone(), false));
assert_eq!(tree.selected_path(), Some(target.as_path()));
tree.set_filter(DirectoryFilter::FoldersOnly);
assert_eq!(
tree.selected_path(),
Some(target.as_path()),
"v0.2: selection cursor survives filter change"
);
tree.set_filter(DirectoryFilter::FilesAndFolders);
assert_eq!(tree.selected_path(), Some(target.as_path()));
}
#[test]
fn filter_change_preserves_directory_selection_when_still_visible() {
let td = TmpDir::new("select-folder-filter");
fs::create_dir(td.path().join("keeps")).unwrap();
fs::write(td.path().join("file.txt"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FilesAndFolders);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
let target = td.path().join("keeps");
let _ = tree.update(DirectoryTreeEvent::Selected(target.clone(), true));
assert_eq!(tree.selected_path(), Some(target.as_path()));
tree.set_filter(DirectoryFilter::FoldersOnly);
assert_eq!(tree.selected_path(), Some(target.as_path()));
let node = find_in_tree(&tree, &target).expect("folder must still be in the tree");
assert!(
node.is_selected,
"per-node flag must be re-synced after rebuild"
);
}
#[test]
fn nonexistent_path_surfaces_as_error_not_panic() {
let missing = std::env::temp_dir().join(format!(
"iced-swdir-tree-nothing-here-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&missing);
let mut tree = DirectoryTree::new(missing.clone());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, missing.clone());
let root = find_in_tree(&tree, &missing).expect("root always present");
assert!(
root.error.is_some(),
"missing path must surface as node.error, not a panic or empty list"
);
if let Some(Error::Io { kind, .. }) = root.error.clone() {
assert_eq!(kind, std::io::ErrorKind::NotFound);
} else {
panic!("expected Error::Io");
}
}
#[cfg(unix)]
#[test]
fn permission_denied_is_greyed_out_not_fatal() {
use std::os::unix::fs::PermissionsExt;
if is_root() {
eprintln!("skipping permission test under root (CAP_DAC_OVERRIDE bypasses chmod)");
return;
}
let td = TmpDir::new("perm");
let locked = td.path().join("locked");
fs::create_dir(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
struct Restorer<'a>(&'a Path);
impl Drop for Restorer<'_> {
fn drop(&mut self) {
let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
}
}
let _restorer = Restorer(&locked);
let mut tree = DirectoryTree::new(locked.clone());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, locked.clone());
let node = find_in_tree(&tree, &locked).expect("locked dir tracked");
assert!(
node.error.is_some(),
"permission denied must surface as error"
);
if let Some(Error::Io { kind, .. }) = node.error.clone() {
assert_eq!(kind, std::io::ErrorKind::PermissionDenied);
} else {
panic!("expected Error::Io, got {:?}", node.error);
}
}
#[cfg(unix)]
fn is_root() -> bool {
fs::read_to_string("/proc/self/status")
.map(|s| {
s.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.map(|rest| rest.split_whitespace().next().unwrap_or("1000") == "0")
.unwrap_or(false)
})
.unwrap_or(false)
}
fn child_names(tree: &DirectoryTree) -> Vec<String> {
let root = find_in_tree(tree, tree.root_path()).expect("root exists");
root.children
.iter()
.map(|n| {
n.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
})
.collect()
}
fn find_in_tree<'a>(tree: &'a DirectoryTree, path: &Path) -> Option<&'a TreeNode> {
fn walk<'a>(node: &'a TreeNode, path: &Path) -> Option<&'a TreeNode> {
if node.path == path {
return Some(node);
}
if !path.starts_with(&node.path) {
return None;
}
node.children.iter().find_map(|c| walk(c, path))
}
walk(iced_swdir_tree::__testing::root(tree), path)
}
#[test]
fn filter_change_preserves_expanded_subtree() {
let td = TmpDir::new("expanded-preserve");
fs::create_dir_all(td.path().join("sub/inner")).unwrap();
fs::write(td.path().join("sub/inner/leaf.txt"), b"").unwrap();
fs::write(td.path().join(".hidden"), b"").unwrap();
let mut tree =
DirectoryTree::new(td.path().to_path_buf()).with_filter(DirectoryFilter::FilesAndFolders);
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().to_path_buf());
iced_swdir_tree::__testing::scan_and_feed(&mut tree, td.path().join("sub"));
let sub_before = find_in_tree(&tree, &td.path().join("sub")).expect("sub exists");
assert!(sub_before.is_expanded);
assert!(sub_before.is_loaded);
assert!(!sub_before.children.is_empty());
tree.set_filter(DirectoryFilter::AllIncludingHidden);
let sub_after =
find_in_tree(&tree, &td.path().join("sub")).expect("sub must still be in the tree");
assert!(
sub_after.is_expanded,
"expansion must survive filter change"
);
assert!(
sub_after.is_loaded,
"loaded flag must survive filter change"
);
assert!(
!sub_after.children.is_empty(),
"deeper subtree must survive filter change"
);
}
#[derive(Default)]
struct CountingExecutor {
count: std::sync::atomic::AtomicUsize,
}
impl iced_swdir_tree::ScanExecutor for CountingExecutor {
fn spawn_blocking(&self, job: iced_swdir_tree::ScanJob) -> iced_swdir_tree::ScanFuture {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
iced_swdir_tree::ThreadExecutor.spawn_blocking(job)
}
}
#[test]
fn with_executor_accepts_a_custom_impl() {
use std::sync::Arc;
let exec: Arc<dyn iced_swdir_tree::ScanExecutor> = Arc::new(CountingExecutor::default());
let _tree = DirectoryTree::new(PathBuf::from("/tmp"))
.with_executor(exec.clone())
.with_filter(DirectoryFilter::FilesAndFolders);
}
#[test]
fn default_executor_is_thread_executor_and_builds_cleanly() {
let _tree = DirectoryTree::new(PathBuf::from("/tmp"));
}