1use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::fs::{DirEntry, Fs};
14use crate::paths::Pather;
15use crate::Result;
16
17#[derive(Debug, Clone, Serialize)]
21pub struct TreeNode {
22 pub name: String,
25 pub path: PathBuf,
27 pub kind: &'static str,
30 #[serde(skip_serializing_if = "Option::is_none")]
33 pub size: Option<u64>,
34 #[serde(skip_serializing_if = "Option::is_none")]
37 pub link_target: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub truncated_count: Option<usize>,
41 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub children: Vec<TreeNode>,
44}
45
46pub 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 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 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 if remaining_depth == 0 {
112 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
149fn directory_order(entry: &DirEntry) -> u8 {
152 if entry.is_dir {
153 0
154 } else {
155 1
156 }
157}
158
159impl TreeNode {
160 pub fn count_nodes(&self) -> usize {
162 1 + self.children.iter().map(Self::count_nodes).sum::<usize>()
163 }
164
165 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 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 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 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 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 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(); env.fs
295 .write_file(&env.data_dir.join("b"), b"hello")
296 .unwrap(); let root = collect_data_dir_tree(env.fs.as_ref(), env.paths.as_ref(), 1).unwrap();
299 assert_eq!(root.count_nodes(), 3); assert_eq!(root.total_size(), 7);
301 }
302}