1use std::path::{Path, PathBuf};
4
5#[derive(Debug)]
6pub struct Entry {
7 pub path: PathBuf,
8 pub depth: usize,
9 pub is_dir: bool,
10 pub open: bool,
11}
12
13impl Entry {
14 pub fn name(&self) -> std::borrow::Cow<'_, str> {
17 match self.path.file_name() {
18 Some(n) => n.to_string_lossy(),
19 None => self.path.to_string_lossy(),
20 }
21 }
22}
23
24pub struct Tree {
25 pub root: PathBuf,
26 pub entries: Vec<Entry>,
27 pub hidden: bool,
28}
29
30fn is_dir(e: &std::fs::DirEntry) -> bool {
35 match e.file_type() {
36 Ok(t) if t.is_dir() => true,
37 Ok(t) if t.is_symlink() => e.path().is_dir(),
38 _ => false,
39 }
40}
41
42pub fn children(dir: &Path, hidden: bool, depth: usize) -> Vec<Entry> {
44 let mut v: Vec<Entry> = std::fs::read_dir(dir)
45 .into_iter()
46 .flatten()
47 .flatten()
48 .filter(|e| hidden || !e.file_name().to_string_lossy().starts_with('.'))
49 .map(|e| Entry {
50 is_dir: is_dir(&e),
51 path: e.path(),
52 depth,
53 open: false,
54 })
55 .collect();
56 v.sort_by_key(|e| (!e.is_dir, e.name().to_lowercase()));
57 v
58}
59
60#[cfg(windows)]
65fn drives() -> Vec<PathBuf> {
66 (b'A'..=b'Z')
67 .map(|b| PathBuf::from(format!("{}:\\", b as char)))
68 .filter(|p| p.metadata().is_ok())
69 .collect()
70}
71
72fn load(root: &Path, hidden: bool, depth: usize) -> Vec<Entry> {
75 #[cfg(windows)]
76 if root.as_os_str().is_empty() {
77 return drives()
78 .into_iter()
79 .map(|path| Entry {
80 path,
81 depth,
82 is_dir: true,
83 open: false,
84 })
85 .collect();
86 }
87 children(root, hidden, depth)
88}
89
90pub fn descendants(entries: &[Entry], i: usize) -> usize {
92 let d = entries[i].depth;
93 entries[i + 1..].iter().take_while(|e| e.depth > d).count()
94}
95
96impl Tree {
97 pub fn new(root: PathBuf, hidden: bool) -> Self {
98 Tree {
99 entries: load(&root, hidden, 0),
100 root,
101 hidden,
102 }
103 }
104
105 pub fn display_root(&self) -> std::borrow::Cow<'_, str> {
108 if self.root.as_os_str().is_empty() {
109 return "This PC".into();
110 }
111 self.root.to_string_lossy()
112 }
113
114 pub fn len(&self) -> usize {
115 self.entries.len()
116 }
117
118 pub fn is_empty(&self) -> bool {
119 self.entries.is_empty()
120 }
121
122 pub fn get(&self, i: usize) -> Option<&Entry> {
123 self.entries.get(i)
124 }
125
126 pub fn reload(&mut self) {
127 self.entries = load(&self.root, self.hidden, 0);
128 }
129
130 pub fn toggle(&mut self, i: usize) {
132 let Some(e) = self.entries.get(i) else { return };
133 if !e.is_dir {
134 return;
135 }
136 if e.open {
137 let n = descendants(&self.entries, i);
138 self.entries.drain(i + 1..=i + n);
139 self.entries[i].open = false;
140 } else {
141 let kids = children(&e.path, self.hidden, e.depth + 1);
142 self.entries[i].open = true;
143 self.entries.splice(i + 1..i + 1, kids);
144 }
145 }
146
147 pub fn parent_of(&self, i: usize) -> Option<usize> {
149 let d = self.entries.get(i)?.depth;
150 if d == 0 {
151 return None;
152 }
153 self.entries[..i].iter().rposition(|e| e.depth < d)
154 }
155
156 pub fn up(&mut self) -> bool {
161 if let Some(parent) = self.root.parent() {
162 self.root = parent.to_path_buf();
163 self.reload();
164 return true;
165 }
166 #[cfg(windows)]
167 if !self.root.as_os_str().is_empty() {
168 self.root = PathBuf::new();
169 self.reload();
170 return true;
171 }
172 false
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn e(depth: usize) -> Entry {
181 Entry {
182 path: PathBuf::from("x"),
183 depth,
184 is_dir: true,
185 open: false,
186 }
187 }
188
189 #[test]
190 fn descendants_counts_only_the_nested_run() {
191 let t = vec![e(0), e(1), e(2), e(1), e(0)];
192 assert_eq!(descendants(&t, 0), 3);
193 assert_eq!(descendants(&t, 1), 1);
194 assert_eq!(descendants(&t, 2), 0);
195 assert_eq!(descendants(&t, 4), 0);
196 }
197
198 #[test]
199 fn expand_then_collapse_restores_the_entry_list() {
200 let mut t = Tree::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")), false);
202 let before = t.len();
203 let i = t
204 .entries
205 .iter()
206 .position(|e| e.is_dir)
207 .expect("src/ exists");
208 t.toggle(i);
209 assert!(t.len() > before && t.entries[i].open);
210 t.toggle(i);
211 assert_eq!(t.len(), before);
212 assert!(!t.entries[i].open);
213 }
214
215 #[test]
216 fn a_link_to_a_directory_counts_as_a_directory() {
217 let base = std::env::temp_dir().join(format!("cdt-tree-{}", std::process::id()));
218 let real = base.join("real");
219 std::fs::create_dir_all(&real).expect("temp dir");
220 let link = base.join("link");
221
222 #[cfg(windows)]
223 let made = std::os::windows::fs::symlink_dir(&real, &link).is_ok();
224 #[cfg(unix)]
225 let made = std::os::unix::fs::symlink(&real, &link).is_ok();
226
227 if made {
230 let kids = children(&base, false, 0);
231 let l = kids
232 .iter()
233 .find(|e| e.name() == "link")
234 .expect("the link is listed");
235 assert!(l.is_dir, "a link to a directory must expand like one");
236 }
237 let _ = std::fs::remove_dir_all(&base);
238 }
239
240 #[test]
241 fn parent_of_walks_out_one_level() {
242 let t = Tree {
243 root: PathBuf::new(),
244 hidden: false,
245 entries: vec![e(0), e(1), e(2)],
246 };
247 assert_eq!(t.parent_of(0), None);
248 assert_eq!(t.parent_of(2), Some(1));
249 }
250
251 #[cfg(windows)]
255 #[test]
256 fn up_crosses_from_a_drive_root_to_the_drives_level_and_then_stops() {
257 let mut t = Tree::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")), false);
258 while t.up() {} assert!(t.root.as_os_str().is_empty(), "{:?}", t.root);
260 assert!(!t.entries.is_empty(), "no drives listed");
261 assert!(t.entries.iter().all(|e| e.is_dir));
262 assert_eq!(t.display_root(), "This PC");
263
264 let cur = std::env::current_dir().unwrap();
266 let letter = cur.to_string_lossy().chars().next().unwrap();
267 let want = PathBuf::from(format!("{letter}:\\"));
268 assert!(
269 t.entries
270 .iter()
271 .any(|e| e.path == want && !e.name().is_empty()),
272 "{want:?} not in {:?}",
273 t.entries.iter().map(|e| &e.path).collect::<Vec<_>>()
274 );
275
276 assert!(!t.up(), "nothing above the drives level");
277 }
278}