use byte_unit::{Byte, UnitType}; use clap::{CommandFactory, Parser, ValueEnum}; use clap_complete::{generate, Shell}; use colored::*; use comfy_table::presets::UTF8_FULL; use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; use csv::WriterBuilder; use ignore::WalkBuilder; use rayon::prelude::*; use serde::Serialize; use std::cmp::Ordering; use std::io::Write; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::str::FromStr; #[derive(Parser, Debug, Clone)] #[command(author, version, about, long_about = None)] #[command(disable_version_flag = true)] pub struct Args { /// Print version #[arg(short = 'v', long, action = clap::ArgAction::Version)] pub version: Option, /// Directory to analyze #[arg(default_value = ".")] pub path: PathBuf, /// Minimum size filter (e.g. "10MB", "1gb") #[arg(short = 'm', long)] pub min_size: Option, /// Limit the number of results per table #[arg(short = 'n', long)] pub number: Option, /// Sort by columns (e.g. "type:asc,size:dsc"). /// defaults to size:dsc. /// Columns: name, type, size, blocks. #[arg(long)] pub sort: Option, /// Depth to traverse (0 = only immediate children) #[arg(short, long, default_value = "0")] pub depth: usize, /// Display full absolute paths #[arg(short = 'f', long, conflicts_with = "path_relative")] pub path_full: bool, /// Display paths relative to current directory (default) #[arg(long)] pub path_relative: bool, /// Number of threads to use (defaults to available logical CPUs) #[arg(short = 'j', long = "threads")] pub threads: Option, /// Generate shell completions #[arg(long, value_enum)] pub completions: Option, /// Respect .gitignore and .ignore files #[arg(short = 'i', long)] pub ignore: bool, /// Output format #[arg(long, value_enum, default_value_t = OutputFormat::Text)] pub format: OutputFormat, /// Save output to file (optional filename, defaults to timestamped name) #[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")] pub save: Option, } #[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] pub enum OutputFormat { Text, Csv, Json, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SortColumn { Name, Type, Size, Blocks, } impl FromStr for SortColumn { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "name" | "n" => Ok(SortColumn::Name), "type" | "t" => Ok(SortColumn::Type), "size" | "s" => Ok(SortColumn::Size), "blocks" | "b" => Ok(SortColumn::Blocks), _ => Err(format!("Unknown column: {}", s)), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SortDirection { Asc, Dsc, } impl FromStr for SortDirection { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "asc" | "a" => Ok(SortDirection::Asc), "dsc" | "d" | "desc" => Ok(SortDirection::Dsc), _ => Err(format!("Unknown direction: {}", s)), } } } #[derive(Clone, Serialize, Debug)] pub struct Node { pub path: PathBuf, pub size_bytes: u64, pub blocks: u64, pub entry_type: EntryType, #[serde(skip)] pub children: Vec, } #[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum EntryType { File, Dir, } impl std::fmt::Display for EntryType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EntryType::File => write!(f, "File"), EntryType::Dir => write!(f, "Dir"), } } } #[derive(Serialize)] pub struct JsonMetrics { pub size_bytes: u64, pub blocks: u64, } #[derive(Serialize)] pub struct JsonEntry<'a> { pub path: &'a PathBuf, pub metrics: JsonMetrics, pub entry_type: String, } #[derive(Serialize)] pub struct JsonReport<'a> { pub path: &'a PathBuf, pub total_size: u64, pub blocks: u64, pub entries: Vec>, } pub fn run(args: Args, mut writer: &mut dyn Write) { if let Some(shell) = args.completions { let mut cmd = Args::command(); let name = cmd.get_name().to_string(); generate(shell, &mut cmd, name, &mut std::io::stdout()); return; } if let Some(threads) = args.threads { rayon::ThreadPoolBuilder::new() .num_threads(threads) .build_global() .ok(); // Ignore error if initialized called multiple times (e.g. in tests) } let target_path = args.path.clone(); if !target_path.exists() { eprintln!("Error: Path '{}' does not exist.", target_path.display()); std::process::exit(1); } let min_bytes = if let Some(size_str) = &args.min_size { match Byte::parse_str(size_str, true) { Ok(byte) => byte.as_u64(), Err(e) => { eprintln!("Error parsing size '{}': {}", size_str, e); std::process::exit(1); } } } else { 0 }; let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc")); if args.format == OutputFormat::Csv { writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header"); } let root_node = build_tree(&target_path, args.ignore); if root_node.size_bytes < min_bytes { if args.format == OutputFormat::Text { writeln!(writer, "Root directory is smaller than minimum size.").ok(); } return; } process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); } pub fn build_tree(path: &Path, ignore: bool) -> Node { let metadata = path.metadata(); if let Ok(meta) = metadata { if meta.is_file() { return Node { path: path.to_path_buf(), size_bytes: meta.len(), blocks: meta.blocks(), entry_type: EntryType::File, children: vec![], }; } } let walker = WalkBuilder::new(path) .standard_filters(false) .hidden(false) .git_ignore(ignore) .ignore(ignore) .max_depth(Some(1)) .build(); let child_paths: Vec = walker .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.path() != path) .map(|e| e.path().to_path_buf()) .collect(); let children: Vec = child_paths .par_iter() .map(|p| build_tree(p, ignore)) .collect(); let (size_bytes, blocks) = children .iter() .fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks)); let (self_size, self_blocks) = path .metadata() .map(|m| (m.len(), m.blocks())) .unwrap_or((0, 0)); Node { path: path.to_path_buf(), size_bytes: size_bytes + self_size, blocks: blocks + self_blocks, entry_type: EntryType::Dir, children, } } fn process_node_recursive( writer: &mut dyn Write, node: &Node, current_depth: usize, args: &Args, min_bytes: u64, sort_criteria: &[(SortColumn, SortDirection)], ) { if node.children.is_empty() && node.entry_type == EntryType::Dir {} let mut display_children: Vec<&Node> = node .children .iter() .filter(|c| c.size_bytes >= min_bytes) .collect(); display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria)); if let Some(n) = args.number { if n < display_children.len() { display_children.truncate(n); } } let show_relative = !args.path_full; let relative_path = if show_relative { let cwd = std::env::current_dir().unwrap_or_default(); node.path .strip_prefix(&cwd) .unwrap_or(&node.path) .display() .to_string() } else { node.path .canonicalize() .unwrap_or(node.path.clone()) .display() .to_string() }; print_output( writer, &node, &display_children, args, &relative_path, current_depth, ); if current_depth < args.depth { for child in display_children { if child.entry_type == EntryType::Dir { process_node_recursive( writer, child, current_depth + 1, args, min_bytes, sort_criteria, ); } } } } pub fn compare_nodes(a: &Node, b: &Node, criteria: &[(SortColumn, SortDirection)]) -> Ordering { for (col, dir) in criteria { let order = match col { SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()), SortColumn::Type => a.entry_type.cmp(&b.entry_type), SortColumn::Size => a.size_bytes.cmp(&b.size_bytes), SortColumn::Blocks => a.blocks.cmp(&b.blocks), }; if order != Ordering::Equal { return match dir { SortDirection::Asc => order, SortDirection::Dsc => order.reverse(), }; } } Ordering::Equal } pub fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> { s.split(',') .filter_map(|part| { let parts: Vec<&str> = part.split(':').collect(); if parts.is_empty() { return None; } let col_str = parts[0]; let col = SortColumn::from_str(col_str).ok()?; let dir = if parts.len() > 1 { SortDirection::from_str(parts[1]).unwrap_or(SortDirection::Dsc) } else { match col { SortColumn::Name | SortColumn::Type => SortDirection::Asc, _ => SortDirection::Dsc, } }; Some((col, dir)) }) .collect() } fn print_output( writer: &mut dyn Write, parent_node: &Node, children: &[&Node], args: &Args, relative_path: &str, depth: usize, ) { match args.format { OutputFormat::Text => print_text(writer, parent_node, children, relative_path, depth), OutputFormat::Csv => print_csv(writer, children), OutputFormat::Json => print_json(writer, parent_node, children), } } fn print_text( writer: &mut dyn Write, parent_node: &Node, children: &[&Node], relative_path: &str, depth: usize, ) { if depth > 0 { writeln!(writer, "{}", "-".repeat(60).dimmed()).ok(); } writeln!(writer, "\n{}", relative_path.bold().blue()).ok(); let total_byte = Byte::from_u64(parent_node.size_bytes); writeln!( writer, "Total size: {} / {} ({} blocks)", total_byte .get_appropriate_unit(UnitType::Binary) .to_string() .bold() .green(), parent_node.size_bytes.to_string().bold().green(), parent_node.blocks ) .ok(); if children.is_empty() { writeln!(writer, "(No children)").ok(); return; } let mut table = Table::new(); table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) .set_header(vec![ Cell::new("Name").add_attribute(Attribute::Bold), Cell::new("Type").add_attribute(Attribute::Bold), Cell::new("Size (Human)") .add_attribute(Attribute::Bold) .set_alignment(CellAlignment::Right), Cell::new("Size (Bytes)") .add_attribute(Attribute::Bold) .set_alignment(CellAlignment::Right), Cell::new("Blocks") .add_attribute(Attribute::Bold) .set_alignment(CellAlignment::Right), ]); for child in children { let name = child.path.file_name().unwrap_or_default().to_string_lossy(); let color = if child.entry_type == EntryType::Dir { Color::Blue } else { Color::Cyan }; let child_byte = Byte::from_u64(child.size_bytes); table.add_row(vec![ Cell::new(name).fg(color), Cell::new(&child.entry_type).fg(Color::Yellow), Cell::new( child_byte .get_appropriate_unit(UnitType::Binary) .to_string(), ) .fg(Color::Green) .set_alignment(CellAlignment::Right), Cell::new(child.size_bytes.to_string()) // Raw bytes instead of "Decimal" unit .fg(Color::Green) .set_alignment(CellAlignment::Right), Cell::new(child.blocks) .fg(Color::Green) .set_alignment(CellAlignment::Right), ]); } writeln!(writer, "{}", table).ok(); } fn print_csv(writer: &mut dyn Write, children: &[&Node]) { let mut wtr = WriterBuilder::new().has_headers(false).from_writer(writer); for child in children { wtr.write_record(&[ child.path.to_string_lossy().as_ref(), &child.entry_type.to_string(), &child.size_bytes.to_string(), &child.blocks.to_string(), ]) .ok(); } wtr.flush().ok(); } fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { let entries_view: Vec = children .iter() .map(|c| JsonEntry { path: &c.path, metrics: JsonMetrics { size_bytes: c.size_bytes, blocks: c.blocks, }, entry_type: c.entry_type.to_string(), }) .collect(); let report = JsonReport { path: &parent_node.path, total_size: parent_node.size_bytes, blocks: parent_node.blocks, entries: entries_view, }; serde_json::to_writer(&mut *writer, &report).ok(); writer.write_all(b"\n").ok(); } #[cfg(test)] mod tests { use super::*; use std::fs::File; use tempfile::TempDir; #[test] fn test_parse_sort_arg() { let criteria = parse_sort_arg("size:asc,name"); assert_eq!(criteria.len(), 2); assert_eq!(criteria[0], (SortColumn::Size, SortDirection::Asc)); assert_eq!(criteria[1], (SortColumn::Name, SortDirection::Asc)); // Default for Name is Asc } #[test] fn test_sort_defaults() { let criteria = parse_sort_arg("size"); assert_eq!(criteria[0], (SortColumn::Size, SortDirection::Dsc)); // Default for Size is Dsc } #[test] fn test_build_tree() { let temp_dir = TempDir::new().unwrap(); let file_path = temp_dir.path().join("test_file.txt"); File::create(&file_path) .unwrap() .write_all(b"Hello") .unwrap(); let node = build_tree(temp_dir.path(), false); assert_eq!(node.entry_type, EntryType::Dir); assert!(!node.children.is_empty()); assert_eq!(node.children[0].path, file_path); // Size should be at least 5 bytes assert!(node.children[0].size_bytes >= 5); } }