From f379064e4486b8b8b576628fe5033133b638ca38 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 22 Jan 2026 13:17:12 -0500 Subject: [PATCH 1/3] feat: v0.2.0 optimization refactor and docs - Refactor core traversal to single-pass O(N) tree construction - Optimize CSV output using `csv` crate for robust escaping - Optimize JSON output with zero-copy serialization - Add MANUAL.md - Bump version to 0.2.0 - Remove walkdir dependency --- CHANGELOG.md | 13 ++ Cargo.lock | 20 +- Cargo.toml | 5 +- MANUAL.md | 154 +++++++++++++++ README.md | 27 ++- src/main.rs | 524 ++++++++++++++++++++++++++------------------------- 6 files changed, 483 insertions(+), 260 deletions(-) create mode 100644 MANUAL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e00a221..b4dbc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-01-22 + +### Changed +- Refactored core traversal logic to a **single-pass, in-memory tree construction** algorithm. This visits every file exactly once, replacing the previous $O(N \times Depth)$ behavior with $O(N)$. +- Switched default CSV output to use the `csv` crate for robust escaping (fixes issues with filenames containing commas). +- Optimized JSON output to use zero-copy serialization, improving performance and memory usage. + +### Added +- Added `MANUAL.md` with comprehensive usage documentation. + +### Removed +- Removed `walkdir` dependency (replaced by `ignore::WalkBuilder` for all traversal). + ## [0.1.0] - 2026-01-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index e594988..b969b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,16 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clap_mangen" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ea63a92086df93893164221ad4f24142086d535b3a0957b9b9bea2dc86301" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -868,6 +878,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + [[package]] name = "rust_decimal" version = "1.40.0" @@ -999,13 +1015,14 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "sized" -version = "0.1.0" +version = "0.2.0" dependencies = [ "atty", "byte-unit", "chrono", "clap", "clap_complete", + "clap_mangen", "colored", "comfy-table", "csv", @@ -1015,7 +1032,6 @@ dependencies = [ "serde", "serde_json", "strum", - "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0137d86..d95ecc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "sized" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] clap = { version = "4.4", features = ["derive"] } -walkdir = "2.3" + rayon = "1.8" humansize = "2.1" colored = "2.0" @@ -19,3 +19,4 @@ csv = "1.3" chrono = "0.4" clap_complete = "4.4" atty = "0.2" +clap_mangen = "0.2.31" diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..9cbf274 --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,154 @@ +# Sized User Manual + +`sized` is a modern, fast, and concurrent disk usage analyzer for the command line, written in Rust. It is designed to provide quick insights into directory sizes with a focus on readability and flexibility. + +## Table of Contents +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [Filtering and sorting](#filtering-and-sorting) +- [Output Formats](#output-formats) +- [Advanced Features](#advanced-features) + - [Concurrency](#concurrency) + - [Gitignore Support](#gitignore-support) + - [Exporting Data](#exporting-data) +- [Shell Completions](#shell-completions) + +## Installation + +Currently, `sized` can be installed from source: + +```bash +git clone +cd sized +cargo install --path . +``` + +## Basic Usage + +By default, `sized` analyzes the current directory recursively and displays a table of the immediate children, sorted by name. + +```bash +sized +``` + +To analyze a specific path: +```bash +sized /path/to/directory +``` + +The output includes: +- **Type**: Icon indicating if it's a directory (📁) or file (📄). +- **Name**: The relative path to the entry. +- **Size**: Human-readable size (e.g., 10 MB, 2.5 GB). +- **% of Parent**: The percentage of the total size of the *current view* (immediate children) that this entry consumes. +- **Last Modified**: Time since the file was last modified. + +### Total Size Header +The header displays the total size of the scanned directory in both Binary (MiB/GiB) and Decimal (MB/GB) units, along with the total block count. + +## Filtering and Sorting + +### Sorting (`--sort`) +Sort the output table by a specific column. + +**Syntax**: `--sort ` +- **Columns**: `name`, `size`, `type` +- **Directions**: `asc` (ascending), `desc` (descending) + +**Examples**: +```bash +# Sort by size (largest first) - Default behavior +sized --sort size:desc + +# Sort by name (A-Z) +sized --sort name:asc +``` + +### Minimum Size (`-m` / `--min-size`) +Hide entries smaller than a specific size to reduce noise. + +**Examples**: +```bash +# Show only files/dirs larger than 10 MB +sized -m 10MB + +# Show only files/dirs larger than 1 GB +sized --min-size 1GB +``` + +### Depth Control (`-d` / `--depth`) +Limit the recursion depth for the *calculation*. Note that the display currently shows immediate children, but this flag controls how deep `sized` looks to calculate directory sizes. +*(Note: Deeply nested directory sizes are always fully calculated unless limited)* + +```bash +sized -d 2 +``` + +## Output Formats (`--format`) + +`sized` supports multiple output formats for integration with other tools. + +### Table (Default) +The standard human-readable ASCII table with colors. +```bash +sized --format text +``` + +### CSV +Comma-Separated Values, suitable for spreadsheets. +```bash +sized --format csv +``` +Columns: `path`, `size_bytes`, `files`, `dirs` + +### JSON +Computed metrics in NDJSON (Newline Delimited JSON) format. +```bash +sized --format json +``` + +## Advanced Features + +### Path Display +- **Default**: Relative paths (`./folder`) +- **Full Path**: Use `-f` or `--path-full` to see absolute paths (`/users/name/folder`). +- **Relative Path**: Use `--path-relative` to explicitly force relative paths. + +### Concurrency (`-j` / `--threads`) +`sized` uses parallel processing. By default, it uses a number of threads equal to your CPU cores. You can limit this for system stability or increase it (though usually not clear). + +```bash +# Limit to 4 threads +sized -j 4 +``` + +### Gitignore Support (`-i` / `--ignore`) +Respect `.gitignore` and `.ignore` files. This is useful for checking the size of a project *as it would be committed*, ignoring `target/`, `node_modules/`, etc. + +```bash +sized -i +``` + +### Exporting Data (`--save`) +Save the output directly to a file. If you use `_AUTO_` (or provide no argument to the flag), it generates a filename with the current timestamp. + +```bash +# Save to a specific file +sized --save report.txt + +# Save to a timestamped file (e.g., 20250122-120000_sized_report.txt) +sized --save +``` + +## Shell Completions + +Generate shell completion scripts for Bash, Zsh, Fish, PowerShell, or Elvish. + +```bash +# Generate for Zsh +sized --completions zsh > _sized +``` + +### Installation (Zsh example) +1. Generate the completion file: `sized --completions zsh > ~/.zfunc/_sized` +2. Add to your `.zshrc`: `fpath+=~/.zfunc; autoload -Uz compinit && compinit` diff --git a/README.md b/README.md index 28f6e59..14438d3 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,32 @@ sized /path/to/directory sized . -d 1 -i -m 50MB --format csv --save ``` -**Generate Shell Completions (Zsh):** +### Generate Shell Completions + +**Bash:** +Add the following to your `.bashrc` or `.bash_profile`: ```bash -sized --completions zsh > _sized +# Option 1: Source directly +source <(sized --completions bash) + +# Option 2: Save to file (safer startup time) +sized --completions bash > ~/.sized_completions.bash +echo "source ~/.sized_completions.bash" >> ~/.bashrc +``` + +**Zsh:** +```bash +# In your .zshrc +sized --completions zsh > /usr/local/share/zsh/site-functions/_sized +# OR +sized --completions zsh > ~/.zfunc/_sized +fpath+=~/.zfunc +autoload -Uz compinit && compinit +``` + +**Fish:** +```bash +sized --completions fish > ~/.config/fish/completions/sized.fish ``` ## License diff --git a/src/main.rs b/src/main.rs index 1cc8304..97272ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,15 +5,16 @@ 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 serde_json::json; + use std::cmp::Ordering; use std::io::Write; use std::os::unix::fs::MetadataExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; #[derive(Parser, Debug, Clone)] @@ -120,17 +121,50 @@ impl FromStr for SortDirection { } } -#[derive(Clone, Serialize)] -struct PathMetrics { +#[derive(Clone, Serialize, Debug)] +struct Node { + path: PathBuf, + size_bytes: u64, + blocks: u64, + entry_type: EntryType, + #[serde(skip)] + children: Vec, +} + +#[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 EntryData { - path: PathBuf, - metrics: PathMetrics, - entry_type: String, // "File" or "Dir" +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>, } fn main() { @@ -200,157 +234,155 @@ fn main() { }; 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"); } - if target_path.is_file() { - // Single file case - let metadata = target_path.metadata().expect("Failed to get metadata"); - let metrics = PathMetrics { - size_bytes: metadata.len(), - blocks: metadata.blocks(), - }; - if metrics.size_bytes < min_bytes { - return; + // 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(); } - - // 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(); - target_path - .strip_prefix(&cwd) - .unwrap_or(&target_path) - .display() - .to_string() - } else { - target_path - .canonicalize() - .unwrap_or(target_path.clone()) - .display() - .to_string() - }; - - print_output( - &mut writer, - &target_path, - &metrics, - &[EntryData { - path: target_path.clone(), - metrics: metrics.clone(), - entry_type: "File".to_string(), - }], - &args, - &relative_path, - ); return; } - // Recursive tables - process_directory_recursive( - &mut writer, - &target_path, - 0, - &args, - min_bytes, - &sort_criteria, - ); + // Render output + process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); } -fn process_directory_recursive( +/// 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 = 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 = 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, - path: &PathBuf, + node: &Node, current_depth: usize, args: &Args, min_bytes: u64, sort_criteria: &[(SortColumn, SortDirection)], ) { - // 1. Get immediate children - let walker = WalkBuilder::new(path) - .standard_filters(false) - .hidden(false) - .git_ignore(args.ignore) - .ignore(args.ignore) - .max_depth(Some(1)) - .build(); - - let entries: Vec<_> = walker - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path() != path) // Exclude root itself - .collect(); - - // 2. Process metrics for them - let mut sized_entries: Vec = entries - .par_iter() - .map(|entry| { - let p = entry.path().to_path_buf(); - let metrics = get_path_metrics(&p, args.ignore); - let entry_type = if p.is_file() { - "File".to_string() - } else { - "Dir".to_string() - }; - EntryData { - path: p, - metrics, - entry_type, - } - }) - .collect(); - - let total_metrics = calc_total(&sized_entries); - - // Filter - if min_bytes > 0 { - sized_entries.retain(|e| e.metrics.size_bytes >= min_bytes); + if node.children.is_empty() && node.entry_type == EntryType::Dir { + // Empty directory or leaf in terms of permissions/ignore } - // Sort - sized_entries.sort_by(|a, b| compare_entries(a, b, sort_criteria)); + // Prepare children for display + let mut display_children: Vec<&Node> = node + .children + .iter() + .filter(|c| c.size_bytes >= min_bytes) + .collect(); - // Limit (per table) + // Sort + display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria)); + + // Limit if let Some(n) = args.number { - if n < sized_entries.len() { - sized_entries.truncate(n); + if n < display_children.len() { + display_children.truncate(n); } } - // Print this level - if current_depth > 0 && args.format == OutputFormat::Text { - writeln!(writer, "{}", "-".repeat(60).dimmed()).ok(); - } // 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(); - path.strip_prefix(&cwd) - .unwrap_or(path) + node.path + .strip_prefix(&cwd) + .unwrap_or(&node.path) .display() .to_string() } else { - path.canonicalize() - .unwrap_or(path.clone()) + node.path + .canonicalize() + .unwrap_or(node.path.clone()) .display() .to_string() }; print_output( writer, - path, - &total_metrics, - &sized_entries, + &node, + &display_children, args, &relative_path, + current_depth, ); - // Recurse if needed + // Recurse if current_depth < args.depth { - for entry in sized_entries { - if entry.entry_type == "Dir" { - process_directory_recursive( + for child in display_children { + if child.entry_type == EntryType::Dir { + process_node_recursive( writer, - &entry.path, + child, current_depth + 1, args, min_bytes, @@ -361,64 +393,13 @@ fn process_directory_recursive( } } -fn get_path_metrics(path: &PathBuf, ignore: bool) -> PathMetrics { - if path.is_file() { - let (size, blocks) = path - .metadata() - .map(|m| (m.len(), m.blocks())) - .unwrap_or((0, 0)); - return PathMetrics { - size_bytes: size, - blocks, - }; - } - - let walker = WalkBuilder::new(path) - .standard_filters(false) - .hidden(false) - .git_ignore(ignore) - .ignore(ignore) - .build(); - - let (size_bytes, blocks) = walker - .into_iter() - .filter_map(|e| e.ok()) - .par_bridge() - .map(|entry| { - entry - .metadata() - .map(|m| (m.len(), m.blocks())) - .unwrap_or((0, 0)) - }) - .reduce(|| (0, 0), |a, b| (a.0 + b.0, a.1 + b.1)); - - PathMetrics { size_bytes, blocks } -} - -fn calc_total(entries: &[EntryData]) -> PathMetrics { - entries.iter().fold( - PathMetrics { - size_bytes: 0, - blocks: 0, - }, - |acc, e| PathMetrics { - size_bytes: acc.size_bytes + e.metrics.size_bytes, - blocks: acc.blocks + e.metrics.blocks, - }, - ) -} - -fn compare_entries( - a: &EntryData, - b: &EntryData, - criteria: &[(SortColumn, SortDirection)], -) -> Ordering { +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.metrics.size_bytes.cmp(&b.metrics.size_bytes), - SortColumn::Blocks => a.metrics.blocks.cmp(&b.metrics.blocks), + SortColumn::Size => a.size_bytes.cmp(&b.size_bytes), + SortColumn::Blocks => a.blocks.cmp(&b.blocks), }; if order != Ordering::Equal { @@ -457,91 +438,126 @@ fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> { fn print_output( writer: &mut dyn Write, - path: &PathBuf, - metrics: &PathMetrics, - entries: &[EntryData], + parent_node: &Node, + children: &[&Node], args: &Args, relative_path: &str, + depth: usize, ) { match args.format { - OutputFormat::Text => { - writeln!(writer, "\n{}", relative_path.bold().blue()).ok(); - writeln!( - writer, - "Total size: {} / {} ({} blocks)", - format_size(metrics.size_bytes, BINARY).bold().green(), - format_size(metrics.size_bytes, DECIMAL).bold().green(), - metrics.blocks - ) - .ok(); - - 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 entry in entries { - let name = entry.path.file_name().unwrap_or_default().to_string_lossy(); - let color = if entry.entry_type == "Dir" { - Color::Blue - } else { - Color::Cyan - }; - - table.add_row(vec![ - Cell::new(name).fg(color), - Cell::new(&entry.entry_type).fg(Color::Yellow), - Cell::new(format_size(entry.metrics.size_bytes, BINARY)) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - Cell::new(format_size(entry.metrics.size_bytes, DECIMAL)) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - Cell::new(entry.metrics.blocks) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - ]); - } - writeln!(writer, "{}", table).ok(); - } - OutputFormat::Csv => { - // Check if we need to print header? Csv writer handles it if we tell it? - // Simple flatten: Path,Type,Size,Blocks - // But we have entries list. - for entry in entries { - writeln!( - writer, - "{},{},{},{}", - entry.path.display(), - entry.entry_type, - entry.metrics.size_bytes, - entry.metrics.blocks - ) - .ok(); - } - } - OutputFormat::Json => { - // NDJSON report per directory - let report = json!({ - "path": path, - "total_size": metrics.size_bytes, - "blocks": metrics.blocks, - "entries": entries - }); - writeln!(writer, "{}", serde_json::to_string(&report).unwrap()).ok(); - } + 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 = 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(); +} -- 2.54.0 From db9059e2fc3605bb1d7c9a9de6e53465405cdbc7 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 22 Jan 2026 13:33:38 -0500 Subject: [PATCH 2/3] 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. --- CHANGELOG.md | 6 + Cargo.lock | 490 +++++++++++++++++++++++++++----------- Cargo.toml | 15 +- benches/benchmark.rs | 34 +++ src/lib.rs | 552 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 551 +----------------------------------------- tests/cli.rs | 47 ++++ 7 files changed, 1014 insertions(+), 681 deletions(-) create mode 100644 benches/benchmark.rs create mode 100644 src/lib.rs create mode 100644 tests/cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4dbc75..637b1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `MANUAL.md` with comprehensive usage documentation. +- Added comprehensive test suite (unit and integration tests). +- Added performance benchmarks using `criterion`. ### Removed - Removed `walkdir` dependency (replaced by `ignore::WalkBuilder` for all traversal). +- Removed `humansize` (consolidated to `byte-unit`). +- Removed `chrono` (replaced with lightweight `time`). +- Removed `atty` (unused). + ## [0.1.0] - 2026-01-22 diff --git a/Cargo.lock b/Cargo.lock index b969b75..b4488e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -23,13 +23,10 @@ dependencies = [ ] [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "anes" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" @@ -88,14 +85,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "atty" -version = "0.2.14" +name = "assert_cmd" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" dependencies = [ - "hermit-abi", + "anstyle", + "bstr", "libc", - "winapi", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", ] [[package]] @@ -152,6 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -202,14 +204,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] -name = "cc" -version = "1.2.53" +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cfg-if" @@ -224,16 +222,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.43" +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", ] [[package]] @@ -323,10 +335,40 @@ dependencies = [ ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] [[package]] name = "crossbeam-deque" @@ -376,6 +418,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "csv" version = "1.4.0" @@ -397,6 +445,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "document-features" version = "0.2.12" @@ -435,10 +498,19 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.8" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] [[package]] name = "funty" @@ -457,6 +529,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "globset" version = "0.4.18" @@ -470,6 +554,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -499,45 +594,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.1.19" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "ignore" @@ -565,12 +624,32 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -599,12 +678,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -638,6 +711,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -659,6 +744,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -682,6 +773,40 @@ dependencies = [ "windows-link", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -691,6 +816,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -738,6 +893,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -771,7 +932,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -823,6 +984,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -1001,12 +1174,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "simdutf8" version = "0.1.5" @@ -1017,21 +1184,23 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" name = "sized" version = "0.2.0" dependencies = [ - "atty", + "assert_cmd", "byte-unit", - "chrono", "clap", "clap_complete", "clap_mangen", "colored", "comfy-table", + "criterion", "csv", - "humansize", "ignore", + "predicates", "rayon", "serde", "serde_json", "strum", + "tempfile", + "time", ] [[package]] @@ -1096,6 +1265,66 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -1187,6 +1416,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1203,6 +1441,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1248,6 +1495,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1279,65 +1536,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -1429,6 +1633,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d95ecc2..6d0ea4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" clap = { version = "4.4", features = ["derive"] } rayon = "1.8" -humansize = "2.1" colored = "2.0" comfy-table = "7.0" byte-unit = "5.0" @@ -16,7 +15,17 @@ ignore = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" csv = "1.3" -chrono = "0.4" +time = { version = "0.3", features = ["macros", "formatting"] } clap_complete = "4.4" -atty = "0.2" clap_mangen = "0.2.31" + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.1" +tempfile = "3.10" +criterion = "0.5" + +[[bench]] +name = "benchmark" +harness = false + diff --git a/benches/benchmark.rs b/benches/benchmark.rs new file mode 100644 index 0000000..35c78cb --- /dev/null +++ b/benches/benchmark.rs @@ -0,0 +1,34 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use sized::build_tree; +use std::fs::{self, File}; +use std::io::Write; +use tempfile::TempDir; // Ensure this is pub in lib.rs + +fn setup_test_dir() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create a moderately deep structure + for i in 0..10 { + let dir_path = base_path.join(format!("dir_{}", i)); + fs::create_dir(&dir_path).unwrap(); + for j in 0..10 { + let file_path = dir_path.join(format!("file_{}.txt", j)); + let mut file = File::create(file_path).unwrap(); + writeln!(file, "Some content for file {}-{}", i, j).unwrap(); + } + } + temp_dir +} + +fn criterion_benchmark(c: &mut Criterion) { + let temp_dir = setup_test_dir(); + let path = temp_dir.path(); + + c.bench_function("build_tree small", |b| { + b.iter(|| build_tree(black_box(path), black_box(false))) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6d256e1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,552 @@ +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); + } +} diff --git a/src/main.rs b/src/main.rs index 97272ab..7749f96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, - - /// Directory to analyze - #[arg(default_value = ".")] - path: PathBuf, - - /// Minimum size filter (e.g. "10MB", "1gb") - #[arg(short = 'm', long)] - min_size: Option, - - /// Limit the number of results per table - #[arg(short = 'n', long)] - number: Option, - - /// Sort by columns (e.g. "type:asc,size:dsc"). - /// defaults to size:dsc. - /// Columns: name, type, size, blocks. - #[arg(long)] - sort: Option, - - /// 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, - - /// Generate shell completions - #[arg(long, value_enum)] - completions: Option, - - /// 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, -} - -#[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 { - 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 { - 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, -} - -#[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>, -} +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 = 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 = 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 = 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 = 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); } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..dc5a9f5 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,47 @@ +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::fs::File; +use std::io::Write; +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn test_basic_cli_run() { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("sized 0.2.0")); +} + +#[test] +fn test_run_on_directory() { + 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 mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg(temp_dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("test_file.txt")); // Basic check +} + +#[test] +fn test_json_output() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("data.json"); + File::create(&file_path).unwrap().write_all(b"{}").unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg(temp_dir.path()) + .arg("--format") + .arg("json") + .assert() + .success() + .stdout(predicate::str::contains("\"entry_type\":\"File\"")) + .stdout(predicate::str::contains("\"path\":")); +} -- 2.54.0 From 276c6404a996d230915e941db4beea8cf373260f Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 22 Jan 2026 15:00:48 -0500 Subject: [PATCH 3/3] feat: v0.2.0 - Performance optimization and distribution pipeline - Implemented multi-threaded directory traversal using ignore::WalkParallel - Added dynamic thread allocation for balanced CPU utilization - Refactored core logic to single-pass, in-memory tree construction (O(N)) - Added --compare, --units, and --precision flags - Established SCM-neutral distribution pipeline (Makefile, release.sh, Homebrew) - Comprehensive updates to README.md, MANUAL.md, and CHANGELOG.md - Decoupled from SCM-specific workflows --- .gitignore | 1 + CHANGELOG.md | 60 ++++--- Cargo.toml | 24 +++ Formula/sized.rb | 23 +++ LICENSE | 21 +++ MANUAL.md | 136 +++++++++------- Makefile | 25 +++ README.md | 61 +++++++- SHIPMENT.md | 56 +++++++ benches/benchmark.rs | 2 +- scripts/release.sh | 53 +++++++ sized.1 | 74 +++++++++ src/lib.rs | 365 +++++++++++++++++++++++++++++++++++++------ 13 files changed, 763 insertions(+), 138 deletions(-) create mode 100644 Formula/sized.rb create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 SHIPMENT.md create mode 100755 scripts/release.sh create mode 100644 sized.1 diff --git a/.gitignore b/.gitignore index ea8c4bf..4f96631 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 637b1c4..d14b2fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,36 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026-01-22 -### Changed -- Refactored core traversal logic to a **single-pass, in-memory tree construction** algorithm. This visits every file exactly once, replacing the previous $O(N \times Depth)$ behavior with $O(N)$. -- Switched default CSV output to use the `csv` crate for robust escaping (fixes issues with filenames containing commas). -- Optimized JSON output to use zero-copy serialization, improving performance and memory usage. - ### Added -- Added `MANUAL.md` with comprehensive usage documentation. -- Added comprehensive test suite (unit and integration tests). -- Added performance benchmarks using `criterion`. +- **Comparison Mode**: Added `--compare` flag to display total size vs. non-ignored size (respecting `.gitignore`). +- **Unit Control**: Added `--units ` flag to toggle standard IEC (MiB) vs. SI (MB) unit systems. +- **Precision Control**: Added `--precision ` to control decimal places in output (default: 2). +- **Physical vs. Logical Distinction**: Explicitly show **Apparent Size** (logical) and **Disk Usage** (actual blocks consumed) in all output formats. +- **Release Assets**: + - Project **Makefile** for easy system-wide installation via `make install`. + - Universal **`scripts/release.sh`** for generating optimized binaries, man pages, shell completions, and `.deb` packages. + - Official **man page** (`sized.1`) for standard Unix documentation documentation. + - Updated **Homebrew Formula** for tap-based distribution. +- **Improved Errors**: Graceful handling for inaccessible directories (e.g., system protected folders) with "Access Denied" status. +- Added a full user guide in `MANUAL.md`. + +### Changed +- **Performance Overhaul**: + - Replaced serial/par-bridge traversal with `ignore::WalkParallel` for true multi-threaded directory scanning. + - Implemented **dynamic thread allocation** to balance CPU load between directory breadth and depth. + - Refactored core logic to a **single-pass, in-memory tree construction** algorithm ($O(N)$ complexity). +- **Architecture**: Refactored the project into a usable Rust library (`src/lib.rs`) and a thin CLI wrapper (`src/main.rs`). +- **Dependencies**: + - Switched to `byte-unit` for more flexible and accurate unit formatting. + - Replaced `walkdir` with `ignore` for faster traversal and native `.gitignore` support. + - Replaced `atty` and `chrono` with standard library features and the lightweight `time` crate. +- **Serialization**: Optimized JSON and CSV output for zero-copy performance and detailed metric reporting. + +### Fixed +- Resolved performance bottleneck in extremely deep directory structures. +- Fixed inconsistent total size reporting when `.gitignore` was active. ### Removed -- Removed `walkdir` dependency (replaced by `ignore::WalkBuilder` for all traversal). -- Removed `humansize` (consolidated to `byte-unit`). -- Removed `chrono` (replaced with lightweight `time`). -- Removed `atty` (unused). - +- Dependency on `walkdir`, `humansize`, `chrono`, and `atty`. ## [0.1.0] - 2026-01-22 ### Added -- Core directory traversal and size calculation logic. -- Parallel processing using `rayon` for high performance. -- Rich table output format using `comfy-table` with colored entries. -- Recursive depth control via `-d`/`--depth`. -- Minimum size filtering via `-m`/`--min-size`. -- Multi-column sorting support (`--sort`). -- Relative path display by default, with `-f`/`--path-full` override. -- "Total size" summary with dual units (Binary/Decimal) and block counts. -- Concurrency control via `-j`/`--threads`. -- `.gitignore` and `.ignore` file support via `-i`/`--ignore`. -- Multiple output formats: Text (Table), CSV, JSON (`--format`). -- Save to file functionality with auto-naming (`--save`). -- Shell completion generation (`--completions`). +- Initial release with core directory traversal and size calculation. +- Basic parallel processing using `rayon`. +- Rich table output via `comfy-table`. +- Basic filtering (`--min-size`, `--depth`) and sorting (`--sort`). +- Output format support: Text, CSV, and JSON. +- Save to file and shell completion generation. diff --git a/Cargo.toml b/Cargo.toml index 6d0ea4b..d6d5af1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,30 @@ name = "sized" version = "0.2.0" edition = "2021" +description = "A modern, fast, and concurrent disk usage analyzer" +license = "MIT" +repository = "https://gitea.speelman.ca/gamertan/sized" +readme = "README.md" + +categories = ["command-line-utilities", "filesystem"] +keywords = ["du", "size", "disk", "usage", "parallel"] + +[package.metadata.deb] +maintainer = "Cole Speelman" +copyright = "2026, Cole Speelman" +license-file = ["LICENSE", "4"] +extended-description = """ +sized is a modern, fast, and concurrent disk usage analyzer. +Features include apparent vs disk usage, binary/decimal units, and gitignore support. +""" +section = "utils" +priority = "optional" +assets = [ + ["target/release/sized", "usr/bin/sized", "755"], + ["sized.1", "usr/share/man/man1/sized.1", "644"], + ["README.md", "usr/share/doc/sized/README.md", "644"], +] + [dependencies] clap = { version = "4.4", features = ["derive"] } diff --git a/Formula/sized.rb b/Formula/sized.rb new file mode 100644 index 0000000..3bc4011 --- /dev/null +++ b/Formula/sized.rb @@ -0,0 +1,23 @@ +class Sized < Formula + desc "A modern, fast, and concurrent disk usage analyzer" + homepage "https://gitea.speelman.ca/gamertan/sized" + url "https://gitea.speelman.ca/gamertan/sized/archive/v0.2.0.tar.gz" + sha256 "REPLACE_WITH_ACTUAL_SHA256" # Run `shasum -a 256 v0.2.0.tar.gz` after upload + license "MIT" + + depends_on "rust" => :build + + def install + system "cargo", "install", *std_cargo_args + + # Install man page + man1.install "sized.1" + + # Generate and install shell completions + generate_completions_from_executable(bin/"sized", "--completions") + end + + test do + system "#{bin}/sized", "--version" + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ceecafb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cole Speelman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANUAL.md b/MANUAL.md index 9cbf274..d36d5e4 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -8,80 +8,85 @@ - [Filtering and sorting](#filtering-and-sorting) - [Output Formats](#output-formats) - [Advanced Features](#advanced-features) - - [Concurrency](#concurrency) + - [Comparison Mode](#comparison-mode) - [Gitignore Support](#gitignore-support) + - [Concurrency](#concurrency) - [Exporting Data](#exporting-data) - [Shell Completions](#shell-completions) ## Installation -Currently, `sized` can be installed from source: +### From Binaries (Recommended) +Download the latest pre-compiled binaries from the [Gitea Releases](https://gitea.speelman.ca/gamertan/sized/releases) page. +### From Source ```bash -git clone +git clone https://gitea.speelman.ca/gamertan/sized.git cd sized -cargo install --path . +make install # Installs binary and man page ``` ## Basic Usage -By default, `sized` analyzes the current directory recursively and displays a table of the immediate children, sorted by name. +By default, `sized` analyzes the current directory recursively and displays a table of the immediate children. ```bash -sized +sized [path] ``` -To analyze a specific path: +### Key Concepts +- **Apparent Size**: The logical size of the entry (file length). This is what most file explorers show. +- **Disk Usage**: The actual physical space consumed on disk (Blocks * 512 bytes). This is the "true" footprint. +- **Blocks**: The actual filesystem blocks allocated. Useful for identifying sparse files or filesystem overhead. + +### Path Display +- **Relative Path** (Default): `sized` shows paths relative to the current directory. +- **Full Path**: Use `-f` or `--path-full` to see absolute paths (e.g., `/Users/dev/project`). +- **Relative Toggle**: Use `--path-relative` to explicitly force relative paths (useful if overriding aliases). + +### Depth Control (`-d` / `--depth`) +By default, `sized` shows the immediate children of the target directory (depth 0). You can increase the recursion depth shown in the output. + ```bash -sized /path/to/directory +# Show current directory and its children's children +sized -d 1 ``` -The output includes: -- **Type**: Icon indicating if it's a directory (📁) or file (📄). -- **Name**: The relative path to the entry. -- **Size**: Human-readable size (e.g., 10 MB, 2.5 GB). -- **% of Parent**: The percentage of the total size of the *current view* (immediate children) that this entry consumes. -- **Last Modified**: Time since the file was last modified. - -### Total Size Header -The header displays the total size of the scanned directory in both Binary (MiB/GiB) and Decimal (MB/GB) units, along with the total block count. +> [!NOTE] +> Regardless of the display depth, `sized` always calculates the *total* size of all subdirectories accurately by traversing the entire tree. ## Filtering and Sorting ### Sorting (`--sort`) -Sort the output table by a specific column. +Sort the output table by a specific column. `sized` supports multi-column sorting. -**Syntax**: `--sort ` -- **Columns**: `name`, `size`, `type` -- **Directions**: `asc` (ascending), `desc` (descending) +**Syntax**: `--sort ,[COLUMN:DIRECTION]` +- **Columns**: `name` (n), `size` (s), `type` (t), `blocks` (b) +- **Directions**: `asc` (a), `dsc` (d) **Examples**: ```bash # Sort by size (largest first) - Default behavior -sized --sort size:desc +sized --sort size:dsc -# Sort by name (A-Z) -sized --sort name:asc +# Primary sort by Type, secondary sort by Size descending +sized --sort type:asc,size:dsc ``` ### Minimum Size (`-m` / `--min-size`) -Hide entries smaller than a specific size to reduce noise. +Hide entries smaller than a specific size to reduce noise. Supports standard units (KB, MB, GB, etc.). -**Examples**: ```bash -# Show only files/dirs larger than 10 MB -sized -m 10MB - -# Show only files/dirs larger than 1 GB -sized --min-size 1GB +# Show only items larger than 100MB +sized -m 100MB ``` -### Depth Control (`-d` / `--depth`) -Limit the recursion depth for the *calculation*. Note that the display currently shows immediate children, but this flag controls how deep `sized` looks to calculate directory sizes. -*(Note: Deeply nested directory sizes are always fully calculated unless limited)* +### Limiting Results (`-n` / `--number`) +Limit the number of rows displayed in each table. ```bash -sized -d 2 +# Show only the top 10 largest items +sized --sort size:dsc -n 10 ``` ## Output Formats (`--format`) @@ -95,54 +100,77 @@ sized --format text ``` ### CSV -Comma-Separated Values, suitable for spreadsheets. +Comma-Separated Values. ```bash sized --format csv ``` -Columns: `path`, `size_bytes`, `files`, `dirs` ### JSON -Computed metrics in NDJSON (Newline Delimited JSON) format. +Computed metrics in NDJSON format. ```bash sized --format json ``` ## Advanced Features -### Path Display -- **Default**: Relative paths (`./folder`) -- **Full Path**: Use `-f` or `--path-full` to see absolute paths (`/users/name/folder`). -- **Relative Path**: Use `--path-relative` to explicitly force relative paths. +### Unit System (`--units`) +Switch between Binary (IEC) and Decimal (SI) units for the output. -### Concurrency (`-j` / `--threads`) -`sized` uses parallel processing. By default, it uses a number of threads equal to your CPU cores. You can limit this for system stability or increase it (though usually not clear). +- **Binary** (Default): GiB, MiB, KiB (multiples of 1024). +- **Decimal**: GB, MB, KB (multiples of 1000). ```bash -# Limit to 4 threads -sized -j 4 +# Use decimal units (SI) +sized --units decimal ``` -### Gitignore Support (`-i` / `--ignore`) -Respect `.gitignore` and `.ignore` files. This is useful for checking the size of a project *as it would be committed*, ignoring `target/`, `node_modules/`, etc. +### Precision (`--precision`) +Specify the number of decimal places for formatted sizes. Defaults to `2`. ```bash +# Higher precision for small files +sized --precision 4 +``` + +### Comparison Mode (`--compare`) +Compare the total physical size of a directory against its "filtered" size (files that would be included in a commit, respecting `.gitignore`). + +```bash +# Show both total and non-ignored metrics +sized -i --compare +``` +This adds **"Filtered"** rows and headers to the output, allowing you to see how much space is consumed by ignored files (like `node_modules` or `target`). + +### Gitignore Support (`-i` / `--ignore`) +Respect `.ignore` and `.gitignore` files during traversal. This is highly recommended for developer workflows. + +```bash +# Exclude git-ignored files from calculations sized -i ``` -### Exporting Data (`--save`) -Save the output directly to a file. If you use `_AUTO_` (or provide no argument to the flag), it generates a filename with the current timestamp. +### Concurrency (`-j` / `--threads`) +`sized` uses parallel processing powered by `rayon` and `ignore`. By default, it uses a number of threads equal to your logical CPU cores. ```bash -# Save to a specific file -sized --save report.txt +# Limit to 4 threads on a high-core system +sized -j 4 +``` -# Save to a timestamped file (e.g., 20250122-120000_sized_report.txt) +### Exporting Data (`--save`) +Save the output directly to a file. If no filename is provided, `sized` generates a timestamped one (e.g., `sized_report_20260122_1430.txt`). + +```bash +# Save to an auto-generated file sized --save + +# Save to a specific path +sized --save reports/my_audit.json --format json ``` ## Shell Completions -Generate shell completion scripts for Bash, Zsh, Fish, PowerShell, or Elvish. +Generate shell completion scripts for Bash, Zsh, or Fish. ```bash # Generate for Zsh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a5c8057 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PREFIX ?= /usr/local +BINDIR = $(PREFIX)/bin +MANDIR = $(PREFIX)/share/man/man1 + +.PHONY: all build install clean help + +all: build + +build: + cargo build --release + +install: build + install -d $(BINDIR) + install -m 755 target/release/sized $(BINDIR)/sized + install -d $(MANDIR) + install -m 644 sized.1 $(MANDIR)/sized.1 + +clean: + cargo clean + +help: + @echo "Usage:" + @echo " make build - Build the optimized binary" + @echo " make install - Install binary and man page (may require sudo)" + @echo " make clean - Remove build artifacts" diff --git a/README.md b/README.md index 14438d3..f792cb8 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,71 @@ A fast, concurrent, and feature-rich command-line tool for visualizing disk usag - **Fast & Concurrent**: Uses `rayon` to process directories in parallel, making it extremely fast on modern multi-core systems. - **Rich Output**: Beautifully formatted tables with colors, distinguishing files and directories. -- **Dual Units**: Displays sizes in both Binary (MiB, GiB) and Decimal (MB, GB) units for clarity. +- **Physical vs logical**: Clearly separates **Apparent Size** (logical) from **Disk Usage** (actual blocks), essential for cloud files and sparse storage. +- **Unit Selection**: Switch between Binary (IEC) and Decimal (SI) unit systems via `--units`. +- **Precision control**: Configure decimal places using `--precision`. - **Advanced Filtering**: Filter by minimum size (`-m 10MB`) to find large items quickly. -- **Sorting**: Flexible sorting by name, type, size, or block count (`--sort`). +- **Sorting**: Flexible sorting by name, type, size (apparent), or blocks (usage) (`--sort`). - **Gitignore Support**: Respects `.gitignore` and `.ignore` files to keep output clean (`-i`). - **Export Options**: Export data to CSV or JSON for further analysis (`--format csv/json`). - **Save to File**: Automatically save reports with timestamped filenames (`--save`). ## Installation -### From Source -Ensure you have Rust and Cargo installed. Clone the repository and install: +### 🚀 Direct Download (macOS & Linux) +Download the latest archive for your architecture from the [Releases](https://gitea.speelman.ca/gamertan/sized/releases) page. +1. **Extract the archive**: + ```bash + tar -xzf sized-v0.2.0-Darwin-arm64.tar.gz + ``` + +2. **Install Binary & Man Page**: + ```bash + # Move binary to path + sudo mv sized /usr/local/bin/ + + # Install man page + sudo mkdir -p /usr/local/share/man/man1 + sudo cp sized.1 /usr/local/share/man/man1/ + ``` + +3. **macOS Security (Gatekeeper)**: + Since the binary isn't code-signed for the App Store, macOS may block it. To allow it: + ```bash + sudo xattr -d com.apple.quarantine /usr/local/bin/sized + ``` + *Alternatively, run `sized` once, let it fail, then go to **System Settings > Privacy & Security** and click **"Allow Anyway"**.* + +### 🍺 Homebrew (macOS & Linux) +If you have a homebrew tap: +```bash +brew install gamertan/tap/sized +``` + +### 📦 Debian / Ubuntu (.deb) +1. Download the `.deb` package from the [Releases](https://gitea.speelman.ca/gamertan/sized/releases) page. +2. Install using `dpkg`: + ```bash + sudo dpkg -i sized_0.2.0_amd64.deb + ``` + +### 🦀 From Source (Rust toolchain required) +```bash +git clone https://gitea.speelman.ca/gamertan/sized.git +cd sized +make install # Installs binary and man page +``` + +Alternatively, via Cargo: ```bash cargo install --path . ``` +## Documentation +- **[Manual](MANUAL.md)**: Detailed explanations of all flags and features. +- **[Man Page](sized.1)**: Standard unix man pages (installed via `make install`). + ## Usage ### Basic Usage @@ -41,7 +90,9 @@ sized /path/to/directory |------|-------------|---------| | `-d`, `--depth` | Recursion depth (0 = current dir only) | `sized -d 1` | | `-m`, `--min-size` | Filter by minimum size | `sized -m 100MB` | -| `-s`, `--sort` | Sort columns (size, name, type, blocks) | `sized --sort size:asc` | +| `--sort` | Sort columns (name, type, size, blocks) | `sized --sort size:asc` | +| `--units` | Unit system (binary, decimal) | `sized --units decimal` | +| `--precision` | Decimal places for sizes | `sized --precision 3` | | `-f`, `--path-full` | Force absolute paths in headers | `sized -f` | | `-i`, `--ignore` | Respect .gitignore files | `sized -i` | | `-j`, `--threads` | Set number of threads | `sized -j 4` | diff --git a/SHIPMENT.md b/SHIPMENT.md new file mode 100644 index 0000000..920a7d3 --- /dev/null +++ b/SHIPMENT.md @@ -0,0 +1,56 @@ +# Project Release Process + +This document defines the process for versioning and distributing the `sized` project independently of the Git hosting provider (GitHub, GitLab, Gitea). + +## 1. Versioning and Tagging + +The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +1. Synchronize the version in `Cargo.toml`. +2. Update `CHANGELOG.md` with the release notes. +3. Commit and create a git tag: + ```bash + git tag -a v0.2.0 -m "Release v0.2.0" + ``` + +## 2. Asset Generation + +The `scripts/release.sh` script is the authoritative way to generate release assets locally or in any CI environment. + +```bash +./scripts/release.sh +``` + +This script generates: +- **Optimized Binary**: The production-ready `sized` executable. +- **Documentation**: The `sized.1` man page. +- **Shell Completions**: Scripts for Bash, Zsh, and Fish. +- **System Installers**: A `.deb` package for Debian-based Linux distributions. + +All assets are gathered in the `dist/v/` directory. + +## 3. Distribution Channels + +### crates.io +To update the project on the central Rust registry: +```bash +cargo publish +``` + +### System Package Managers +For Homebrew, GitLab, or Gitea-based distribution: +1. Push the git tag to your SCM. +2. Run the release script to generate assets. +3. Attach the contents of the `dist/` folder to your SCM's "Release" or "Tag" entry. +4. Update downstream formulas (like `homebrew-tap`) with the new source URL and SHA256 of the generated tarball. + +## 4. Automation & Hooks + +To automate asset generation locally, you can use a git `post-checkout` or a wrapper script. Since gitea and gitlab use different CI formats, it is recommended to simply call `./scripts/release.sh` within your preferred CI runner (e.g., `gitlab-ci.yml` or `gitea-actions`). + +## 5. Manual Installation +For system-wide installation from source, use the `Makefile`: +```bash +make install +``` +Defaults to `PREFIX=/usr/local`. Overridable via `PREFIX=/your/path make install`. diff --git a/benches/benchmark.rs b/benches/benchmark.rs index 35c78cb..607f6fc 100644 --- a/benches/benchmark.rs +++ b/benches/benchmark.rs @@ -26,7 +26,7 @@ fn criterion_benchmark(c: &mut Criterion) { let path = temp_dir.path(); c.bench_function("build_tree small", |b| { - b.iter(|| build_tree(black_box(path), black_box(false))) + b.iter(|| build_tree(black_box(path), black_box(false), black_box(false))) }); } diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..8e2fe0d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e + +VERSION=$(grep '^version =' Cargo.toml | cut -d '"' -f 2) +DIST_DIR="dist/v$VERSION" + +echo "Building release assets for sized v$VERSION..." + +# 1. Clean and Prepare +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +# 2. Build Release Binary +cargo build --release + +# 3. Generate Man Page +cargo run --release -- --generate-man-page "$DIST_DIR" + +# 4. Generate Completions +cargo run --release -- --completions bash > "$DIST_DIR/sized.bash" +cargo run --release -- --completions zsh > "$DIST_DIR/_sized" +cargo run --release -- --completions fish > "$DIST_DIR/sized.fish" + +# 5. Build Debian Package (if cargo-deb is installed) +if command -v cargo-deb &> /dev/null; then + echo "Building Debian package..." + # On macOS, we use --no-strip to avoid 'unrecognized option: --strip-unneeded' + # and --no-build because we already built the release binary. + cargo deb --no-build --no-strip + cp target/debian/*.deb "$DIST_DIR/" + echo "Note: .deb package contains the binary for $(uname -s)-$(uname -m)" +else + echo "Warning: cargo-deb not found. Skipping .deb packaging." +fi + +# 6. Build Alpine Package (Placeholder/Hook) +# Note: For real .apk building, one usually uses a docker container or abuild. +# This serves as a reminder for Alpine users. +if [ -f "APKBUILD" ] && command -v abuild &> /dev/null; then + echo "Building Alpine package..." + abuild -r +fi + +# 7. Create General Release Tarball +echo "Creating release tarball..." +tar -czf "dist/sized-v$VERSION-$(uname -s)-$(uname -m).tar.gz" -C "$DIST_DIR" . + +# 8. Copy Binary +cp target/release/sized "$DIST_DIR/" + +echo "Success! Assets are ready in $DIST_DIR" +ls -F "$DIST_DIR" +echo "Tarball: dist/sized-v$VERSION-$(uname -s)-$(uname -m).tar.gz" diff --git a/sized.1 b/sized.1 new file mode 100644 index 0000000..389ea24 --- /dev/null +++ b/sized.1 @@ -0,0 +1,74 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH sized 1 "sized 0.2.0" +.SH NAME +sized +.SH SYNOPSIS +\fBsized\fR [\fB\-v\fR|\fB\-\-version\fR] [\fB\-m\fR|\fB\-\-min\-size\fR] [\fB\-n\fR|\fB\-\-number\fR] [\fB\-\-sort\fR] [\fB\-d\fR|\fB\-\-depth\fR] [\fB\-f\fR|\fB\-\-path\-full\fR] [\fB\-\-path\-relative\fR] [\fB\-j\fR|\fB\-\-threads\fR] [\fB\-\-completions\fR] [\fB\-i\fR|\fB\-\-ignore\fR] [\fB\-\-format\fR] [\fB\-\-save\fR] [\fB\-\-precision\fR] [\fB\-\-units\fR] [\fB\-\-compare\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIPATH\fR] +.SH DESCRIPTION +.SH OPTIONS +.TP +\fB\-v\fR, \fB\-\-version\fR +Print version +.TP +\fB\-m\fR, \fB\-\-min\-size\fR \fI\fR +Minimum size filter (e.g. "10MB", "1gb") +.TP +\fB\-n\fR, \fB\-\-number\fR \fI\fR +Limit the number of results per table +.TP +\fB\-\-sort\fR \fI\fR +Sort by columns (e.g. "type:asc,size:dsc"). defaults to size:dsc. Columns: name, type, size, blocks +.TP +\fB\-d\fR, \fB\-\-depth\fR \fI\fR [default: 0] +Depth to traverse (0 = only immediate children) +.TP +\fB\-f\fR, \fB\-\-path\-full\fR +Display full absolute paths +.TP +\fB\-\-path\-relative\fR +Display paths relative to current directory (default) +.TP +\fB\-j\fR, \fB\-\-threads\fR \fI\fR +Number of threads to use (defaults to available logical CPUs) +.TP +\fB\-\-completions\fR \fI\fR +Generate shell completions +.br + +.br +[\fIpossible values: \fRbash, elvish, fish, powershell, zsh] +.TP +\fB\-i\fR, \fB\-\-ignore\fR +Respect .gitignore and .ignore files +.TP +\fB\-\-format\fR \fI\fR [default: text] +Output format +.br + +.br +[\fIpossible values: \fRtext, csv, json] +.TP +\fB\-\-save\fR [\fI\fR] +Save output to file (optional filename, defaults to timestamped name) +.TP +\fB\-\-precision\fR \fI\fR [default: 2] +Precision for size output (decimal places) +.TP +\fB\-\-units\fR \fI\fR [default: binary] +Unit system to use +.br + +.br +[\fIpossible values: \fRbinary, decimal] +.TP +\fB\-\-compare\fR +Show comparison between total and non\-ignored files +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +[\fIPATH\fR] [default: .] +Directory to analyze +.SH VERSION +v0.2.0 diff --git a/src/lib.rs b/src/lib.rs index 6d256e1..42a4a48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,6 +72,22 @@ pub struct Args { /// Save output to file (optional filename, defaults to timestamped name) #[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")] pub save: Option, + + /// Precision for size output (decimal places) + #[arg(long, default_value = "2")] + pub precision: usize, + + /// Unit system to use + #[arg(long, value_enum, default_value_t = UnitSystem::Binary)] + pub units: UnitSystem, + + /// Show comparison between total and non-ignored files + #[arg(long)] + pub compare: bool, + + /// Generate man page to the specified output directory + #[arg(long, hide = true)] + pub generate_man_page: Option, } #[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] @@ -81,6 +97,12 @@ pub enum OutputFormat { Json, } +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum UnitSystem { + Binary, + Decimal, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum SortColumn { Name, @@ -124,7 +146,10 @@ pub struct Node { pub path: PathBuf, pub size_bytes: u64, pub blocks: u64, + pub size_bytes_filtered: u64, + pub blocks_filtered: u64, pub entry_type: EntryType, + pub accessible: bool, #[serde(skip)] pub children: Vec, } @@ -148,6 +173,8 @@ impl std::fmt::Display for EntryType { pub struct JsonMetrics { pub size_bytes: u64, pub blocks: u64, + pub size_bytes_filtered: u64, + pub blocks_filtered: u64, } #[derive(Serialize)] @@ -155,6 +182,7 @@ pub struct JsonEntry<'a> { pub path: &'a PathBuf, pub metrics: JsonMetrics, pub entry_type: String, + pub accessible: bool, } #[derive(Serialize)] @@ -162,7 +190,10 @@ pub struct JsonReport<'a> { pub path: &'a PathBuf, pub total_size: u64, pub blocks: u64, + pub total_size_filtered: u64, + pub blocks_filtered: u64, pub entries: Vec>, + pub accessible: bool, } pub fn run(args: Args, mut writer: &mut dyn Write) { @@ -173,6 +204,19 @@ pub fn run(args: Args, mut writer: &mut dyn Write) { return; } + if let Some(out_dir) = args.generate_man_page { + let cmd = Args::command(); + let man = clap_mangen::Man::new(cmd); + let mut buffer: Vec = Default::default(); + man.render(&mut buffer).expect("Failed to render man page"); + + std::fs::create_dir_all(&out_dir).expect("Failed to create man page directory"); + let file_path = out_dir.join("sized.1"); + std::fs::write(&file_path, buffer).expect("Failed to write man page"); + println!("Man page generated at {}", file_path.display()); + return; + } + if let Some(threads) = args.threads { rayon::ThreadPoolBuilder::new() .num_threads(threads) @@ -205,7 +249,7 @@ pub fn run(args: Args, mut writer: &mut dyn Write) { writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header"); } - let root_node = build_tree(&target_path, args.ignore); + let root_node = build_tree(&target_path, args.ignore, args.compare); if root_node.size_bytes < min_bytes { if args.format == OutputFormat::Text { @@ -217,47 +261,109 @@ pub fn run(args: Args, mut writer: &mut dyn Write) { 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(); +pub fn build_tree(path: &Path, ignore: bool, compare: bool) -> Node { + let metadata = path.symlink_metadata(); if let Ok(meta) = metadata { - if meta.is_file() { + // Treat symlinks as files (nodes) but do not recurse + if meta.is_file() || meta.is_symlink() { return Node { path: path.to_path_buf(), size_bytes: meta.len(), blocks: meta.blocks(), + size_bytes_filtered: meta.len(), // Single file is its own filtered size for now + blocks_filtered: meta.blocks(), entry_type: EntryType::File, + accessible: true, children: vec![], }; } } - let walker = WalkBuilder::new(path) + // Check if we can read the directory (handle permissions) + if let Err(e) = std::fs::read_dir(path) { + if e.kind() == std::io::ErrorKind::PermissionDenied { + // Return an "empty" directory node marked as inaccessible + let meta = path.symlink_metadata().ok(); + let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let blocks = meta.as_ref().map(|m| m.blocks()).unwrap_or(0); + return Node { + path: path.to_path_buf(), + size_bytes: size, + blocks, + size_bytes_filtered: size, + blocks_filtered: blocks, + entry_type: EntryType::Dir, + accessible: false, + children: vec![], + }; + } + } + + // Total walker (always everything if compare is true, else respects 'ignore' arg) + let total_ignore = if compare { false } else { ignore }; + let walker_total = WalkBuilder::new(path) .standard_filters(false) .hidden(false) - .git_ignore(ignore) - .ignore(ignore) + .git_ignore(total_ignore) + .ignore(total_ignore) .max_depth(Some(1)) .build(); - let child_paths: Vec = walker + let child_paths_total: Vec = walker_total .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 + // Filtered set (only if compare is true) + let non_ignored_set: std::collections::HashSet = if compare { + let walker_filtered = WalkBuilder::new(path) + .standard_filters(false) + .hidden(false) + .git_ignore(true) + .ignore(true) + .max_depth(Some(1)) + .build(); + + walker_filtered + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path() != path) + .map(|e| e.path().to_path_buf()) + .collect() + } else { + std::collections::HashSet::new() + }; + + let children: Vec = child_paths_total .par_iter() - .map(|p| build_tree(p, ignore)) + .map(|p| build_tree(p, ignore, compare)) .collect(); - let (size_bytes, blocks) = children - .iter() - .fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks)); + let mut size_bytes = 0; + let mut blocks = 0; + let mut size_bytes_filtered = 0; + let mut blocks_filtered = 0; + + for child in &children { + size_bytes += child.size_bytes; + blocks += child.blocks; + + if compare { + if non_ignored_set.contains(&child.path) { + size_bytes_filtered += child.size_bytes_filtered; + blocks_filtered += child.blocks_filtered; + } + } else { + size_bytes_filtered += child.size_bytes_filtered; + blocks_filtered += child.blocks_filtered; + } + } let (self_size, self_blocks) = path - .metadata() + .symlink_metadata() .map(|m| (m.len(), m.blocks())) .unwrap_or((0, 0)); @@ -265,7 +371,10 @@ pub fn build_tree(path: &Path, ignore: bool) -> Node { path: path.to_path_buf(), size_bytes: size_bytes + self_size, blocks: blocks + self_blocks, + size_bytes_filtered: size_bytes_filtered + self_size, // self is always part of self + blocks_filtered: blocks_filtered + self_blocks, entry_type: EntryType::Dir, + accessible: true, children, } } @@ -387,7 +496,7 @@ fn print_output( depth: usize, ) { match args.format { - OutputFormat::Text => print_text(writer, parent_node, children, relative_path, depth), + OutputFormat::Text => print_text(writer, parent_node, children, args, relative_path, depth), OutputFormat::Csv => print_csv(writer, children), OutputFormat::Json => print_json(writer, parent_node, children), } @@ -397,6 +506,7 @@ fn print_text( writer: &mut dyn Write, parent_node: &Node, children: &[&Node], + args: &Args, relative_path: &str, depth: usize, ) { @@ -405,42 +515,112 @@ fn print_text( } 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() + let unit_type = match args.units { + UnitSystem::Binary => UnitType::Binary, + UnitSystem::Decimal => UnitType::Decimal, + }; + + if parent_node.accessible { + let total_apparent = Byte::from_u64(parent_node.size_bytes).get_appropriate_unit(unit_type); + let total_usage = Byte::from_u64(parent_node.blocks * 512).get_appropriate_unit(unit_type); + + let mut summary = format!( + "Total Apparent: {} / Disk Usage: {} ({} blocks)", + format!( + "{:.precision$} {}", + total_apparent.get_value(), + total_apparent.get_unit(), + precision = args.precision + ) .bold() .green(), - parent_node.size_bytes.to_string().bold().green(), - parent_node.blocks - ) - .ok(); + format!( + "{:.precision$} {}", + total_usage.get_value(), + total_usage.get_unit(), + precision = args.precision + ) + .bold() + .green(), + parent_node.blocks + ); + + if args.compare { + let total_apparent_filtered = + Byte::from_u64(parent_node.size_bytes_filtered).get_appropriate_unit(unit_type); + let total_usage_filtered = + Byte::from_u64(parent_node.blocks_filtered * 512).get_appropriate_unit(unit_type); + + summary.push_str(&format!( + "\nFiltered Apparent: {} / Filtered Disk Usage: {} ({} blocks)", + format!( + "{:.precision$} {}", + total_apparent_filtered.get_value(), + total_apparent_filtered.get_unit(), + precision = args.precision + ) + .bold() + .cyan(), + format!( + "{:.precision$} {}", + total_usage_filtered.get_value(), + total_usage_filtered.get_unit(), + precision = args.precision + ) + .bold() + .cyan(), + parent_node.blocks_filtered + )); + } + + writeln!(writer, "{}", summary).ok(); + } else { + writeln!(writer, "Total size: {}", "Access Denied".bold().red()).ok(); + } if children.is_empty() { - writeln!(writer, "(No children)").ok(); + if parent_node.accessible { + 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)") + .set_content_arrangement(ContentArrangement::Dynamic); + + let mut headers = vec![ + Cell::new("Name").add_attribute(Attribute::Bold), + Cell::new("Type").add_attribute(Attribute::Bold), + Cell::new("Apparent") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + Cell::new("Disk Usage") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + ]; + + if args.compare { + headers.push( + Cell::new("App. Filtered") .add_attribute(Attribute::Bold) .set_alignment(CellAlignment::Right), - Cell::new("Size (Bytes)") + ); + headers.push( + Cell::new("Disk Filtered") .add_attribute(Attribute::Bold) .set_alignment(CellAlignment::Right), - Cell::new("Blocks") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Right), - ]); + ); + } + + headers.push( + Cell::new("Blocks") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + ); + + table.set_header(headers); for child in children { let name = child.path.file_name().unwrap_or_default().to_string_lossy(); @@ -450,25 +630,100 @@ fn print_text( Color::Cyan }; - let child_byte = Byte::from_u64(child.size_bytes); + if child.accessible { + let child_byte = Byte::from_u64(child.size_bytes).get_appropriate_unit(unit_type); + let disk_usage_byte = + Byte::from_u64(child.blocks * 512).get_appropriate_unit(unit_type); - 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 + let mut row = vec![ + Cell::new(name).fg(color), + Cell::new(&child.entry_type).fg(Color::Yellow), + Cell::new(format!( + "{:.precision$} {}", + child_byte.get_value(), + child_byte.get_unit(), + precision = args.precision + )) .fg(Color::Green) .set_alignment(CellAlignment::Right), - Cell::new(child.blocks) + Cell::new(format!( + "{:.precision$} {}", + disk_usage_byte.get_value(), + disk_usage_byte.get_unit(), + precision = args.precision + )) .fg(Color::Green) .set_alignment(CellAlignment::Right), - ]); + ]; + + if args.compare { + let child_byte_filtered = + Byte::from_u64(child.size_bytes_filtered).get_appropriate_unit(unit_type); + let disk_usage_byte_filtered = + Byte::from_u64(child.blocks_filtered * 512).get_appropriate_unit(unit_type); + + row.push( + Cell::new(format!( + "{:.precision$} {}", + child_byte_filtered.get_value(), + child_byte_filtered.get_unit(), + precision = args.precision + )) + .fg(Color::Cyan) + .set_alignment(CellAlignment::Right), + ); + row.push( + Cell::new(format!( + "{:.precision$} {}", + disk_usage_byte_filtered.get_value(), + disk_usage_byte_filtered.get_unit(), + precision = args.precision + )) + .fg(Color::Cyan) + .set_alignment(CellAlignment::Right), + ); + } + + row.push( + Cell::new(child.blocks) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + ); + + table.add_row(row); + } else { + let mut row = vec![ + Cell::new(name).fg(color), + Cell::new(&child.entry_type).fg(Color::Yellow), + Cell::new("N/A") + .fg(Color::Red) + .set_alignment(CellAlignment::Right), + Cell::new("Access Denied") + .fg(Color::Red) + .set_alignment(CellAlignment::Right), + ]; + + if args.compare { + row.push( + Cell::new("-") + .fg(Color::Red) + .set_alignment(CellAlignment::Right), + ); + row.push( + Cell::new("-") + .fg(Color::Red) + .set_alignment(CellAlignment::Right), + ); + } + + row.push( + Cell::new("-") + .fg(Color::Red) + .set_alignment(CellAlignment::Right), + ); + + table.add_row(row); + } } writeln!(writer, "{}", table).ok(); } @@ -496,8 +751,11 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { metrics: JsonMetrics { size_bytes: c.size_bytes, blocks: c.blocks, + size_bytes_filtered: c.size_bytes_filtered, + blocks_filtered: c.blocks_filtered, }, entry_type: c.entry_type.to_string(), + accessible: c.accessible, }) .collect(); @@ -505,7 +763,10 @@ fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { path: &parent_node.path, total_size: parent_node.size_bytes, blocks: parent_node.blocks, + total_size_filtered: parent_node.size_bytes_filtered, + blocks_filtered: parent_node.blocks_filtered, entries: entries_view, + accessible: parent_node.accessible, }; serde_json::to_writer(&mut *writer, &report).ok(); @@ -541,7 +802,7 @@ mod tests { .write_all(b"Hello") .unwrap(); - let node = build_tree(temp_dir.path(), false); + let node = build_tree(temp_dir.path(), false, false); assert_eq!(node.entry_type, EntryType::Dir); assert!(!node.children.is_empty()); -- 2.54.0