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    pub fn name(&self) -> std::borrow::Cow<'_, str> {
15        self.path.file_name().unwrap_or_default().to_string_lossy()
16    }
17}
18
19pub struct Tree {
20    pub root: PathBuf,
21    pub entries: Vec<Entry>,
22    pub hidden: bool,
23}
24
25/// Whether an entry should behave as a directory. `DirEntry::file_type` does
26/// not follow links, so a symlink or Windows junction pointing at a directory
27/// reports `is_dir = false`; left alone it would render as a leaf and refuse
28/// to expand. Only links pay the extra stat.
29fn is_dir(e: &std::fs::DirEntry) -> bool {
30    match e.file_type() {
31        Ok(t) if t.is_dir() => true,
32        Ok(t) if t.is_symlink() => e.path().is_dir(),
33        _ => false,
34    }
35}
36
37/// One directory's entries, directories first then files, case-insensitive.
38pub fn children(dir: &Path, hidden: bool, depth: usize) -> Vec<Entry> {
39    let mut v: Vec<Entry> = std::fs::read_dir(dir)
40        .into_iter()
41        .flatten()
42        .flatten()
43        .filter(|e| hidden || !e.file_name().to_string_lossy().starts_with('.'))
44        .map(|e| Entry {
45            is_dir: is_dir(&e),
46            path: e.path(),
47            depth,
48            open: false,
49        })
50        .collect();
51    v.sort_by_key(|e| (!e.is_dir, e.name().to_lowercase()));
52    v
53}
54
55/// Number of entries right after `i` that are nested under it.
56pub fn descendants(entries: &[Entry], i: usize) -> usize {
57    let d = entries[i].depth;
58    entries[i + 1..].iter().take_while(|e| e.depth > d).count()
59}
60
61impl Tree {
62    pub fn new(root: PathBuf, hidden: bool) -> Self {
63        Tree {
64            entries: children(&root, hidden, 0),
65            root,
66            hidden,
67        }
68    }
69
70    pub fn len(&self) -> usize {
71        self.entries.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.entries.is_empty()
76    }
77
78    pub fn get(&self, i: usize) -> Option<&Entry> {
79        self.entries.get(i)
80    }
81
82    pub fn reload(&mut self) {
83        self.entries = children(&self.root, self.hidden, 0);
84    }
85
86    /// Expand or collapse the directory at `i`. No-op on files.
87    pub fn toggle(&mut self, i: usize) {
88        let Some(e) = self.entries.get(i) else { return };
89        if !e.is_dir {
90            return;
91        }
92        if e.open {
93            let n = descendants(&self.entries, i);
94            self.entries.drain(i + 1..=i + n);
95            self.entries[i].open = false;
96        } else {
97            let kids = children(&e.path, self.hidden, e.depth + 1);
98            self.entries[i].open = true;
99            self.entries.splice(i + 1..i + 1, kids);
100        }
101    }
102
103    /// Index of the entry that `i` is nested under.
104    pub fn parent_of(&self, i: usize) -> Option<usize> {
105        let d = self.entries.get(i)?.depth;
106        if d == 0 {
107            return None;
108        }
109        self.entries[..i].iter().rposition(|e| e.depth < d)
110    }
111
112    /// Re-root one directory up. False at the filesystem root.
113    pub fn up(&mut self) -> bool {
114        let Some(parent) = self.root.parent().map(Path::to_path_buf) else {
115            return false;
116        };
117        self.root = parent;
118        self.reload();
119        true
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn e(depth: usize) -> Entry {
128        Entry {
129            path: PathBuf::from("x"),
130            depth,
131            is_dir: true,
132            open: false,
133        }
134    }
135
136    #[test]
137    fn descendants_counts_only_the_nested_run() {
138        let t = vec![e(0), e(1), e(2), e(1), e(0)];
139        assert_eq!(descendants(&t, 0), 3);
140        assert_eq!(descendants(&t, 1), 1);
141        assert_eq!(descendants(&t, 2), 0);
142        assert_eq!(descendants(&t, 4), 0);
143    }
144
145    #[test]
146    fn expand_then_collapse_restores_the_entry_list() {
147        // Own crate dir: contains src/ (a dir) and Cargo.toml.
148        let mut t = Tree::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")), false);
149        let before = t.len();
150        let i = t
151            .entries
152            .iter()
153            .position(|e| e.is_dir)
154            .expect("src/ exists");
155        t.toggle(i);
156        assert!(t.len() > before && t.entries[i].open);
157        t.toggle(i);
158        assert_eq!(t.len(), before);
159        assert!(!t.entries[i].open);
160    }
161
162    #[test]
163    fn a_link_to_a_directory_counts_as_a_directory() {
164        let base = std::env::temp_dir().join(format!("cdt-tree-{}", std::process::id()));
165        let real = base.join("real");
166        std::fs::create_dir_all(&real).expect("temp dir");
167        let link = base.join("link");
168
169        #[cfg(windows)]
170        let made = std::os::windows::fs::symlink_dir(&real, &link).is_ok();
171        #[cfg(unix)]
172        let made = std::os::unix::fs::symlink(&real, &link).is_ok();
173
174        // Creating a symlink needs admin or Developer Mode on Windows; skip
175        // there rather than failing the suite, as the rg tests do.
176        if made {
177            let kids = children(&base, false, 0);
178            let l = kids
179                .iter()
180                .find(|e| e.name() == "link")
181                .expect("the link is listed");
182            assert!(l.is_dir, "a link to a directory must expand like one");
183        }
184        let _ = std::fs::remove_dir_all(&base);
185    }
186
187    #[test]
188    fn parent_of_walks_out_one_level() {
189        let t = Tree {
190            root: PathBuf::new(),
191            hidden: false,
192            entries: vec![e(0), e(1), e(2)],
193        };
194        assert_eq!(t.parent_of(0), None);
195        assert_eq!(t.parent_of(2), Some(1));
196    }
197}