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());