Skip to main content

dodot_lib/probe/
data_dir_tree.rs

1//! Tree walk of `<data_dir>` for `dodot probe show-data-dir`.
2//!
3//! The walk is bounded: directories beyond `max_depth` are summarised
4//! as a single child with the entry count, so the output stays readable
5//! on installs with large pack sets. Symlinks are never followed — we
6//! report the link target as a string instead, which is what the user
7//! debugging "where did dodot put X?" wants to see.
8
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::fs::{DirEntry, Fs};
14use crate::paths::Pather;
15use crate::Result;
16
17/// One node in the data-dir tree. Directories have children; files have
18/// a size; symlinks carry the resolved target path so the renderer can
19/// show `→ /…`.
20#[derive(Debug, Clone, Serialize)]
21pub struct TreeNode {
22    /// Display name — the file/dir basename for everything except the
23    /// root, which uses the full data_dir path.
24    pub name: String,
25    /// Absolute path to this node.
26    pub path: PathBuf,
27    /// `"dir"`, `"file"`, `"symlink"`, or `"truncated"`. The renderer
28    /// picks an icon based on this.
29    pub kind: &'static str,
30    /// Size in bytes for files (via `lstat`, so symlinks report the
31    /// link-entry size, not the target's).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub size: Option<u64>,
34    /// Symlink target as a plain string (not resolved). None for
35    /// non-symlinks.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub link_target: Option<String>,
38    /// For `kind == "truncated"`, the number of children not expanded.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub truncated_count: Option<usize>,
41    /// Children of a directory. Empty for files/symlinks.
42    #[serde(skip_serializing_if = "Vec::is_empty", default)]
43    pub children: Vec<TreeNode>,
44}
45
46/// Walk `<data_dir>` to `max_depth` levels deep and return a tree of
47/// [`TreeNode`]s rooted at the data directory.
48///
49/// `max_depth = 0` returns just the root with no children. `max_depth = 1`
50/// shows immediate children of data_dir but doesn't recurse into them,
51/// and so on. A reasonable default for display is 4 (enough to show
52/// `packs / <pack> / <handler> / <entry>`).
53pub fn collect_data_dir_tree(
54    fs: &dyn Fs,
55    paths: &dyn Pather,
56    max_depth: usize,
57) -> Result<TreeNode> {
58    let root = paths.data_dir().to_path_buf();
59    walk(fs, &root, &root_name(&root), max_depth)
60}
61
62fn root_name(root: &Path) -> String {
63    root.display().to_string()
64}
65
66fn walk(fs: &dyn Fs, path: &Path, display_name: &str, remaining_depth: usize) -> Result<TreeNode> {
67    // The root may not exist on a fresh install. Represent it as an
68    // empty directory rather than erroring — `probe show-data-dir` on a
69    // brand-new system should print "empty", not fail.
70    if !fs.exists(path) {
71        return Ok(TreeNode {
72            name: display_name.to_string(),
73            path: path.to_path_buf(),
74            kind: "dir",
75            size: None,
76            link_target: None,
77            truncated_count: None,
78            children: Vec::new(),
79        });
80    }
81
82    // Classify via lstat so symlinks are never followed.
83    let meta = fs.lstat(path)?;
84
85    if meta.is_symlink {
86        let target = fs.readlink(path).ok().map(|p| p.display().to_string());
87        return Ok(TreeNode {
88            name: display_name.to_string(),
89            path: path.to_path_buf(),
90            kind: "symlink",
91            size: Some(meta.len),
92            link_target: target,
93            truncated_count: None,
94            children: Vec::new(),
95        });
96    }
97
98    if !meta.is_dir {
99        return Ok(TreeNode {
100            name: display_name.to_string(),
101            path: path.to_path_buf(),
102            kind: "file",
103            size: Some(meta.len),
104            link_target: None,
105            truncated_count: None,
106            children: Vec::new(),
107        });
108    }
109
110    // Directory.
111    if remaining_depth == 0 {
112        // Report how many entries are hidden so the user knows the
113        // subtree wasn't empty.
114        let count = fs.read_dir(path).map(|v| v.len()).unwrap_or(0);
115        return Ok(TreeNode {
116            name: display_name.to_string(),
117            path: path.to_path_buf(),
118            kind: "dir",
119            size: None,
120            link_target: None,
121            truncated_count: if count > 0 { Some(count) } else { None },
122            children: Vec::new(),
123        });
124    }
125
126    let mut entries = fs.read_dir(path)?;
127    entries.sort_by(|a, b| {
128        directory_order(a)
129            .cmp(&directory_order(b))
130            .then(a.name.cmp(&b.name))
131    });
132
133    let mut children = Vec::with_capacity(entries.len());
134    for entry in entries {
135        children.push(walk(fs, &entry.path, &entry.name, remaining_depth - 1)?);
136    }
137
138    Ok(TreeNode {
139        name: display_name.to_string(),
140        path: path.to_path_buf(),
141        kind: "dir",
142        size: None,
143        link_target: None,
144        truncated_count: None,
145        children,
146    })
147}
148
149/// Sort key: directories before files, both sorted by name afterwards.
150/// Keeps the tree output visually grouped (all subdirs on top).
151fn directory_order(entry: &DirEntry) -> u8 {
152    if entry.is_dir {
153        0
154    } else {
155        1
156    }
157}
158
159impl TreeNode {
160    /// Count nodes in the subtree rooted here (including self).
161    pub fn count_nodes(&self) -> usize {
162        1 + self.children.iter().map(Self::count_nodes).sum::<usize>()
163    }
164
165    /// Total file size (symlinks counted by their link-entry size).
166    pub fn total_size(&self) -> u64 {
167        self.size.unwrap_or(0) + self.children.iter().map(Self::total_size).sum::<u64>()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::testing::TempEnvironment;
175
176    #[test]
177    fn missing_data_dir_returns_empty_root() {
178        let env = TempEnvironment::builder().build();
179        // Remove the data dir to simulate a fresh install.
180        env.fs.remove_dir_all(&env.data_dir).unwrap();
181
182        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 4).unwrap();
183        assert_eq!(root.kind, "dir");
184        assert!(root.children.is_empty());
185    }
186
187    #[test]
188    fn depth_zero_returns_root_only_with_truncated_count() {
189        let env = TempEnvironment::builder().build();
190        // Create a couple of files under data_dir so there's something to truncate.
191        env.fs
192            .write_file(&env.data_dir.join("a.txt"), b"hi")
193            .unwrap();
194        env.fs
195            .write_file(&env.data_dir.join("b.txt"), b"hi")
196            .unwrap();
197
198        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
199        assert_eq!(root.kind, "dir");
200        assert!(root.children.is_empty());
201        assert!(root.truncated_count.unwrap() >= 2);
202    }
203
204    #[test]
205    fn files_report_size() {
206        let env = TempEnvironment::builder().build();
207        env.fs
208            .write_file(&env.data_dir.join("hello.txt"), b"hello world")
209            .unwrap();
210
211        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 1).unwrap();
212        let hello = root
213            .children
214            .iter()
215            .find(|c| c.name == "hello.txt")
216            .expect("hello.txt node");
217        assert_eq!(hello.kind, "file");
218        assert_eq!(hello.size, Some(11));
219    }
220
221    #[test]
222    fn symlinks_carry_target_and_are_not_followed() {
223        let env = TempEnvironment::builder().build();
224        let target = env.home.join("real.txt");
225        env.fs.write_file(&target, b"xx").unwrap();
226        env.fs
227            .symlink(&target, &env.data_dir.join("link.txt"))
228            .unwrap();
229
230        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 1).unwrap();
231        let link = root
232            .children
233            .iter()
234            .find(|c| c.name == "link.txt")
235            .expect("link.txt node");
236        assert_eq!(link.kind, "symlink");
237        assert_eq!(link.link_target.as_deref(), Some(target.to_str().unwrap()));
238    }
239
240    #[test]
241    fn directories_before_files_then_alphabetical() {
242        let env = TempEnvironment::builder().build();
243        env.fs.mkdir_all(&env.data_dir.join("packs")).unwrap();
244        env.fs.mkdir_all(&env.data_dir.join("shell")).unwrap();
245        env.fs
246            .write_file(&env.data_dir.join("deployment-map.tsv"), b"x")
247            .unwrap();
248        env.fs
249            .write_file(&env.data_dir.join("zzz.txt"), b"x")
250            .unwrap();
251
252        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 1).unwrap();
253        let names: Vec<&str> = root.children.iter().map(|c| c.name.as_str()).collect();
254        // packs, shell (dirs, alphabetical), then deployment-map.tsv, zzz.txt (files).
255        assert_eq!(
256            names,
257            vec!["packs", "shell", "deployment-map.tsv", "zzz.txt"]
258        );
259    }
260
261    #[test]
262    fn deep_tree_truncates_at_max_depth() {
263        let env = TempEnvironment::builder().build();
264        let deep = env.data_dir.join("packs").join("vim").join("shell");
265        env.fs.mkdir_all(&deep).unwrap();
266        env.fs.write_file(&deep.join("aliases.sh"), b"x").unwrap();
267
268        // Depth 2: root -> packs -> vim (truncated, not expanded).
269        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
270        let packs = root
271            .children
272            .iter()
273            .find(|c| c.name == "packs")
274            .expect("packs node");
275        let vim = packs
276            .children
277            .iter()
278            .find(|c| c.name == "vim")
279            .expect("vim node");
280        assert!(vim.children.is_empty(), "vim should be a truncation leaf");
281        assert_eq!(vim.truncated_count, Some(1));
282    }
283
284    #[test]
285    fn count_and_total_size_helpers_agree() {
286        let env = TempEnvironment::builder().build();
287        // Start from a clean data_dir so we control the exact shape.
288        // (TempEnvironment pre-creates `shell/` and `packs/` — fine for
289        // realism, but confuses exact-count assertions.)
290        env.fs.remove_dir_all(&env.data_dir).unwrap();
291        env.fs.mkdir_all(&env.data_dir).unwrap();
292
293        env.fs.write_file(&env.data_dir.join("a"), b"hi").unwrap(); // 2 bytes
294        env.fs
295            .write_file(&env.data_dir.join("b"), b"hello")
296            .unwrap(); // 5 bytes
297
298        let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 1).unwrap();
299        assert_eq!(root.count_nodes(), 3); // root + 2 children
300        assert_eq!(root.total_size(), 7);
301    }
302}