use anyhow::Result;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
pub type TreeNodeRef = Rc<RefCell<TreeNode>>;
pub struct TreeNode {
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub is_expanded: bool,
pub depth: usize,
pub children: Vec<TreeNodeRef>,
pub has_error: bool, pub error_message: Option<String>, pub has_children: Option<bool>,
is_sorted: bool, }
impl TreeNode {
pub fn new(path: PathBuf, depth: usize) -> Result<Self> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let is_dir = path.is_dir();
Ok(TreeNode {
path,
name,
is_dir,
is_expanded: false,
depth,
children: Vec::new(),
has_error: false,
error_message: None,
has_children: None,
is_sorted: false,
})
}
pub fn probe_has_children(
&mut self,
show_files: bool,
show_hidden: bool,
follow_symlinks: bool,
) {
if !self.is_dir {
self.has_children = Some(false);
return;
}
let entries = match fs::read_dir(&self.path) {
Ok(e) => e,
Err(e) => {
self.has_error = true;
self.error_message = Some(format!("Cannot read: {}", e));
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !follow_symlinks {
if let Ok(meta) = fs::symlink_metadata(&path) {
if meta.is_symlink() {
continue;
}
}
}
if !show_hidden {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
}
}
if path.is_dir() || show_files {
self.has_children = Some(true);
return;
}
}
self.has_children = Some(false);
}
pub fn load_children(
&mut self,
show_files: bool,
show_hidden: bool,
follow_symlinks: bool,
) -> Result<()> {
if !self.is_dir || (!self.children.is_empty() && self.is_sorted) {
return Ok(());
}
if !self.children.is_empty() {
self.children.clear();
self.is_sorted = false;
self.has_children = None;
}
let entries = match fs::read_dir(&self.path) {
Ok(entries) => entries,
Err(e) => {
self.has_error = true;
self.error_message = Some(format!("Cannot read: {}", e));
return Ok(()); }
};
let mut error_count = 0;
let mut skipped_entries = Vec::new();
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
if !follow_symlinks {
if let Ok(metadata) = fs::symlink_metadata(&path) {
if metadata.is_symlink() {
continue; }
}
}
let is_dir = path.is_dir();
if !show_hidden {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue; }
}
}
if is_dir || show_files {
match TreeNode::new(path.clone(), self.depth + 1) {
Ok(node) => {
self.children.push(Rc::new(RefCell::new(node)));
}
Err(e) => {
error_count += 1;
skipped_entries.push(format!(
"{}: {}",
path.file_name().unwrap_or_default().to_string_lossy(),
e
));
}
}
}
}
Err(e) => {
error_count += 1;
skipped_entries.push(format!("unknown entry: {}", e));
}
}
}
if error_count > 0 {
self.has_error = true;
if error_count <= 3 {
self.error_message = Some(skipped_entries.join(", "));
} else {
self.error_message = Some(format!("{} entries inaccessible", error_count));
}
}
self.children.sort_by(|a, b| {
let a_borrowed = a.borrow();
let b_borrowed = b.borrow();
match (a_borrowed.is_dir, b_borrowed.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a_borrowed.name.cmp(&b_borrowed.name),
}
});
self.is_sorted = true;
self.has_children = Some(!self.children.is_empty());
for child_ref in &self.children {
child_ref
.borrow_mut()
.probe_has_children(show_files, show_hidden, follow_symlinks);
}
Ok(())
}
pub fn toggle_expand(
&mut self,
show_files: bool,
show_hidden: bool,
follow_symlinks: bool,
) -> Result<()> {
if !self.is_dir {
return Ok(());
}
if self.has_children == Some(false) {
return Ok(());
}
if self.is_expanded {
self.is_expanded = false;
} else {
self.load_children(show_files, show_hidden, follow_symlinks)?;
if !self.has_error {
self.is_expanded = true;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_dir_node(path: PathBuf) -> TreeNode {
TreeNode::new(path, 0).expect("TreeNode::new failed")
}
#[test]
fn leaf_dir_has_children_false_after_load() {
let tmp = TempDir::new().unwrap();
let mut node = make_dir_node(tmp.path().to_path_buf());
node.load_children(false, false, false).unwrap();
assert_eq!(
node.has_children,
Some(false),
"empty dir should be Some(false)"
);
}
#[test]
fn dir_with_subdir_has_children_true_after_load() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("sub")).unwrap();
let mut node = make_dir_node(tmp.path().to_path_buf());
node.load_children(false, false, false).unwrap();
assert_eq!(node.has_children, Some(true));
}
#[test]
fn child_leaf_gets_has_children_false_after_parent_load() {
let tmp = TempDir::new().unwrap();
let child = tmp.path().join("leaf");
std::fs::create_dir(&child).unwrap();
let mut root = make_dir_node(tmp.path().to_path_buf());
root.load_children(false, false, false).unwrap();
let child_node = root.children[0].borrow();
assert_eq!(
child_node.has_children,
Some(false),
"leaf child should be probed as Some(false)"
);
}
#[test]
fn child_with_subdir_gets_has_children_true_after_parent_load() {
let tmp = TempDir::new().unwrap();
let child = tmp.path().join("inner");
std::fs::create_dir(&child).unwrap();
std::fs::create_dir(child.join("nested")).unwrap();
let mut root = make_dir_node(tmp.path().to_path_buf());
root.load_children(false, false, false).unwrap();
let child_node = root.children[0].borrow();
assert_eq!(child_node.has_children, Some(true));
}
#[test]
fn toggle_expand_does_not_expand_leaf_dir() {
let tmp = TempDir::new().unwrap();
let mut node = make_dir_node(tmp.path().to_path_buf());
node.probe_has_children(false, false, false);
assert_eq!(node.has_children, Some(false));
node.toggle_expand(false, false, false).unwrap();
assert!(!node.is_expanded, "leaf dir must not expand");
}
#[test]
fn toggle_expand_expands_dir_with_subdir() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("child")).unwrap();
let mut node = make_dir_node(tmp.path().to_path_buf());
node.probe_has_children(false, false, false);
assert_eq!(node.has_children, Some(true));
node.toggle_expand(false, false, false).unwrap();
assert!(node.is_expanded);
}
#[cfg(unix)]
#[test]
fn probe_marks_unreadable_dir_as_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let locked = tmp.path().join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let mut root = make_dir_node(tmp.path().to_path_buf());
root.load_children(false, false, false).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
let child = root
.children
.iter()
.find(|c| c.borrow().name == "locked")
.expect("locked child must exist");
let child = child.borrow();
assert!(child.has_error, "probe must mark unreadable dir as error");
assert!(
child.error_message.is_some(),
"error_message must be set by probe"
);
}
}