use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use crate::tree::{NodeId, Tree};
pub fn scan(dir: &Path, no_ignore: bool) -> std::io::Result<Tree> {
let _span = crate::profile::span("fstree::scan");
let root_dir = dir.canonicalize()?;
let mut tree = Tree::new_fs(root_dir.clone(), no_ignore);
walk_into(&mut tree, None, &root_dir, no_ignore);
tree.containers_first();
Ok(tree)
}
pub(crate) fn materialize(tree: &mut Tree, id: NodeId) {
let path = PathBuf::from(tree.path(id));
let no_ignore = tree.fs_no_ignore();
walk_into(tree, Some(id), &path, no_ignore);
tree.containers_first_children(id);
tree.mark_children_loaded(id);
}
fn walk_into(tree: &mut Tree, parent: Option<NodeId>, path: &Path, no_ignore: bool) {
let walk = ignore::WalkBuilder::new(path)
.standard_filters(!no_ignore)
.hidden(false)
.max_depth(Some(1))
.sort_by_file_name(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()))
.build();
for entry in walk {
match entry {
Ok(entry) => {
if entry.depth() == 0 {
continue; }
if !no_ignore && is_repository_metadata(entry.file_name()) {
continue;
}
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
tree.push_fs(parent, entry.file_name(), is_dir);
}
Err(error) => tree.record_error(error.to_string()),
}
}
}
fn is_repository_metadata(name: &OsStr) -> bool {
name == OsStr::new(".git") || name == OsStr::new(".jj")
}
#[cfg(test)]
mod tests {
use super::*;
use tui_treelistview::{TreeChildren, TreeModel};
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
std::fs::write(p.join(".hidden-file"), "").unwrap();
std::fs::create_dir(p.join("b-dir")).unwrap();
std::fs::write(p.join("b-dir/inner.txt"), "").unwrap();
std::fs::create_dir(p.join("empty-dir")).unwrap();
std::fs::write(p.join("a-file.txt"), "").unwrap();
std::fs::write(p.join("z-file.txt"), "").unwrap();
std::fs::write(p.join("ignored.log"), "").unwrap();
std::fs::write(p.join(".ignore"), "*.log\n").unwrap();
dir
}
fn scan_all(dir: &Path) -> Tree {
let mut tree = scan(dir, false).unwrap();
tree.index_all();
tree
}
fn root_names(tree: &Tree) -> Vec<String> {
tree.root_ids().iter().map(|&id| tree.name(id)).collect()
}
#[test]
fn default_scan_shows_dotfiles_but_honors_ignore_files() {
let dir = fixture();
let tree = scan(dir.path(), false).unwrap();
assert_eq!(
root_names(&tree),
[
"b-dir",
"empty-dir",
".hidden-file",
".ignore",
"a-file.txt",
"z-file.txt",
]
);
assert!(!root_names(&tree).contains(&"ignored.log".to_string()));
}
#[test]
fn no_ignore_reveals_ignored_files() {
let dir = fixture();
let tree = scan(dir.path(), true).unwrap();
let names = root_names(&tree);
assert!(names.contains(&".hidden-file".to_string()));
assert!(names.contains(&"ignored.log".to_string()));
}
#[test]
fn repository_metadata_directories_follow_the_ignore_setting() {
let dir = fixture();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join(".jj")).unwrap();
std::fs::create_dir(dir.path().join("b-dir/.git")).unwrap();
std::fs::create_dir(dir.path().join("b-dir/.jj")).unwrap();
for (no_ignore, expected) in [(false, false), (true, true)] {
let mut tree = scan(dir.path(), no_ignore).unwrap();
let names = root_names(&tree);
assert_eq!(names.contains(&".git".to_string()), expected, "{names:?}");
assert_eq!(names.contains(&".jj".to_string()), expected, "{names:?}");
let b_dir = tree
.root_ids()
.iter()
.copied()
.find(|&id| tree.name(id) == "b-dir")
.unwrap();
tree.ensure_children(b_dir);
let child_names: Vec<_> = tree
.children_of(b_dir)
.iter()
.map(|&id| tree.name(id))
.collect();
assert_eq!(
child_names.contains(&".git".to_string()),
expected,
"{child_names:?}"
);
assert_eq!(
child_names.contains(&".jj".to_string()),
expected,
"{child_names:?}"
);
}
}
#[test]
fn scan_builds_only_the_top_level_and_dirs_materialize_on_demand() {
let dir = fixture();
let mut tree = scan(dir.path(), false).unwrap();
let b_dir = tree.root_ids()[0];
assert!(tree.children_of(b_dir).is_empty());
assert!(!tree.is_leaf(b_dir), "an unwalked dir must stay expandable");
assert!(!tree.fully_indexed());
assert!(tree.ensure_children(b_dir));
assert_eq!(tree.name(tree.children_of(b_dir)[0]), "inner.txt");
tree.index_all();
assert!(tree.fully_indexed());
assert!(tree.errors().is_empty());
}
#[test]
fn ancestor_ignore_rules_apply_to_lazily_walked_subdirectories() {
let dir = fixture();
std::fs::write(dir.path().join("b-dir/nested.log"), "").unwrap();
let mut tree = scan(dir.path(), false).unwrap();
let b_dir = tree.root_ids()[0];
tree.ensure_children(b_dir);
let names: Vec<String> = tree
.children_of(b_dir)
.iter()
.map(|&id| tree.name(id))
.collect();
assert!(!names.contains(&"nested.log".to_string()), "{names:?}");
assert!(names.contains(&"inner.txt".to_string()), "{names:?}");
}
#[cfg(unix)]
#[test]
fn unreadable_directories_record_an_error_instead_of_vanishing() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let mut tree = scan(dir.path(), false).unwrap();
tree.index_all();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(tree.fully_indexed());
assert!(!tree.errors().is_empty());
}
#[test]
fn children_and_depth() {
let dir = fixture();
let tree = scan_all(dir.path());
let b_dir = tree.root_ids()[0];
assert_eq!(tree.name(b_dir), "b-dir");
assert_eq!(tree.depth(b_dir), 0);
let kids = tree.children_of(b_dir).to_vec();
assert_eq!(kids.len(), 1);
assert_eq!(tree.name(kids[0]), "inner.txt");
assert_eq!(tree.depth(kids[0]), 1);
assert_eq!(tree.parent(kids[0]), Some(b_dir));
}
#[test]
fn paths_are_absolute_and_relative() {
let dir = fixture();
let tree = scan_all(dir.path());
let b_dir = tree.root_ids()[0];
let inner = tree.children_of(b_dir)[0];
assert!(Path::new(&tree.path(inner)).is_absolute());
assert!(Path::new(&tree.path(inner)).ends_with("b-dir/inner.txt"));
assert_eq!(
tree.relpath(inner),
Path::new("b-dir/inner.txt").as_os_str()
);
assert_eq!(
tree.alternate_output(inner),
std::ffi::OsStr::new("inner.txt")
);
}
#[test]
fn leaf_classification() {
let dir = fixture();
let tree = scan_all(dir.path());
let by_name = |name: &str| {
tree.root_ids()
.iter()
.copied()
.find(|&id| tree.name(id) == name)
.unwrap()
};
assert!(!tree.is_leaf(by_name("b-dir")));
assert!(!tree.is_leaf(by_name("empty-dir")));
assert!(tree.is_leaf(by_name("a-file.txt")));
}
#[test]
fn tree_model_children_match_nodes() {
let dir = fixture();
let tree = scan_all(dir.path());
let b_dir = tree.root_ids()[0];
match tree.children(b_dir) {
TreeChildren::Loaded(kids) => assert_eq!(kids, tree.children_of(b_dir)),
other => panic!("expected Loaded, got {other:?}"),
}
let empty = tree.root_ids()[1];
assert_eq!(tree.children(empty), TreeChildren::Unloaded);
}
#[test]
fn unwalked_directories_report_unloaded_children() {
let dir = fixture();
let tree = scan(dir.path(), false).unwrap();
let b_dir = tree.root_ids()[0];
assert_eq!(tree.children(b_dir), TreeChildren::Unloaded);
}
#[test]
fn branches_lists_expandable_dirs() {
let dir = fixture();
let tree = scan_all(dir.path());
let names: Vec<String> = tree.branches().map(|(id, _)| tree.name(id)).collect();
assert_eq!(names, ["b-dir", "empty-dir"]);
}
}