use ignore::overrides::OverrideBuilder;
use ignore::WalkBuilder;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
const DEFAULT_SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
"dist",
"build",
".eggs",
"venv",
".venv",
"target",
".cargo",
".ruff_cache",
];
static DEFAULT_SKIP_SET: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| DEFAULT_SKIP_DIRS.iter().copied().collect());
struct Entry {
name: String,
full_path: PathBuf,
is_dir: bool,
size: u64,
children: BTreeMap<String, Entry>,
}
impl Entry {
fn new_dir(name: String, full_path: PathBuf) -> Self {
Self {
name,
full_path,
is_dir: true,
size: 0,
children: BTreeMap::new(),
}
}
fn new_file(name: String, full_path: PathBuf, size: u64) -> Self {
Self {
name,
full_path,
is_dir: false,
size,
children: BTreeMap::new(),
}
}
}
#[derive(Default)]
pub struct ListDirOpts<'a> {
pub depth: Option<usize>,
pub glob: Option<&'a str>,
pub dirs_only: bool,
pub relative_to: Option<&'a str>,
pub respect_gitignore: bool,
pub skip_dirs: Option<&'a [String]>,
pub include_size: bool,
pub annotate: Option<&'a AnnotateFn>,
}
pub type AnnotateFn = dyn Fn(&str) -> Option<String>;
pub fn list_dir(path: &str, opts: &ListDirOpts) -> Result<String, String> {
let root = PathBuf::from(path)
.canonicalize()
.map_err(|e| format!("Cannot resolve '{}': {}", path, e))?;
if !root.is_dir() {
return Ok(format!("Error: '{}' is not a directory.", path));
}
let depth = opts.depth.unwrap_or(1);
let respect_gitignore = opts.respect_gitignore;
let glob = opts.glob;
let relative_to = opts.relative_to;
let dirs_only = opts.dirs_only;
let include_size = opts.include_size;
let custom_skip: Option<HashSet<String>> =
opts.skip_dirs.map(|dirs| dirs.iter().cloned().collect());
let mut tree = Entry::new_dir(dir_display_name(&root, relative_to), root.clone());
let mut leaf_counts: BTreeMap<PathBuf, (usize, usize)> = BTreeMap::new();
{
let mut builder = WalkBuilder::new(&root);
builder.max_depth(Some(depth + 1));
builder.hidden(false);
builder.git_ignore(respect_gitignore);
builder.git_global(respect_gitignore);
builder.git_exclude(respect_gitignore);
if let Some(glob_pat) = glob {
let mut overrides = OverrideBuilder::new(&root);
overrides.add("*/").map_err(|e| format!("{}", e))?;
overrides.add(glob_pat).map_err(|e| format!("{}", e))?;
let built = overrides.build().map_err(|e| format!("{}", e))?;
builder.overrides(built);
}
builder.filter_entry(move |entry| {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if let Some(name) = entry.file_name().to_str() {
return match &custom_skip {
Some(set) => !set.contains(name),
None => !DEFAULT_SKIP_SET.contains(name),
};
}
}
true
});
for entry in builder.build().flatten() {
let entry_path = entry.path().to_path_buf();
if entry_path == root {
continue;
}
let rel = match entry_path.strip_prefix(&root) {
Ok(r) => r,
Err(_) => continue,
};
let comp_count = rel.components().count();
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if comp_count <= depth {
if dirs_only && !is_dir {
continue;
}
let components: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
let size = if include_size && !is_dir {
entry.metadata().map(|m| m.len()).unwrap_or(0)
} else {
0
};
insert_entry(&mut tree, &components, is_dir, size, &entry_path);
} else {
if let Some(parent) = entry_path.parent() {
let counter = leaf_counts.entry(parent.to_path_buf()).or_insert((0, 0));
if is_dir {
counter.0 += 1;
} else {
counter.1 += 1;
}
}
}
}
}
if glob.is_some() && !dirs_only {
prune_empty_dirs(&mut tree);
}
if tree.children.is_empty() {
return Ok(format!("{}/ (empty)", tree.name));
}
let annotations = if let Some(annotate_fn) = opts.annotate {
let mut map = HashMap::new();
let base = relative_to
.and_then(|r| PathBuf::from(r).canonicalize().ok())
.unwrap_or_else(|| root.clone());
collect_annotations(&tree, &base, annotate_fn, &mut map);
map
} else {
HashMap::new()
};
let mut output = Vec::new();
output.push(format!("{}/", tree.name));
render_tree(
&tree,
"",
&mut output,
include_size,
&leaf_counts,
&annotations,
);
Ok(output.join("\n"))
}
fn collect_annotations(
entry: &Entry,
base: &Path,
annotate_fn: &AnnotateFn,
map: &mut HashMap<PathBuf, String>,
) {
for child in entry.children.values() {
let rel_path = child
.full_path
.strip_prefix(base)
.unwrap_or(&child.full_path)
.to_string_lossy()
.to_string();
if let Some(annotation) = annotate_fn(&rel_path) {
map.insert(child.full_path.clone(), annotation);
}
if child.is_dir && !child.children.is_empty() {
collect_annotations(child, base, annotate_fn, map);
}
}
}
fn insert_entry(
tree: &mut Entry,
components: &[String],
is_dir: bool,
size: u64,
full_path: &Path,
) {
let mut node = tree;
for (i, comp) in components.iter().enumerate() {
if i == components.len() - 1 {
node.children.entry(comp.clone()).or_insert_with(|| {
if is_dir {
Entry::new_dir(comp.clone(), full_path.to_path_buf())
} else {
Entry::new_file(comp.clone(), full_path.to_path_buf(), size)
}
});
} else {
let intermediate_path: PathBuf = full_path
.components()
.take(full_path.components().count() - (components.len() - 1 - i))
.collect();
node = node
.children
.entry(comp.clone())
.or_insert_with(|| Entry::new_dir(comp.clone(), intermediate_path));
}
}
}
fn prune_empty_dirs(entry: &mut Entry) -> bool {
if !entry.is_dir {
return true;
}
entry.children.retain(|_, child| prune_empty_dirs(child));
!entry.children.is_empty()
}
fn render_tree(
entry: &Entry,
prefix: &str,
output: &mut Vec<String>,
include_size: bool,
leaf_counts: &BTreeMap<PathBuf, (usize, usize)>,
annotations: &HashMap<PathBuf, String>,
) {
let len = entry.children.len();
let max_name_width = if !annotations.is_empty() {
entry
.children
.values()
.map(|child| {
let base = child.name.len() + if child.is_dir { 1 } else { 0 }; if include_size && !child.is_dir {
base + 2 + format_size(child.size).len() + 1 } else {
base
}
})
.max()
.unwrap_or(0)
} else {
0
};
for (i, child) in entry.children.values().enumerate() {
let is_last = i == len - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last { " " } else { "│ " };
if child.is_dir {
let summary = if child.children.is_empty() {
leaf_counts
.get(&child.full_path)
.map(|&(d, f)| format_summary(d, f))
.unwrap_or_default()
} else {
String::new()
};
let annotation = annotations.get(&child.full_path);
if let Some(ann) = annotation {
let name_part = format!("{}/", child.name);
let pad = if max_name_width > name_part.len() {
max_name_width - name_part.len()
} else {
0
};
output.push(format!(
"{}{}{}{}{} {}",
prefix,
connector,
name_part,
summary,
" ".repeat(pad),
ann
));
} else {
output.push(format!("{}{}{}/{}", prefix, connector, child.name, summary));
}
if !child.children.is_empty() {
render_tree(
child,
&format!("{}{}", prefix, child_prefix),
output,
include_size,
leaf_counts,
annotations,
);
}
} else {
let size_str = if include_size {
format!(" ({})", format_size(child.size))
} else {
String::new()
};
let annotation = annotations.get(&child.full_path);
if let Some(ann) = annotation {
let name_part = format!("{}{}", child.name, size_str);
let pad = if max_name_width > name_part.len() {
max_name_width - name_part.len()
} else {
0
};
output.push(format!(
"{}{}{}{} {}",
prefix,
connector,
name_part,
" ".repeat(pad),
ann
));
} else {
output.push(format!("{}{}{}{}", prefix, connector, child.name, size_str));
}
}
}
}
fn format_summary(dirs: usize, files: usize) -> String {
match (dirs, files) {
(0, 0) => String::new(),
(0, f) => format!(" [{} file{}]", f, if f == 1 { "" } else { "s" }),
(d, 0) => format!(" [{} dir{}]", d, if d == 1 { "" } else { "s" }),
(d, f) => format!(
" [{} dir{}, {} file{}]",
d,
if d == 1 { "" } else { "s" },
f,
if f == 1 { "" } else { "s" }
),
}
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn dir_display_name(path: &Path, relative_to: Option<&str>) -> String {
if let Some(base) = relative_to {
let base_path = PathBuf::from(base);
if let Ok(rel) = path.strip_prefix(&base_path) {
let s = rel.to_string_lossy().to_string();
if s.is_empty() {
return path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
}
return s;
}
}
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string())
}