1use ignore::overrides::OverrideBuilder;
7use ignore::WalkBuilder;
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::LazyLock;
11
12const DEFAULT_SKIP_DIRS: &[&str] = &[
13 ".git",
14 "node_modules",
15 "__pycache__",
16 ".tox",
17 ".mypy_cache",
18 ".pytest_cache",
19 "dist",
20 "build",
21 ".eggs",
22 "venv",
23 ".venv",
24 "target",
25 ".cargo",
26 ".ruff_cache",
27];
28
29static DEFAULT_SKIP_SET: LazyLock<HashSet<&'static str>> =
30 LazyLock::new(|| DEFAULT_SKIP_DIRS.iter().copied().collect());
31
32struct Entry {
34 name: String,
35 full_path: PathBuf,
36 is_dir: bool,
37 size: u64,
38 children: BTreeMap<String, Entry>,
39}
40
41impl Entry {
42 fn new_dir(name: String, full_path: PathBuf) -> Self {
43 Self {
44 name,
45 full_path,
46 is_dir: true,
47 size: 0,
48 children: BTreeMap::new(),
49 }
50 }
51
52 fn new_file(name: String, full_path: PathBuf, size: u64) -> Self {
53 Self {
54 name,
55 full_path,
56 is_dir: false,
57 size,
58 children: BTreeMap::new(),
59 }
60 }
61}
62
63#[derive(Default)]
65pub struct ListDirOpts<'a> {
66 pub depth: Option<usize>,
68 pub glob: Option<&'a str>,
69 pub dirs_only: bool,
70 pub relative_to: Option<&'a str>,
71 pub respect_gitignore: bool,
73 pub skip_dirs: Option<&'a [String]>,
76 pub include_size: bool,
77 pub annotate: Option<&'a AnnotateFn>,
82}
83
84pub type AnnotateFn = dyn Fn(&str) -> Option<String>;
86
87pub fn list_dir(path: &str, opts: &ListDirOpts) -> Result<String, String> {
89 let root = PathBuf::from(path)
90 .canonicalize()
91 .map_err(|e| format!("Cannot resolve '{}': {}", path, e))?;
92 if !root.is_dir() {
93 return Ok(format!("Error: '{}' is not a directory.", path));
94 }
95
96 let depth = opts.depth.unwrap_or(1);
97 let respect_gitignore = opts.respect_gitignore;
98 let glob = opts.glob;
99 let relative_to = opts.relative_to;
100 let dirs_only = opts.dirs_only;
101 let include_size = opts.include_size;
102
103 let custom_skip: Option<HashSet<String>> =
104 opts.skip_dirs.map(|dirs| dirs.iter().cloned().collect());
105
106 let mut tree = Entry::new_dir(dir_display_name(&root, relative_to), root.clone());
107 let mut leaf_counts: BTreeMap<PathBuf, (usize, usize)> = BTreeMap::new();
108
109 {
110 let mut builder = WalkBuilder::new(&root);
111 builder.max_depth(Some(depth + 1));
112 builder.hidden(false);
113 builder.git_ignore(respect_gitignore);
114 builder.git_global(respect_gitignore);
115 builder.git_exclude(respect_gitignore);
116
117 if let Some(glob_pat) = glob {
118 let mut overrides = OverrideBuilder::new(&root);
119 overrides.add("*/").map_err(|e| format!("{}", e))?;
120 overrides.add(glob_pat).map_err(|e| format!("{}", e))?;
121 let built = overrides.build().map_err(|e| format!("{}", e))?;
122 builder.overrides(built);
123 }
124
125 builder.filter_entry(move |entry| {
126 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
127 if let Some(name) = entry.file_name().to_str() {
128 return match &custom_skip {
129 Some(set) => !set.contains(name),
130 None => !DEFAULT_SKIP_SET.contains(name),
131 };
132 }
133 }
134 true
135 });
136
137 for entry in builder.build().flatten() {
138 let entry_path = entry.path().to_path_buf();
139 if entry_path == root {
140 continue;
141 }
142 let rel = match entry_path.strip_prefix(&root) {
143 Ok(r) => r,
144 Err(_) => continue,
145 };
146 let comp_count = rel.components().count();
147 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
148
149 if comp_count <= depth {
150 if dirs_only && !is_dir {
152 continue;
153 }
154 let components: Vec<String> = rel
155 .components()
156 .map(|c| c.as_os_str().to_string_lossy().to_string())
157 .collect();
158 let size = if include_size && !is_dir {
159 entry.metadata().map(|m| m.len()).unwrap_or(0)
160 } else {
161 0
162 };
163 insert_entry(&mut tree, &components, is_dir, size, &entry_path);
164 } else {
165 if let Some(parent) = entry_path.parent() {
167 let counter = leaf_counts.entry(parent.to_path_buf()).or_insert((0, 0));
168 if is_dir {
169 counter.0 += 1;
170 } else {
171 counter.1 += 1;
172 }
173 }
174 }
175 }
176 }
177
178 if glob.is_some() && !dirs_only {
180 prune_empty_dirs(&mut tree);
181 }
182
183 if tree.children.is_empty() {
184 return Ok(format!("{}/ (empty)", tree.name));
185 }
186
187 let annotations = if let Some(annotate_fn) = opts.annotate {
189 let mut map = HashMap::new();
190 let base = relative_to
194 .and_then(|r| PathBuf::from(r).canonicalize().ok())
195 .unwrap_or_else(|| root.clone());
196 collect_annotations(&tree, &base, annotate_fn, &mut map);
197 map
198 } else {
199 HashMap::new()
200 };
201
202 let mut output = Vec::new();
203 output.push(format!("{}/", tree.name));
204 render_tree(
205 &tree,
206 "",
207 &mut output,
208 include_size,
209 &leaf_counts,
210 &annotations,
211 );
212 Ok(output.join("\n"))
213}
214
215fn collect_annotations(
217 entry: &Entry,
218 base: &Path,
219 annotate_fn: &AnnotateFn,
220 map: &mut HashMap<PathBuf, String>,
221) {
222 for child in entry.children.values() {
223 let rel_path = child
224 .full_path
225 .strip_prefix(base)
226 .unwrap_or(&child.full_path)
227 .to_string_lossy()
228 .to_string();
229 if let Some(annotation) = annotate_fn(&rel_path) {
230 map.insert(child.full_path.clone(), annotation);
231 }
232 if child.is_dir && !child.children.is_empty() {
233 collect_annotations(child, base, annotate_fn, map);
234 }
235 }
236}
237
238fn insert_entry(
239 tree: &mut Entry,
240 components: &[String],
241 is_dir: bool,
242 size: u64,
243 full_path: &Path,
244) {
245 let mut node = tree;
246 for (i, comp) in components.iter().enumerate() {
247 if i == components.len() - 1 {
248 node.children.entry(comp.clone()).or_insert_with(|| {
249 if is_dir {
250 Entry::new_dir(comp.clone(), full_path.to_path_buf())
251 } else {
252 Entry::new_file(comp.clone(), full_path.to_path_buf(), size)
253 }
254 });
255 } else {
256 let intermediate_path: PathBuf = full_path
258 .components()
259 .take(full_path.components().count() - (components.len() - 1 - i))
260 .collect();
261 node = node
262 .children
263 .entry(comp.clone())
264 .or_insert_with(|| Entry::new_dir(comp.clone(), intermediate_path));
265 }
266 }
267}
268
269fn prune_empty_dirs(entry: &mut Entry) -> bool {
270 if !entry.is_dir {
271 return true;
272 }
273 entry.children.retain(|_, child| prune_empty_dirs(child));
274 !entry.children.is_empty()
275}
276
277fn render_tree(
278 entry: &Entry,
279 prefix: &str,
280 output: &mut Vec<String>,
281 include_size: bool,
282 leaf_counts: &BTreeMap<PathBuf, (usize, usize)>,
283 annotations: &HashMap<PathBuf, String>,
284) {
285 let len = entry.children.len();
286
287 let max_name_width = if !annotations.is_empty() {
289 entry
290 .children
291 .values()
292 .map(|child| {
293 let base = child.name.len() + if child.is_dir { 1 } else { 0 }; if include_size && !child.is_dir {
295 base + 2 + format_size(child.size).len() + 1 } else {
297 base
298 }
299 })
300 .max()
301 .unwrap_or(0)
302 } else {
303 0
304 };
305
306 for (i, child) in entry.children.values().enumerate() {
307 let is_last = i == len - 1;
308 let connector = if is_last { "└── " } else { "├── " };
309 let child_prefix = if is_last { " " } else { "│ " };
310
311 if child.is_dir {
312 let summary = if child.children.is_empty() {
313 leaf_counts
314 .get(&child.full_path)
315 .map(|&(d, f)| format_summary(d, f))
316 .unwrap_or_default()
317 } else {
318 String::new()
319 };
320 let annotation = annotations.get(&child.full_path);
321 if let Some(ann) = annotation {
322 let name_part = format!("{}/", child.name);
323 let pad = if max_name_width > name_part.len() {
324 max_name_width - name_part.len()
325 } else {
326 0
327 };
328 output.push(format!(
329 "{}{}{}{}{} {}",
330 prefix,
331 connector,
332 name_part,
333 summary,
334 " ".repeat(pad),
335 ann
336 ));
337 } else {
338 output.push(format!("{}{}{}/{}", prefix, connector, child.name, summary));
339 }
340 if !child.children.is_empty() {
341 render_tree(
342 child,
343 &format!("{}{}", prefix, child_prefix),
344 output,
345 include_size,
346 leaf_counts,
347 annotations,
348 );
349 }
350 } else {
351 let size_str = if include_size {
352 format!(" ({})", format_size(child.size))
353 } else {
354 String::new()
355 };
356 let annotation = annotations.get(&child.full_path);
357 if let Some(ann) = annotation {
358 let name_part = format!("{}{}", child.name, size_str);
359 let pad = if max_name_width > name_part.len() {
360 max_name_width - name_part.len()
361 } else {
362 0
363 };
364 output.push(format!(
365 "{}{}{}{} {}",
366 prefix,
367 connector,
368 name_part,
369 " ".repeat(pad),
370 ann
371 ));
372 } else {
373 output.push(format!("{}{}{}{}", prefix, connector, child.name, size_str));
374 }
375 }
376 }
377}
378
379fn format_summary(dirs: usize, files: usize) -> String {
380 match (dirs, files) {
381 (0, 0) => String::new(),
382 (0, f) => format!(" [{} file{}]", f, if f == 1 { "" } else { "s" }),
383 (d, 0) => format!(" [{} dir{}]", d, if d == 1 { "" } else { "s" }),
384 (d, f) => format!(
385 " [{} dir{}, {} file{}]",
386 d,
387 if d == 1 { "" } else { "s" },
388 f,
389 if f == 1 { "" } else { "s" }
390 ),
391 }
392}
393
394fn format_size(bytes: u64) -> String {
395 if bytes < 1024 {
396 format!("{} B", bytes)
397 } else if bytes < 1024 * 1024 {
398 format!("{:.1} KB", bytes as f64 / 1024.0)
399 } else if bytes < 1024 * 1024 * 1024 {
400 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
401 } else {
402 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
403 }
404}
405
406fn dir_display_name(path: &Path, relative_to: Option<&str>) -> String {
407 if let Some(base) = relative_to {
408 let base_path = PathBuf::from(base);
409 if let Ok(rel) = path.strip_prefix(&base_path) {
410 let s = rel.to_string_lossy().to_string();
411 if s.is_empty() {
412 return path
413 .file_name()
414 .map(|n| n.to_string_lossy().to_string())
415 .unwrap_or_else(|| ".".to_string());
416 }
417 return s;
418 }
419 }
420 path.file_name()
421 .map(|n| n.to_string_lossy().to_string())
422 .unwrap_or_else(|| ".".to_string())
423}