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
This commit is contained in:
@@ -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/),
|
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).
|
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
|
## [0.1.0] - 2026-01-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+18
-2
@@ -285,6 +285,16 @@ version = "0.7.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
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]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -868,6 +878,12 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roff"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.40.0"
|
version = "1.40.0"
|
||||||
@@ -999,13 +1015,14 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sized"
|
name = "sized"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atty",
|
"atty",
|
||||||
"byte-unit",
|
"byte-unit",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
"clap_mangen",
|
||||||
"colored",
|
"colored",
|
||||||
"comfy-table",
|
"comfy-table",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1015,7 +1032,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"strum",
|
"strum",
|
||||||
"walkdir",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sized"
|
name = "sized"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
walkdir = "2.3"
|
|
||||||
rayon = "1.8"
|
rayon = "1.8"
|
||||||
humansize = "2.1"
|
humansize = "2.1"
|
||||||
colored = "2.0"
|
colored = "2.0"
|
||||||
@@ -19,3 +19,4 @@ csv = "1.3"
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
clap_complete = "4.4"
|
clap_complete = "4.4"
|
||||||
atty = "0.2"
|
atty = "0.2"
|
||||||
|
clap_mangen = "0.2.31"
|
||||||
|
|||||||
@@ -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 <repository_url>
|
||||||
|
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 <COLUMN:DIRECTION>`
|
||||||
|
- **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`
|
||||||
@@ -55,9 +55,32 @@ sized /path/to/directory
|
|||||||
sized . -d 1 -i -m 50MB --format csv --save
|
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
|
```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
|
## License
|
||||||
|
|||||||
+222
-206
@@ -5,15 +5,16 @@ use clap_complete::{generate, Shell};
|
|||||||
use colored::*;
|
use colored::*;
|
||||||
use comfy_table::presets::UTF8_FULL;
|
use comfy_table::presets::UTF8_FULL;
|
||||||
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
|
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
|
||||||
|
use csv::WriterBuilder;
|
||||||
use humansize::{format_size, BINARY, DECIMAL};
|
use humansize::{format_size, BINARY, DECIMAL};
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
@@ -120,17 +121,50 @@ impl FromStr for SortDirection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize, Debug)]
|
||||||
struct PathMetrics {
|
struct Node {
|
||||||
|
path: PathBuf,
|
||||||
|
size_bytes: u64,
|
||||||
|
blocks: u64,
|
||||||
|
entry_type: EntryType,
|
||||||
|
#[serde(skip)]
|
||||||
|
children: Vec<Node>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum EntryType {
|
||||||
|
File,
|
||||||
|
Dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for EntryType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
EntryType::File => write!(f, "File"),
|
||||||
|
EntryType::Dir => write!(f, "Dir"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct JsonMetrics {
|
||||||
size_bytes: u64,
|
size_bytes: u64,
|
||||||
blocks: u64,
|
blocks: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct EntryData {
|
struct JsonEntry<'a> {
|
||||||
path: PathBuf,
|
path: &'a PathBuf,
|
||||||
metrics: PathMetrics,
|
metrics: JsonMetrics,
|
||||||
entry_type: String, // "File" or "Dir"
|
entry_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct JsonReport<'a> {
|
||||||
|
path: &'a PathBuf,
|
||||||
|
total_size: u64,
|
||||||
|
blocks: u64,
|
||||||
|
entries: Vec<JsonEntry<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -200,157 +234,155 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if args.format == OutputFormat::Csv {
|
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");
|
writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header");
|
||||||
}
|
}
|
||||||
|
|
||||||
if target_path.is_file() {
|
// Build the tree!
|
||||||
// Single file case
|
let root_node = build_tree(&target_path, args.ignore);
|
||||||
let metadata = target_path.metadata().expect("Failed to get metadata");
|
|
||||||
let metrics = PathMetrics {
|
if root_node.size_bytes < min_bytes {
|
||||||
size_bytes: metadata.len(),
|
// Even the root is too small?
|
||||||
blocks: metadata.blocks(),
|
if args.format == OutputFormat::Text {
|
||||||
};
|
writeln!(writer, "Root directory is smaller than minimum size.").ok();
|
||||||
if metrics.size_bytes < min_bytes {
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to relative paths unless --path-full is specified
|
// Render output
|
||||||
let show_relative = !args.path_full;
|
process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria);
|
||||||
let relative_path = if show_relative {
|
}
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
|
||||||
target_path
|
/// Builds the directory tree in memory, calculating sizes in a single pass.
|
||||||
.strip_prefix(&cwd)
|
/// Uses Rayon for parallelism.
|
||||||
.unwrap_or(&target_path)
|
fn build_tree(path: &Path, ignore: bool) -> Node {
|
||||||
.display()
|
let metadata = path.metadata();
|
||||||
.to_string()
|
|
||||||
} else {
|
if let Ok(meta) = metadata {
|
||||||
target_path
|
if meta.is_file() {
|
||||||
.canonicalize()
|
return Node {
|
||||||
.unwrap_or(target_path.clone())
|
path: path.to_path_buf(),
|
||||||
.display()
|
size_bytes: meta.len(),
|
||||||
.to_string()
|
blocks: meta.blocks(),
|
||||||
|
entry_type: EntryType::File,
|
||||||
|
children: vec![],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
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
|
// It's a directory (or error, which we treat as zero-size usually, but here we try to read)
|
||||||
process_directory_recursive(
|
// Use ignore::WalkBuilder to get *immediate children only* respecting gitignore
|
||||||
&mut writer,
|
let walker = WalkBuilder::new(path)
|
||||||
&target_path,
|
.standard_filters(false) // We manually control ignore
|
||||||
0,
|
.hidden(false) // Show hidden files
|
||||||
&args,
|
.git_ignore(ignore)
|
||||||
min_bytes,
|
.ignore(ignore)
|
||||||
&sort_criteria,
|
.max_depth(Some(1)) // ONLY immediate children
|
||||||
);
|
.build();
|
||||||
|
|
||||||
|
let child_paths: Vec<PathBuf> = walker
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path() != path) // Exclude self
|
||||||
|
.map(|e| e.path().to_path_buf())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Recursively build children in parallel
|
||||||
|
let children: Vec<Node> = child_paths
|
||||||
|
.par_iter()
|
||||||
|
.map(|p| build_tree(p, ignore))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sum up stats
|
||||||
|
let (size_bytes, blocks) = children
|
||||||
|
.iter()
|
||||||
|
.fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks));
|
||||||
|
|
||||||
|
// Add self-size (directory entry itself has size)
|
||||||
|
let (self_size, self_blocks) = path
|
||||||
|
.metadata()
|
||||||
|
.map(|m| (m.len(), m.blocks()))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
|
Node {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
size_bytes: size_bytes + self_size,
|
||||||
|
blocks: blocks + self_blocks,
|
||||||
|
entry_type: EntryType::Dir,
|
||||||
|
children,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_directory_recursive(
|
fn process_node_recursive(
|
||||||
writer: &mut dyn Write,
|
writer: &mut dyn Write,
|
||||||
path: &PathBuf,
|
node: &Node,
|
||||||
current_depth: usize,
|
current_depth: usize,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
min_bytes: u64,
|
min_bytes: u64,
|
||||||
sort_criteria: &[(SortColumn, SortDirection)],
|
sort_criteria: &[(SortColumn, SortDirection)],
|
||||||
) {
|
) {
|
||||||
// 1. Get immediate children
|
if node.children.is_empty() && node.entry_type == EntryType::Dir {
|
||||||
let walker = WalkBuilder::new(path)
|
// Empty directory or leaf in terms of permissions/ignore
|
||||||
.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<EntryData> = 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,
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
// Prepare children for display
|
||||||
|
let mut display_children: Vec<&Node> = node
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.size_bytes >= min_bytes)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total_metrics = calc_total(&sized_entries);
|
|
||||||
|
|
||||||
// Filter
|
|
||||||
if min_bytes > 0 {
|
|
||||||
sized_entries.retain(|e| e.metrics.size_bytes >= min_bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
sized_entries.sort_by(|a, b| compare_entries(a, b, sort_criteria));
|
display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria));
|
||||||
|
|
||||||
// Limit (per table)
|
// Limit
|
||||||
if let Some(n) = args.number {
|
if let Some(n) = args.number {
|
||||||
if n < sized_entries.len() {
|
if n < display_children.len() {
|
||||||
sized_entries.truncate(n);
|
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
|
// Default to relative paths unless --path-full is specified
|
||||||
let show_relative = !args.path_full;
|
let show_relative = !args.path_full;
|
||||||
let relative_path = if show_relative {
|
let relative_path = if show_relative {
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
let cwd = std::env::current_dir().unwrap_or_default();
|
||||||
path.strip_prefix(&cwd)
|
node.path
|
||||||
.unwrap_or(path)
|
.strip_prefix(&cwd)
|
||||||
|
.unwrap_or(&node.path)
|
||||||
.display()
|
.display()
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
path.canonicalize()
|
node.path
|
||||||
.unwrap_or(path.clone())
|
.canonicalize()
|
||||||
|
.unwrap_or(node.path.clone())
|
||||||
.display()
|
.display()
|
||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
print_output(
|
print_output(
|
||||||
writer,
|
writer,
|
||||||
path,
|
&node,
|
||||||
&total_metrics,
|
&display_children,
|
||||||
&sized_entries,
|
|
||||||
args,
|
args,
|
||||||
&relative_path,
|
&relative_path,
|
||||||
|
current_depth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Recurse if needed
|
// Recurse
|
||||||
if current_depth < args.depth {
|
if current_depth < args.depth {
|
||||||
for entry in sized_entries {
|
for child in display_children {
|
||||||
if entry.entry_type == "Dir" {
|
if child.entry_type == EntryType::Dir {
|
||||||
process_directory_recursive(
|
process_node_recursive(
|
||||||
writer,
|
writer,
|
||||||
&entry.path,
|
child,
|
||||||
current_depth + 1,
|
current_depth + 1,
|
||||||
args,
|
args,
|
||||||
min_bytes,
|
min_bytes,
|
||||||
@@ -361,64 +393,13 @@ fn process_directory_recursive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_path_metrics(path: &PathBuf, ignore: bool) -> PathMetrics {
|
fn compare_nodes(a: &Node, b: &Node, criteria: &[(SortColumn, SortDirection)]) -> Ordering {
|
||||||
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 {
|
|
||||||
for (col, dir) in criteria {
|
for (col, dir) in criteria {
|
||||||
let order = match col {
|
let order = match col {
|
||||||
SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()),
|
SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()),
|
||||||
SortColumn::Type => a.entry_type.cmp(&b.entry_type),
|
SortColumn::Type => a.entry_type.cmp(&b.entry_type),
|
||||||
SortColumn::Size => a.metrics.size_bytes.cmp(&b.metrics.size_bytes),
|
SortColumn::Size => a.size_bytes.cmp(&b.size_bytes),
|
||||||
SortColumn::Blocks => a.metrics.blocks.cmp(&b.metrics.blocks),
|
SortColumn::Blocks => a.blocks.cmp(&b.blocks),
|
||||||
};
|
};
|
||||||
|
|
||||||
if order != Ordering::Equal {
|
if order != Ordering::Equal {
|
||||||
@@ -457,24 +438,44 @@ fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> {
|
|||||||
|
|
||||||
fn print_output(
|
fn print_output(
|
||||||
writer: &mut dyn Write,
|
writer: &mut dyn Write,
|
||||||
path: &PathBuf,
|
parent_node: &Node,
|
||||||
metrics: &PathMetrics,
|
children: &[&Node],
|
||||||
entries: &[EntryData],
|
|
||||||
args: &Args,
|
args: &Args,
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
|
depth: usize,
|
||||||
) {
|
) {
|
||||||
match args.format {
|
match args.format {
|
||||||
OutputFormat::Text => {
|
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, "\n{}", relative_path.bold().blue()).ok();
|
||||||
writeln!(
|
writeln!(
|
||||||
writer,
|
writer,
|
||||||
"Total size: {} / {} ({} blocks)",
|
"Total size: {} / {} ({} blocks)",
|
||||||
format_size(metrics.size_bytes, BINARY).bold().green(),
|
format_size(parent_node.size_bytes, BINARY).bold().green(),
|
||||||
format_size(metrics.size_bytes, DECIMAL).bold().green(),
|
format_size(parent_node.size_bytes, DECIMAL).bold().green(),
|
||||||
metrics.blocks
|
parent_node.blocks
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
|
if children.is_empty() {
|
||||||
|
writeln!(writer, "(No children)").ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table
|
table
|
||||||
.load_preset(UTF8_FULL)
|
.load_preset(UTF8_FULL)
|
||||||
@@ -493,9 +494,9 @@ fn print_output(
|
|||||||
.set_alignment(CellAlignment::Right),
|
.set_alignment(CellAlignment::Right),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for entry in entries {
|
for child in children {
|
||||||
let name = entry.path.file_name().unwrap_or_default().to_string_lossy();
|
let name = child.path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
let color = if entry.entry_type == "Dir" {
|
let color = if child.entry_type == EntryType::Dir {
|
||||||
Color::Blue
|
Color::Blue
|
||||||
} else {
|
} else {
|
||||||
Color::Cyan
|
Color::Cyan
|
||||||
@@ -503,45 +504,60 @@ fn print_output(
|
|||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
Cell::new(name).fg(color),
|
Cell::new(name).fg(color),
|
||||||
Cell::new(&entry.entry_type).fg(Color::Yellow),
|
Cell::new(&child.entry_type).fg(Color::Yellow),
|
||||||
Cell::new(format_size(entry.metrics.size_bytes, BINARY))
|
Cell::new(format_size(child.size_bytes, BINARY))
|
||||||
.fg(Color::Green)
|
.fg(Color::Green)
|
||||||
.set_alignment(CellAlignment::Right),
|
.set_alignment(CellAlignment::Right),
|
||||||
Cell::new(format_size(entry.metrics.size_bytes, DECIMAL))
|
Cell::new(format_size(child.size_bytes, DECIMAL))
|
||||||
.fg(Color::Green)
|
.fg(Color::Green)
|
||||||
.set_alignment(CellAlignment::Right),
|
.set_alignment(CellAlignment::Right),
|
||||||
Cell::new(entry.metrics.blocks)
|
Cell::new(child.blocks)
|
||||||
.fg(Color::Green)
|
.fg(Color::Green)
|
||||||
.set_alignment(CellAlignment::Right),
|
.set_alignment(CellAlignment::Right),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
writeln!(writer, "{}", table).ok();
|
writeln!(writer, "{}", table).ok();
|
||||||
}
|
}
|
||||||
OutputFormat::Csv => {
|
|
||||||
// Check if we need to print header? Csv writer handles it if we tell it?
|
fn print_csv(writer: &mut dyn Write, children: &[&Node]) {
|
||||||
// Simple flatten: Path,Type,Size,Blocks
|
// strict csv writing
|
||||||
// But we have entries list.
|
let mut wtr = WriterBuilder::new()
|
||||||
for entry in entries {
|
.has_headers(false) // We already wrote the header globally
|
||||||
writeln!(
|
.from_writer(writer);
|
||||||
writer,
|
|
||||||
"{},{},{},{}",
|
for child in children {
|
||||||
entry.path.display(),
|
wtr.write_record(&[
|
||||||
entry.entry_type,
|
child.path.to_string_lossy().as_ref(), // This handles escaping automatically
|
||||||
entry.metrics.size_bytes,
|
&child.entry_type.to_string(),
|
||||||
entry.metrics.blocks
|
&child.size_bytes.to_string(),
|
||||||
)
|
&child.blocks.to_string(),
|
||||||
|
])
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
wtr.flush().ok();
|
||||||
}
|
}
|
||||||
OutputFormat::Json => {
|
|
||||||
// NDJSON report per directory
|
fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) {
|
||||||
let report = json!({
|
// Zero-copy serialization
|
||||||
"path": path,
|
let entries_view: Vec<JsonEntry> = children
|
||||||
"total_size": metrics.size_bytes,
|
.iter()
|
||||||
"blocks": metrics.blocks,
|
.map(|c| JsonEntry {
|
||||||
"entries": entries
|
path: &c.path,
|
||||||
});
|
metrics: JsonMetrics {
|
||||||
writeln!(writer, "{}", serde_json::to_string(&report).unwrap()).ok();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user