Skip to main content

cdt_tree/
lib.rs

1//! Directory listing + up/down navigation for a miller-column browser: each
2//! pane is just one directory's children, and `Tree` tracks the one currently
3//! browsed.
4use 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    /// A drive root (`C:\`) has no file name, so it would otherwise render as
14    /// a blank row; show the path itself for anything like that.
15    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
23/// Whether an entry should behave as a directory. `DirEntry::file_type` does
24/// not follow links, so a symlink or Windows junction pointing at a directory
25/// reports `is_dir = false`; left alone it would render as a leaf and refuse
26/// to be entered. Only links pay the extra stat.
27fn 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
35/// One directory's entries, directories first then files, case-insensitive.
36pub 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/// The drive letters that exist, as `C:\`-style roots. std has no API for
52/// this, so each letter is probed; cheap enough since it only runs when
53/// stepping up from a drive root. Windows only — other platforms have one
54/// root (`/`) already, nothing to list.
55#[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
63/// `root`'s entries — real directory listing, except the empty path, which
64/// stands for the synthetic level above any one drive on Windows.
65fn 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
76/// What `root`'s parent directory is, by the same rule [`Tree::up`] uses: a
77/// drive root on Windows steps out to the synthetic drives level instead of
78/// stopping, since `C:\` has no parent the way nested directories do.
79fn 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
90/// What the parent pane shows for `root`, without moving into it. `None` once
91/// there is truly nothing above (e.g. `/` on non-Windows, or the drives level
92/// itself on Windows).
93pub 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    /// `root`, or — on the synthetic drives level — a label for it, since an
113    /// empty path would otherwise render as a blank title bar.
114    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    // No `is_empty`: nothing in this codebase checks a Tree for emptiness,
122    // only its length (for `sel` bounds), so the pair convention clippy wants
123    // would just be dead code.
124    #[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    /// Re-root one directory up. False once there is truly nothing above.
138    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    /// Re-root into `path`. False (no-op) if it is not a directory.
150    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        // Creating a symlink needs admin or Developer Mode on Windows; skip
219        // there rather than failing the suite, as the rg tests do.
220        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    /// The core contract this module exists for: Windows has no single root,
232    /// so stepping up from a drive has to surface the other drives instead of
233    /// just stopping, and stop for real once that level is reached.
234    #[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() {} // walk out past every ancestor directory
239        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        // The current drive must be among them, named without a blank row.
245        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}