use crate::{
error::{Error, Result},
printer::colors::Colors,
walker::WalkerEntry,
};
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::io::Write;
use std::path::{Path, PathBuf};
pub struct TreePrinter {
entries: Vec<PathBuf>,
max_entries: usize,
}
impl TreePrinter {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::with_capacity(1024), max_entries: 1_000_000, }
}
#[must_use]
pub fn with_max_entries(max_entries: usize) -> Self {
Self {
entries: Vec::with_capacity(max_entries.min(1024)),
max_entries,
}
}
#[must_use]
pub fn has_entries(&self) -> bool {
!self.entries.is_empty()
}
pub fn add_entry(&mut self, path: PathBuf) -> Result<()> {
if self.entries.len() >= self.max_entries {
return Err(Error::Printer {
message: format!(
"Tree printer entry limit exceeded (max: {}). \
Consider using --max-depth or --max-files to limit scope.",
self.max_entries
),
});
}
self.entries.push(path);
Ok(())
}
pub fn print_tree(&self, root: &Path) -> Result<()> {
let stdout = std::io::stdout();
let mut handle = std::io::BufWriter::new(stdout.lock());
self.write_tree(&mut handle, root)
}
pub fn write_tree<W: Write>(&self, writer: &mut W, root: &Path) -> Result<()> {
if self.entries.is_empty() {
return Ok(());
}
let mut tree = TreeNode::new_directory(root);
for entry in &self.entries {
let relative = entry.strip_prefix(root).unwrap_or(entry);
insert_path(&mut tree, relative, entry);
}
let colors = Colors::get();
let colored_root = colors.colorize(".", root);
writeln!(writer, "{colored_root}").map_err(Error::Io)?;
write_node_children(writer, &tree, "", colors)?;
Ok(())
}
pub fn print(entry: &WalkerEntry, _root: &Path) -> Result<()> {
let colors = Colors::get();
let display_string = entry.relative_path.display().to_string();
let colored_name = colors.colorize(&display_string, &entry.path);
println!("{colored_name}");
Ok(())
}
}
impl Default for TreePrinter {
fn default() -> Self {
Self::new()
}
}
pub fn format_tree(entries: &[WalkerEntry], root: &Path) -> Result<String> {
if entries.is_empty() {
return Ok(String::new());
}
let colors = Colors::get();
let mut tree = TreeNode::new_directory(root);
for entry in entries {
let relative = entry
.path
.strip_prefix(root)
.unwrap_or(&entry.relative_path);
insert_path(&mut tree, relative, &entry.path);
}
let mut buf = Vec::new();
let colored_root = colors.colorize(".", root);
writeln!(buf, "{colored_root}").map_err(Error::Io)?;
write_node_children(&mut buf, &tree, "", colors)?;
Ok(String::from_utf8(buf).unwrap_or_else(|e| e.to_string()))
}
fn insert_path(node: &mut TreeNode, path: &Path, full_path: &Path) {
let components: Vec<_> = path.components().collect();
if components.is_empty() {
return;
}
let num_components = components.len();
let mut root = full_path;
for _ in 0..num_components {
root = root.parent().unwrap_or(root);
}
let mut current = node;
let mut current_path = root.to_path_buf();
for component in components {
let name_os = component.as_os_str().to_os_string();
current_path = current_path.join(&name_os);
current = current
.children
.entry(name_os.clone())
.or_insert_with(|| TreeNode {
name: name_os,
full_path: current_path.clone(),
children: BTreeMap::new(),
});
}
}
fn write_node_children<W: Write>(
writer: &mut W,
node: &TreeNode,
prefix: &str,
colors: &Colors,
) -> Result<()> {
let children_count = node.children.len();
for (i, child) in node.children.values().enumerate() {
let is_last = i == children_count - 1;
write_node(writer, child, prefix, is_last, colors)?;
}
Ok(())
}
fn write_node<W: Write>(
writer: &mut W,
node: &TreeNode,
prefix: &str,
is_last: bool,
colors: &Colors,
) -> Result<()> {
let connector = if is_last { "└── " } else { "├── " };
let name_str = node.name.to_string_lossy();
let colored_name = colors.colorize(&name_str, &node.full_path);
writeln!(writer, "{prefix}{connector}{colored_name}").map_err(Error::Io)?;
let child_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
write_node_children(writer, node, &child_prefix, colors)?;
Ok(())
}
#[derive(Debug)]
struct TreeNode {
name: OsString,
full_path: PathBuf,
children: BTreeMap<OsString, Self>,
}
impl TreeNode {
fn new_directory(path: &Path) -> Self {
Self {
name: path
.file_name()
.unwrap_or_else(|| OsStr::new(""))
.to_os_string(),
full_path: path.to_path_buf(),
children: BTreeMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::path::PathBuf;
#[test]
fn test_tree_printer_empty() {
let printer = TreePrinter::new();
assert_eq!(printer.entries.len(), 0);
}
#[test]
fn test_tree_printer_add_entry() {
let mut printer = TreePrinter::new();
printer.add_entry(PathBuf::from("test.txt")).unwrap();
assert_eq!(printer.entries.len(), 1);
}
#[test]
fn test_tree_printer_respects_max_entries() {
let mut printer = TreePrinter::with_max_entries(2);
assert!(printer.add_entry(PathBuf::from("test1.txt")).is_ok());
assert!(printer.add_entry(PathBuf::from("test2.txt")).is_ok());
let result = printer.add_entry(PathBuf::from("test3.txt"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("limit exceeded"));
}
#[test]
fn test_simple_print() {
let entry = WalkerEntry {
path: PathBuf::from("/tmp/test.txt"),
relative_path: PathBuf::from("test.txt"),
is_dir: false,
};
let result = TreePrinter::print(&entry, Path::new("/tmp"));
assert!(result.is_ok());
}
#[test]
fn test_format_tree_empty() {
let entries: Vec<WalkerEntry> = vec![];
let result = format_tree(&entries, Path::new("/tmp"));
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_format_tree_single_file() {
let entries = vec![WalkerEntry {
path: PathBuf::from("/tmp/test.txt"),
relative_path: PathBuf::from("test.txt"),
is_dir: false,
}];
let result = format_tree(&entries, Path::new("/tmp"));
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("test.txt"));
assert!(output.contains("└── "));
}
#[test]
fn test_format_tree_with_directory() {
let entries = vec![
WalkerEntry {
path: PathBuf::from("/tmp/mydir"),
relative_path: PathBuf::from("mydir"),
is_dir: true,
},
WalkerEntry {
path: PathBuf::from("/tmp/mydir/test.txt"),
relative_path: PathBuf::from("mydir/test.txt"),
is_dir: false,
},
];
let result = format_tree(&entries, Path::new("/tmp"));
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("mydir"));
assert!(output.contains("test.txt"));
}
proptest! {
#[test]
fn test_tree_printer_handles_any_path(
s in "[a-zA-Z0-9_-]{1,20}"
) {
let mut printer = TreePrinter::new();
let path = PathBuf::from(format!("{s}.txt"));
printer.add_entry(path).unwrap();
assert_eq!(printer.entries.len(), 1);
}
}
}