1use std::path::{Path, PathBuf};
5
6#[derive(Debug)]
7pub struct Entry {
8 pub path: PathBuf,
9 pub is_dir: bool,
10}
11
12impl Entry {
13 pub fn name(&self) -> std::borrow::Cow<'_, str> {
16 match self.path.file_name() {
17 Some(n) => n.to_string_lossy(),
18 None => self.path.to_string_lossy(),
19 }
20 }
21}
22
23fn is_dir(e: &std::fs::DirEntry) -> bool {
28 match e.file_type() {
29 Ok(t) if t.is_dir() => true,
30 Ok(t) if t.is_symlink() => e.path().is_dir(),
31 _ => false,
32 }
33}
34
35pub fn children(dir: &Path, hidden: bool) -> Vec<Entry> {
37 let mut v: Vec<Entry> = std::fs::read_dir(dir)
38 .into_iter()
39 .flatten()
40 .flatten()
41 .filter(|e| hidden || !e.file_name().to_string_lossy().starts_with('.'))
42 .map(|e| Entry {
43 is_dir: is_dir(&e),
44 path: e.path(),
45 })
46 .collect();
47 v.sort_by_key(|e| (!e.is_dir, e.name().to_lowercase()));
48 v
49}
50
51#[cfg(windows)]
56fn drives() -> Vec<PathBuf> {
57 (b'A'..=b'Z')
58 .map(|b| PathBuf::from(format!("{}:\\", b as char)))
59 .filter(|p| p.metadata().is_ok())
60 .collect()
61}
62
63fn load(root: &Path, hidden: bool) -> Vec<Entry> {
66 #[cfg(windows)]
67 if root.as_os_str().is_empty() {
68 return drives()
69 .into_iter()
70 .map(|path| Entry { path, is_dir: true })
71 .collect();
72 }
73 children(root, hidden)
74}
75
76fn parent_root(root: &Path) -> Option<PathBuf> {
80 if let Some(p) = root.parent() {
81 return Some(p.to_path_buf());
82 }
83 #[cfg(windows)]
84 if !root.as_os_str().is_empty() {
85 return Some(PathBuf::new());
86 }
87 None
88}
89
90pub fn up_listing(root: &Path, hidden: bool) -> Option<Vec<Entry>> {
94 parent_root(root).map(|p| load(&p, hidden))
95}
96
97pub struct Tree {
98 pub root: PathBuf,
99 pub entries: Vec<Entry>,
100 pub hidden: bool,
101}
102
103impl Tree {
104 pub fn new(root: PathBuf, hidden: bool) -> Self {
105 Tree {
106 entries: load(&root, hidden),
107 root,
108 hidden,
109 }
110 }
111
112 pub fn display_root(&self) -> std::borrow::Cow<'_, str> {
115 if self.root.as_os_str().is_empty() {
116 return "This PC".into();
117 }
118 self.root.to_string_lossy()
119 }
120
121 #[allow(clippy::len_without_is_empty)]
125 pub fn len(&self) -> usize {
126 self.entries.len()
127 }
128
129 pub fn get(&self, i: usize) -> Option<&Entry> {
130 self.entries.get(i)
131 }
132
133 pub fn reload(&mut self) {
134 self.entries = load(&self.root, self.hidden);
135 }
136
137 pub fn up(&mut self) -> bool {
139 match parent_root(&self.root) {
140 Some(p) => {
141 self.root = p;
142 self.reload();
143 true
144 }
145 None => false,
146 }
147 }
148
149 pub fn down(&mut self, path: PathBuf) -> bool {
151 if !path.is_dir() {
152 return false;
153 }
154 self.root = path;
155 self.reload();
156 true
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn down_then_up_round_trips_the_root() {
166 let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
167 let mut t = Tree::new(start.clone(), false);
168 let src = t
169 .entries
170 .iter()
171 .find(|e| e.is_dir)
172 .expect("src/ exists")
173 .path
174 .clone();
175
176 assert!(t.down(src.clone()));
177 assert_eq!(t.root, src);
178
179 assert!(t.up());
180 assert_eq!(t.root, start);
181 }
182
183 #[test]
184 fn down_into_a_file_is_a_no_op() {
185 let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
186 let mut t = Tree::new(start.clone(), false);
187 let file = t
188 .entries
189 .iter()
190 .find(|e| !e.is_dir)
191 .expect("Cargo.toml exists")
192 .path
193 .clone();
194
195 assert!(!t.down(file));
196 assert_eq!(t.root, start);
197 }
198
199 #[test]
200 fn up_listing_shows_the_parent_without_moving() {
201 let start = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
202 let listing = up_listing(&start, false).expect("has a parent");
203 assert!(listing.iter().any(|e| e.path == start));
204 }
205
206 #[test]
207 fn a_link_to_a_directory_counts_as_a_directory() {
208 let base = std::env::temp_dir().join(format!("cdt-tree-{}", std::process::id()));
209 let real = base.join("real");
210 std::fs::create_dir_all(&real).expect("temp dir");
211 let link = base.join("link");
212
213 #[cfg(windows)]
214 let made = std::os::windows::fs::symlink_dir(&real, &link).is_ok();
215 #[cfg(unix)]
216 let made = std::os::unix::fs::symlink(&real, &link).is_ok();
217
218 if made {
221 let kids = children(&base, false);
222 let l = kids
223 .iter()
224 .find(|e| e.name() == "link")
225 .expect("the link is listed");
226 assert!(l.is_dir, "a link to a directory must expand like one");
227 }
228 let _ = std::fs::remove_dir_all(&base);
229 }
230
231 #[cfg(windows)]
235 #[test]
236 fn up_crosses_from_a_drive_root_to_the_drives_level_and_then_stops() {
237 let mut t = Tree::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")), false);
238 while t.up() {} assert!(t.root.as_os_str().is_empty(), "{:?}", t.root);
240 assert!(!t.entries.is_empty(), "no drives listed");
241 assert!(t.entries.iter().all(|e| e.is_dir));
242 assert_eq!(t.display_root(), "This PC");
243
244 let cur = std::env::current_dir().unwrap();
246 let letter = cur.to_string_lossy().chars().next().unwrap();
247 let want = PathBuf::from(format!("{letter}:\\"));
248 assert!(
249 t.entries
250 .iter()
251 .any(|e| e.path == want && !e.name().is_empty()),
252 "{want:?} not in {:?}",
253 t.entries.iter().map(|e| &e.path).collect::<Vec<_>>()
254 );
255
256 assert!(!t.up(), "nothing above the drives level");
257 }
258}