use std::ffi::OsStr;
use std::fmt::Write as FmtWrite;
use std::path::Path;
const IGNORED_DIRS: &[&str] = &[
"node_modules",
".git",
"target",
"dist",
"build",
"coverage",
];
pub const DEFAULT_MAX_DEPTH: usize = 6;
pub fn render_tree(root: &Path, max_depth: usize) -> String {
let mut out = String::new();
let root_label = root.file_name().and_then(OsStr::to_str).unwrap_or(".");
let _ = writeln!(out, "{root_label}/");
render_dir(root, "", max_depth, &mut out);
out
}
fn render_dir(dir: &Path, prefix: &str, depth_remaining: usize, out: &mut String) {
if depth_remaining == 0 {
let _ = writeln!(out, "{prefix} …");
return;
}
let mut entries: Vec<std::fs::DirEntry> = match std::fs::read_dir(dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name_str = name.to_str().unwrap_or("");
let is_ignored_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false)
&& IGNORED_DIRS.contains(&name_str);
!is_ignored_dir
})
.collect(),
Err(_) => return,
};
entries.sort_by(|a, b| {
let a_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false);
let b_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false);
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a
.file_name()
.to_str()
.unwrap_or("")
.to_lowercase()
.cmp(&b.file_name().to_str().unwrap_or("").to_lowercase()),
}
});
let count = entries.len();
for (i, entry) in entries.iter().enumerate() {
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let extension = if is_last { " " } else { "│ " };
let name = entry.file_name();
let name_str = name.to_str().unwrap_or("?");
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
let _ = writeln!(out, "{prefix}{connector}{name_str}/");
let new_prefix = format!("{prefix}{extension}");
render_dir(&entry.path(), &new_prefix, depth_remaining - 1, out);
} else {
let _ = writeln!(out, "{prefix}{connector}{name_str}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("{prefix}_{nanos}"));
fs::create_dir_all(&p).unwrap();
p
}
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
File::create(path).unwrap();
}
#[test]
fn renders_root_label_at_top() {
let dir = unique_temp_dir("codedna_map_root");
touch(&dir.join("main.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
let first_line = tree.lines().next().unwrap_or("");
assert!(first_line.ends_with('/'), "root label should end with '/'");
fs::remove_dir_all(dir).ok();
}
#[test]
fn renders_files_in_root() {
let dir = unique_temp_dir("codedna_map_files");
touch(&dir.join("main.rs"));
touch(&dir.join("lib.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
assert!(tree.contains("main.rs"));
assert!(tree.contains("lib.rs"));
fs::remove_dir_all(dir).ok();
}
#[test]
fn renders_subdirectory_with_trailing_slash() {
let dir = unique_temp_dir("codedna_map_subdir");
fs::create_dir_all(dir.join("src")).unwrap();
touch(&dir.join("src/main.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
assert!(tree.contains("src/"));
assert!(tree.contains("main.rs"));
fs::remove_dir_all(dir).ok();
}
#[test]
fn directories_appear_before_files() {
let dir = unique_temp_dir("codedna_map_order");
fs::create_dir_all(dir.join("src")).unwrap();
touch(&dir.join("readme.md"));
touch(&dir.join("src/main.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
let lines: Vec<&str> = tree.lines().collect();
let src_pos = lines.iter().position(|l| l.contains("src/")).unwrap();
let readme_pos = lines.iter().position(|l| l.contains("readme.md")).unwrap();
assert!(
src_pos < readme_pos,
"directories should appear before files\n{tree}"
);
fs::remove_dir_all(dir).ok();
}
#[test]
fn skips_ignored_directories() {
let dir = unique_temp_dir("codedna_map_ignored");
for ignored in IGNORED_DIRS {
let d = dir.join(ignored);
fs::create_dir_all(&d).unwrap();
touch(&d.join("should_not_appear.txt"));
}
touch(&dir.join("main.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
for ignored in IGNORED_DIRS {
assert!(
!tree.contains(ignored),
"ignored dir '{ignored}' appeared in tree:\n{tree}"
);
}
assert!(tree.contains("main.rs"));
fs::remove_dir_all(dir).ok();
}
#[test]
fn respects_max_depth() {
let dir = unique_temp_dir("codedna_map_depth");
fs::create_dir_all(dir.join("a/b/c")).unwrap();
touch(&dir.join("a/b/c/deep.rs"));
let tree = render_tree(&dir, 2);
assert!(tree.contains("a/"));
assert!(tree.contains("b/"));
assert!(
!tree.contains("deep.rs"),
"deep.rs should be hidden at depth 2:\n{tree}"
);
fs::remove_dir_all(dir).ok();
}
#[test]
fn uses_correct_box_drawing_chars() {
let dir = unique_temp_dir("codedna_map_box");
touch(&dir.join("aaa.rs"));
touch(&dir.join("bbb.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
assert!(tree.contains("├── "), "should contain ├── :\n{tree}");
assert!(tree.contains("└── "), "should contain └── :\n{tree}");
fs::remove_dir_all(dir).ok();
}
#[test]
fn last_entry_uses_corner_connector() {
let dir = unique_temp_dir("codedna_map_corner");
touch(&dir.join("aaa.rs")); touch(&dir.join("zzz.rs"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
let lines: Vec<&str> = tree.lines().collect();
let last_entry = lines.last().unwrap();
assert!(
last_entry.contains("└── "),
"last entry should use └── :\n{tree}"
);
fs::remove_dir_all(dir).ok();
}
#[test]
fn handles_empty_directory() {
let dir = unique_temp_dir("codedna_map_empty");
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
assert_eq!(tree.lines().count(), 1);
fs::remove_dir_all(dir).ok();
}
#[test]
fn handles_nested_directories_with_continuation_pipe() {
let dir = unique_temp_dir("codedna_map_pipe");
fs::create_dir_all(dir.join("src/components")).unwrap();
fs::create_dir_all(dir.join("src/hooks")).unwrap();
touch(&dir.join("src/components/Button.tsx"));
touch(&dir.join("src/hooks/useAuth.ts"));
let tree = render_tree(&dir, DEFAULT_MAX_DEPTH);
assert!(
tree.contains("│"),
"continuation pipe │ should appear:\n{tree}"
);
fs::remove_dir_all(dir).ok();
}
#[test]
fn handles_missing_directory_gracefully() {
let missing = PathBuf::from("/no/such/path/codedna_map_missing");
let tree = render_tree(&missing, DEFAULT_MAX_DEPTH);
assert!(!tree.is_empty());
}
}