Skip to main content

cdt_tree/
lib.rs

1//! Directory tree model: a flat `Vec<Entry>` where nesting is held in `depth`.
2//! Expanding splices children in, collapsing drains the nested run back out.
3use 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    /// A drive root (`C:\`) has no file name, so it would otherwise render as
15    /// a blank row; show the path itself for anything like that.
16    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
30/// Whether an entry should behave as a directory. `DirEntry::file_type` does
31/// not follow links, so a symlink or Windows junction pointing at a directory
32/// reports `is_dir = false`; left alone it would render as a leaf and refuse
33/// to expand. Only links pay the extra stat.
34fn 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
42/// One directory's entries, directories first then files, case-insensitive.
43pub 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/// The drive letters that exist, as `C:\`-style roots. std has no API for
61/// this, so each letter is probed; cheap enough since it only runs when
62/// stepping up from a drive root. Windows only — other platforms have one
63/// root (`/`) already, nothing to list.
64#[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
72/// `root`'s entries — real directory listing, except the empty path, which
73/// [`Tree::up`] uses on Windows as the synthetic level above any one drive.
74fn 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
90/// Number of entries right after `i` that are nested under it.
91pub 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    /// `root`, or — on the synthetic drives level — a label for it, since an
106    /// empty path would otherwise render as a blank title bar.
107    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    /// Expand or collapse the directory at `i`. No-op on files.
131    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    /// Index of the entry that `i` is nested under.
148    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    /// Re-root one directory up. At a drive root on Windows, steps out to the
157    /// synthetic drives level ([`load`]) instead of stopping, since `C:\` has
158    /// no parent the way nested directories do. False once there is truly
159    /// nothing above (the drives level itself, or `/` on other platforms).
160    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        // Own crate dir: contains src/ (a dir) and Cargo.toml.
201        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        // Creating a symlink needs admin or Developer Mode on Windows; skip
228        // there rather than failing the suite, as the rg tests do.
229        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    /// The core contract this module exists for: Windows has no single root,
252    /// so stepping up from a drive has to surface the other drives instead of
253    /// just stopping, and stop for real once that level is reached.
254    #[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() {} // walk out past every ancestor directory
259        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        // The current drive must be among them, named without a blank row.
265        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}