use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use clap::{Parser, ArgAction};
use dunce::canonicalize;
use cli_clipboard::{ClipboardContext, ClipboardProvider};
use clap_version_flag::colorful_version;
use regex::Regex;
const COLOR_RESET: &str = "\x1b[0m";
const COLOR_WHITE_ON_RED: &str = "\x1b[1;97;41m";
const COLOR_ORANGE: &str = "\x1b[38;5;214m";
const COLOR_BRIGHT_YELLOW: &str = "\x1b[38;2;255;255;0m"; const COLOR_BRIGHT_CYAN: &str = "\x1b[38;2;0;255;255m"; const COLOR_LIGHT_MAGENTA_TRUE: &str = "\x1b[38;2;255;128;255m";
#[derive(Parser)]
#[command(name = "tree2")]
#[command(about = "Print directory tree with file sizes, exclusions, and .gitignore support")]
#[command(disable_version_flag = true)]
struct Cli {
#[arg(short = 'V', long = "version", action = ArgAction::SetTrue)]
version: bool,
#[arg(default_value = ".")]
path: String,
#[arg(short, long, num_args = 0..)]
exclude: Vec<String>,
#[arg(short = 'c', long)]
clipboard: bool,
#[arg(short = 'i', long, num_args = 0..)]
ignore_file: Vec<String>,
#[arg(short = 'e', long = "exception", num_args = 0..)]
exceptions: Vec<String>,
#[arg(short = 'a', long = "all")]
show_all: bool,
}
struct Config {
excludes: HashSet<String>,
root_excludes: HashSet<String>,
exception_patterns: Vec<Pattern>,
}
enum Pattern {
Wildcard(String),
Regex(Regex),
Exact(String),
}
impl Pattern {
fn matches(&self, text: &str) -> bool {
match self {
Pattern::Wildcard(pattern) => wildcard_match(pattern, text),
Pattern::Regex(re) => re.is_match(text),
Pattern::Exact(exact) => text == exact,
}
}
fn from_string(s: &str) -> Result<Self, String> {
if let Some(pattern) = s.strip_prefix("regex:") {
match Regex::new(pattern) {
Ok(re) => Ok(Pattern::Regex(re)),
Err(e) => Err(format!("Invalid regex '{}': {}", pattern, e)),
}
}
else if s.contains('*') || s.contains('?') {
Ok(Pattern::Wildcard(s.to_string()))
}
else {
Ok(Pattern::Exact(s.to_string()))
}
}
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let pattern_chars: Vec<char> = pattern.chars().collect();
let text_chars: Vec<char> = text.chars().collect();
wildcard_match_recursive(&pattern_chars, &text_chars, 0, 0)
}
fn wildcard_match_recursive(pattern: &[char], text: &[char], p_idx: usize, t_idx: usize) -> bool {
if p_idx == pattern.len() {
return t_idx == text.len();
}
match pattern[p_idx] {
'*' => {
for i in t_idx..=text.len() {
if wildcard_match_recursive(pattern, text, p_idx + 1, i) {
return true;
}
}
false
}
'?' => {
if t_idx < text.len() {
wildcard_match_recursive(pattern, text, p_idx + 1, t_idx + 1)
} else {
false
}
}
c => {
if t_idx < text.len() && text[t_idx] == c {
wildcard_match_recursive(pattern, text, p_idx + 1, t_idx + 1)
} else {
false
}
}
}
}
fn human_size(size: u64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"];
let mut size = size as f64;
for unit in units.iter() {
if size < 1024.0 {
return format!("{:.2} {}", size, unit);
}
size /= 1024.0;
}
format!("{:.2} PB", size)
}
fn load_ignore_file(path: &Path, filename: &str) -> HashSet<String> {
let ignore_path = path.join(filename);
if !ignore_path.exists() {
return HashSet::new();
}
match fs::read_to_string(ignore_path) {
Ok(content) => {
content.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.trim_end_matches('/').to_string())
.collect()
}
Err(_) => HashSet::new(),
}
}
fn load_all_ignore_files(path: &Path, specific_files: Option<&[String]>, show_all: bool) -> HashSet<String> {
let mut all_excludes = HashSet::new();
if !show_all {
let default_excludes = vec![
".git",
".svn",
".hg",
".bzr",
"_darcs",
"CVS",
".DS_Store",
"Thumbs.db",
"desktop.ini",
];
for exclude in default_excludes {
all_excludes.insert(exclude.to_string());
}
}
let ignore_files = vec![
".gitignore",
".dockerignore",
".npmignore",
".eslintignore",
".prettierignore",
".hgignore",
".terraformignore",
".helmignore",
".gcloudignore",
".cfignore",
".slugignore",
".pt", ];
if let Some(files) = specific_files {
for filename in files {
let excludes = load_ignore_file(path, filename);
all_excludes.extend(excludes);
}
} else {
for filename in ignore_files {
let excludes = load_ignore_file(path, filename);
all_excludes.extend(excludes);
}
}
all_excludes
}
fn should_exclude(
entry: &str,
excludes: &HashSet<String>,
root_excludes: &HashSet<String>,
exception_patterns: &[Pattern]
) -> bool {
for pattern in exception_patterns {
if pattern.matches(entry) {
return false;
}
}
if excludes.contains(entry) {
return true;
}
for pattern in root_excludes {
if pattern.contains('*') || pattern.contains('?') {
if wildcard_match(pattern, entry) {
return true;
}
} else {
if entry == pattern {
return true;
}
}
}
false
}
fn print_tree(path: &Path, prefix: &str, config: &Config, output: &mut String, use_colors: bool) {
let entries = match fs::read_dir(path) {
Ok(entries) => {
let mut entries: Vec<_> = entries.collect::<Result<Vec<_>, _>>().unwrap_or_default();
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
entries
}
Err(_) => {
let permission_text = format!("{}└── 🔒 [Permission Denied]\n", prefix);
if use_colors {
let colored = format!("{}{}{}", COLOR_WHITE_ON_RED, permission_text, COLOR_RESET);
print!("{}", colored);
output.push_str(&permission_text);
} else {
print!("{}", permission_text);
output.push_str(&permission_text);
}
return;
}
};
let filtered_entries: Vec<_> = entries
.iter()
.filter(|entry| {
let file_name = entry.file_name().to_string_lossy().to_string();
!should_exclude(&file_name, &config.excludes, &config.root_excludes, &config.exception_patterns)
})
.collect();
for (idx, entry) in filtered_entries.iter().enumerate() {
let file_name = entry.file_name().to_string_lossy().to_string();
let connector = if idx == filtered_entries.len() - 1 { "└── " } else { "├── " };
let metadata = match entry.metadata() {
Ok(meta) => meta,
Err(_) => continue,
};
if metadata.is_dir() {
let folder_text = format!("{}{}📁 {}/\n", prefix, connector, file_name);
if use_colors {
let colored = format!("{}{}{}", COLOR_BRIGHT_YELLOW, folder_text, COLOR_RESET);
print!("{}", colored);
} else {
print!("{}", folder_text);
}
output.push_str(&folder_text);
let new_prefix = if idx == filtered_entries.len() - 1 {
format!("{} ", prefix)
} else {
format!("{}│ ", prefix)
};
print_tree(&entry.path(), &new_prefix, config, output, use_colors);
} else {
let size = metadata.len();
let size_str = human_size(size);
let parts: Vec<&str> = size_str.split_whitespace().collect();
let (size_value, size_unit) = (parts[0], parts[1]);
let file_line = format!("{}{}📄 {} ({} {})\n", prefix, connector, file_name, size_value, size_unit);
if use_colors {
print!("{}{}📄 {} (", COLOR_BRIGHT_CYAN, format!("{}{}", prefix, connector), file_name);
if size == 0 {
print!("{}{}", COLOR_WHITE_ON_RED, size_value);
} else {
print!("{}{}", COLOR_LIGHT_MAGENTA_TRUE, size_value);
}
print!("{} ", COLOR_RESET);
print!("{}{}", COLOR_ORANGE, size_unit);
println!("{})", COLOR_RESET);
} else {
print!("{}", file_line);
}
output.push_str(&file_line);
}
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() == 2 && (args[1] == "-V" || args[1] == "--version") {
let version = colorful_version!();
version.print_and_exit();
}
let cli = Cli::parse();
let version_str = colorful_version!();
if cli.version {
println!("{}", version_str);
std::process::exit(0);
}
let path = PathBuf::from(&cli.path);
let abs_path = match canonicalize(&path) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let ignore_file_excludes = if cli.ignore_file.is_empty() {
load_all_ignore_files(&abs_path, None, cli.show_all)
} else {
load_all_ignore_files(&abs_path, Some(&cli.ignore_file), cli.show_all)
};
let mut exception_patterns = Vec::new();
for exception in &cli.exceptions {
match Pattern::from_string(exception) {
Ok(pattern) => exception_patterns.push(pattern),
Err(e) => {
eprintln!("Warning: {}", e);
}
}
}
let config = Config {
excludes: cli.exclude.into_iter().collect(),
root_excludes: ignore_file_excludes,
exception_patterns,
};
let mut output = String::new();
let use_colors = !cli.clipboard;
let root_text = format!("📂 {}/\n", abs_path.display());
if use_colors {
let colored = format!("{}{}{}", COLOR_BRIGHT_YELLOW, root_text, COLOR_RESET);
print!("{}", colored);
} else {
print!("{}", root_text);
}
output.push_str(&root_text);
print_tree(&abs_path, "", &config, &mut output, use_colors);
if cli.clipboard {
match ClipboardContext::new() {
Ok(mut ctx) => {
match ctx.set_contents(output.clone()) {
Ok(_) => eprintln!("\n✅ Tree output copied to clipboard!"),
Err(e) => eprintln!("\n❌ Failed to copy to clipboard: {}", e),
}
}
Err(e) => eprintln!("\n❌ Failed to access clipboard: {}", e),
}
}
}