feat: optimization refactor, test suite, and dependency cleanup

Refactored the application to improve performance, maintainability, and binary size.

Key Changes:
- **Core Algorithm**: Switched to a parallel, single-pass tree construction algorithm for O(N) traversal.
- **Architecture**: Extract core logic from `main.rs` to `lib.rs` to enable unit testing.
- **Testing**: Added comprehensive unit tests and integration tests (`tests/cli.rs`) covering CLI arguments and output formats.
- **Benchmarking**: Added `criterion` benchmarks for performance tracking.
- **Dependencies**:
  - Removed `walkdir` (logic replaced by `ignore` + custom tree).
  - Removed `humansize` (consolidated to `byte-unit`).
  - Replaced `chrono` with lightweight `time` crate.
  - Removed unused `atty`.
- **Reporting**: Optimized CSV and JSON output for correctness and zero-copy performance.
- **Documentation**: Updated CHANGELOG.md and bumped version to 0.2.0.
This commit is contained in:
2026-01-22 13:33:38 -05:00
parent f379064e44
commit db9059e2fc
7 changed files with 1014 additions and 681 deletions
+13 -538
View File
@@ -1,222 +1,23 @@
use byte_unit::Byte;
use chrono::Local;
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 humansize::{format_size, BINARY, DECIMAL};
use ignore::WalkBuilder;
use rayon::prelude::*;
use serde::Serialize;
use std::cmp::Ordering;
use clap::Parser;
use sized::{run, Args, OutputFormat};
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)]
struct Args {
/// Print version
#[arg(short = 'v', long, action = clap::ArgAction::Version)]
version: Option<bool>,
/// Directory to analyze
#[arg(default_value = ".")]
path: PathBuf,
/// Minimum size filter (e.g. "10MB", "1gb")
#[arg(short = 'm', long)]
min_size: Option<String>,
/// Limit the number of results per table
#[arg(short = 'n', long)]
number: Option<usize>,
/// Sort by columns (e.g. "type:asc,size:dsc").
/// defaults to size:dsc.
/// Columns: name, type, size, blocks.
#[arg(long)]
sort: Option<String>,
/// Depth to traverse (0 = only immediate children)
#[arg(short, long, default_value = "0")]
depth: usize,
/// Display full absolute paths
#[arg(short = 'f', long, conflicts_with = "path_relative")]
path_full: bool,
/// Display paths relative to current directory (default)
#[arg(long)]
path_relative: bool,
/// Number of threads to use (defaults to available logical CPUs)
#[arg(short = 'j', long = "threads")]
threads: Option<usize>,
/// Generate shell completions
#[arg(long, value_enum)]
completions: Option<Shell>,
/// Respect .gitignore and .ignore files
#[arg(short = 'i', long)]
ignore: bool,
/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
/// Save output to file (optional filename, defaults to timestamped name)
#[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")]
save: Option<PathBuf>,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
enum OutputFormat {
Text,
Csv,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SortColumn {
Name,
Type,
Size,
Blocks,
}
impl FromStr for SortColumn {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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)]
enum SortDirection {
Asc,
Dsc,
}
impl FromStr for SortDirection {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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)]
struct Node {
path: PathBuf,
size_bytes: u64,
blocks: u64,
entry_type: EntryType,
#[serde(skip)]
children: Vec<Node>,
}
#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
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)]
struct JsonMetrics {
size_bytes: u64,
blocks: u64,
}
#[derive(Serialize)]
struct JsonEntry<'a> {
path: &'a PathBuf,
metrics: JsonMetrics,
entry_type: String,
}
#[derive(Serialize)]
struct JsonReport<'a> {
path: &'a PathBuf,
total_size: u64,
blocks: u64,
entries: Vec<JsonEntry<'a>>,
}
use std::path::PathBuf;
use time::macros::format_description;
use time::OffsetDateTime;
fn main() {
let args = Args::parse();
// Handle completions
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;
}
// Determine output writer logic (moved mostly to main to keep lib clean,
// or keep in lib? Plan said simplified main. Let's keep file creation in main usually for CLI apps,
// but the logic for "auto name" is business logic.
// Wait, the lib function `run` takes `writer`. So we need to create the writer here.
// Configure Rayon
if let Some(threads) = args.threads {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global()
.expect("Failed to build thread pool");
}
let target_path = args.path.clone();
if !target_path.exists() {
eprintln!("Error: Path '{}' does not exist.", target_path.display());
std::process::exit(1);
}
// Parse min_size
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
};
// Parse sort
let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc"));
// Determine output writer
let mut writer: Box<dyn Write> = if let Some(path_arg) = &args.save {
let output_path = if path_arg.to_string_lossy() == "_AUTO_" {
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
let dir_name = target_path
.file_name()
.unwrap_or_default()
.to_string_lossy();
let format = format_description!("[year][month][day]-[hour][minute][second]");
let timestamp = OffsetDateTime::now_utc().format(format).unwrap();
let dir_name = args.path.file_name().unwrap_or_default().to_string_lossy();
let ext = match args.format {
OutputFormat::Csv => "csv",
OutputFormat::Json => "json",
@@ -233,331 +34,5 @@ fn main() {
Box::new(std::io::stdout())
};
if args.format == OutputFormat::Csv {
// We do NOT write the header here manually anymore if we want to be safe/consistent,
// OR we write it manually but treat it as raw bytes.
// Actually, if we use csv::Writer, we should use it for the header too, OR assume the writer calls will handle it (no, we need to init).
// For simplicity in this logic, let's write the header manually *once* if we are creating a fresh file/stream.
// BUT, `process_node_recursive` might be called multiple times?
// Wait, `process_node_recursive` is called recursively.
// The CSV logic implementation in `print_output` currently printed children.
// If we recurse, we print children of children.
// So a single global header is correct.
writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header");
}
// Build the tree!
let root_node = build_tree(&target_path, args.ignore);
if root_node.size_bytes < min_bytes {
// Even the root is too small?
if args.format == OutputFormat::Text {
writeln!(writer, "Root directory is smaller than minimum size.").ok();
}
return;
}
// Render output
process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria);
}
/// Builds the directory tree in memory, calculating sizes in a single pass.
/// Uses Rayon for parallelism.
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![],
};
}
}
// It's a directory (or error, which we treat as zero-size usually, but here we try to read)
// Use ignore::WalkBuilder to get *immediate children only* respecting gitignore
let walker = WalkBuilder::new(path)
.standard_filters(false) // We manually control ignore
.hidden(false) // Show hidden files
.git_ignore(ignore)
.ignore(ignore)
.max_depth(Some(1)) // ONLY immediate children
.build();
let child_paths: Vec<PathBuf> = walker
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path() != path) // Exclude self
.map(|e| e.path().to_path_buf())
.collect();
// Recursively build children in parallel
let children: Vec<Node> = child_paths
.par_iter()
.map(|p| build_tree(p, ignore))
.collect();
// Sum up stats
let (size_bytes, blocks) = children
.iter()
.fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks));
// Add self-size (directory entry itself has size)
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 {
// Empty directory or leaf in terms of permissions/ignore
}
// Prepare children for display
let mut display_children: Vec<&Node> = node
.children
.iter()
.filter(|c| c.size_bytes >= min_bytes)
.collect();
// Sort
display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria));
// Limit
if let Some(n) = args.number {
if n < display_children.len() {
display_children.truncate(n);
}
}
// Default to relative paths unless --path-full is specified
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,
);
// Recurse
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,
);
}
}
}
}
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
}
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();
writeln!(
writer,
"Total size: {} / {} ({} blocks)",
format_size(parent_node.size_bytes, BINARY).bold().green(),
format_size(parent_node.size_bytes, DECIMAL).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 (Bin)")
.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
};
table.add_row(vec![
Cell::new(name).fg(color),
Cell::new(&child.entry_type).fg(Color::Yellow),
Cell::new(format_size(child.size_bytes, BINARY))
.fg(Color::Green)
.set_alignment(CellAlignment::Right),
Cell::new(format_size(child.size_bytes, DECIMAL))
.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]) {
// strict csv writing
let mut wtr = WriterBuilder::new()
.has_headers(false) // We already wrote the header globally
.from_writer(writer);
for child in children {
wtr.write_record(&[
child.path.to_string_lossy().as_ref(), // This handles escaping automatically
&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]) {
// Zero-copy serialization
let entries_view: Vec<JsonEntry> = 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();
run(args, &mut writer);
}