use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct Entry {
pub path: PathBuf,
pub is_dir: bool,
}
impl Entry {
pub fn name(&self) -> std::borrow::Cow<'_, str> {
match self.path.file_name() {
Some(n) => n.to_string_lossy(),
None => self.path.to_string_lossy(),
}
}
}
fn is_dir(e: &std::fs::DirEntry) -> bool {
match e.file_type() {
Ok(t) if t.is_dir() => true,
Ok(t) if t.is_symlink() => e.path().is_dir(),
_ => false,
}
}
pub fn children(dir: &Path, hidden: bool) -> Vec<Entry> {
let mut v: Vec<Entry> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| hidden || !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| Entry {
is_dir: is_dir(&e),
path: e.path(),
})
.collect();
v.sort_by_key(|e| (!e.is_dir, e.name().to_lowercase()));
v
}
#[cfg(windows)]
fn drives() -> Vec<PathBuf> {
(b'A'..=b'Z')
.map(|b| PathBuf::from(format!("{}:\\", b as char)))
.filter(|p| p.metadata().is_ok())
.collect()
}
fn load(root: &Path, hidden: bool) -> Vec<Entry> {
#[cfg(windows)]
if root.as_os_str().is_empty() {
return drives()
.into_iter()
.map(|path| Entry { path, is_dir: true })
.collect();
}
children(root, hidden)
}
fn parent_root(root: &Path) -> Option<PathBuf> {
if let Some(p) = root.parent() {
return Some(p.to_path_buf());
}
#[cfg(windows)]
if !root.as_os_str().is_empty() {
return Some(PathBuf::new());
}
None
}
pub fn up_listing(root: &Path, hidden: bool) -> Option<Vec<Entry>> {
parent_root(root).map(|p| load(&p, hidden))
}
pub struct Tree {
pub root: PathBuf,
pub entries: Vec<Entry>,
pub hidden: bool,
}
impl Tree {
pub fn new(root: PathBuf, hidden: bool) -> Self {
Tree {
entries: load(&root, hidden),
root,
hidden,
}
}
pub fn display_root(&self) -> std::borrow::Cow<'_, str> {
if self.root.as_os_str().is_empty() {
return "This PC".into();
}
self.root.to_string_lossy()
}
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn get(&self, i: usize) -> Option<&Entry> {
self.entries.get(i)
}
pub fn reload(&mut self) {
self.entries = load(&self.root, self.hidden);
}
pub fn up(&mut self) -> bool {
match parent_root(&self.root) {
Some(p) => {
self.root = p;
self.reload();
true
}
None => false,
}
}
pub fn down(&mut self, path: PathBuf) -> bool {
if !path.is_dir() {
return false;
}
self.root = path;
self.reload();
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn down_then_up_round_trips_the_root() {
let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut t = Tree::new(start.clone(), false);
let src = t
.entries
.iter()
.find(|e| e.is_dir)
.expect("src/ exists")
.path
.clone();
assert!(t.down(src.clone()));
assert_eq!(t.root, src);
assert!(t.up());
assert_eq!(t.root, start);
}
#[test]
fn down_into_a_file_is_a_no_op() {
let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut t = Tree::new(start.clone(), false);
let file = t
.entries
.iter()
.find(|e| !e.is_dir)
.expect("Cargo.toml exists")
.path
.clone();
assert!(!t.down(file));
assert_eq!(t.root, start);
}
#[test]
fn up_listing_shows_the_parent_without_moving() {
let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let listing = up_listing(&start, false).expect("has a parent");
assert!(listing.iter().any(|e| e.path == start));
}
#[test]
fn a_link_to_a_directory_counts_as_a_directory() {
let base = std::env::temp_dir().join(format!("cdt-tree-{}", std::process::id()));
let real = base.join("real");
std::fs::create_dir_all(&real).expect("temp dir");
let link = base.join("link");
#[cfg(windows)]
let made = std::os::windows::fs::symlink_dir(&real, &link).is_ok();
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&real, &link).is_ok();
if made {
let kids = children(&base, false);
let l = kids
.iter()
.find(|e| e.name() == "link")
.expect("the link is listed");
assert!(l.is_dir, "a link to a directory must expand like one");
}
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(windows)]
#[test]
fn up_crosses_from_a_drive_root_to_the_drives_level_and_then_stops() {
let mut t = Tree::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")), false);
while t.up() {} assert!(t.root.as_os_str().is_empty(), "{:?}", t.root);
assert!(!t.entries.is_empty(), "no drives listed");
assert!(t.entries.iter().all(|e| e.is_dir));
assert_eq!(t.display_root(), "This PC");
let cur = std::env::current_dir().unwrap();
let letter = cur.to_string_lossy().chars().next().unwrap();
let want = PathBuf::from(format!("{letter}:\\"));
assert!(
t.entries
.iter()
.any(|e| e.path == want && !e.name().is_empty()),
"{want:?} not in {:?}",
t.entries.iter().map(|e| &e.path).collect::<Vec<_>>()
);
assert!(!t.up(), "nothing above the drives level");
}
}