v0.2.0 - Performance & Distribution #1

Merged
gamertan merged 3 commits from feat/optimization-refactor into main 2026-01-22 15:26:34 -05:00
16 changed files with 1869 additions and 688 deletions
+1
View File
@@ -1 +1,2 @@
/target /target
/dist
+40 -13
View File
@@ -5,19 +5,46 @@ 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
### Added
- **Comparison Mode**: Added `--compare` flag to display total size vs. non-ignored size (respecting `.gitignore`).
- **Unit Control**: Added `--units <binary|decimal>` flag to toggle standard IEC (MiB) vs. SI (MB) unit systems.
- **Precision Control**: Added `--precision <number>` 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
- Dependency on `walkdir`, `humansize`, `chrono`, and `atty`.
## [0.1.0] - 2026-01-22 ## [0.1.0] - 2026-01-22
### Added ### Added
- Core directory traversal and size calculation logic. - Initial release with core directory traversal and size calculation.
- Parallel processing using `rayon` for high performance. - Basic parallel processing using `rayon`.
- Rich table output format using `comfy-table` with colored entries. - Rich table output via `comfy-table`.
- Recursive depth control via `-d`/`--depth`. - Basic filtering (`--min-size`, `--depth`) and sorting (`--sort`).
- Minimum size filtering via `-m`/`--min-size`. - Output format support: Text, CSV, and JSON.
- Multi-column sorting support (`--sort`). - Save to file and shell completion generation.
- 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`).
Generated
+368 -142
View File
@@ -8,7 +8,7 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
"once_cell", "once_cell",
"version_check", "version_check",
] ]
@@ -23,13 +23,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "android_system_properties" name = "anes"
version = "0.1.5" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
@@ -88,14 +85,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "atty" name = "assert_cmd"
version = "0.2.14" version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514"
dependencies = [ dependencies = [
"hermit-abi", "anstyle",
"bstr",
"libc", "libc",
"winapi", "predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
] ]
[[package]] [[package]]
@@ -152,6 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [ dependencies = [
"memchr", "memchr",
"regex-automata",
"serde", "serde",
] ]
@@ -202,14 +204,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]] [[package]]
name = "cc" name = "cast"
version = "1.2.53" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@@ -224,16 +222,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "ciborium"
version = "0.4.43" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [ dependencies = [
"iana-time-zone", "ciborium-io",
"js-sys", "ciborium-ll",
"num-traits", "serde",
"wasm-bindgen", ]
"windows-link",
[[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]] [[package]]
@@ -285,6 +297,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"
@@ -313,10 +335,40 @@ dependencies = [
] ]
[[package]] [[package]]
name = "core-foundation-sys" name = "criterion"
version = "0.8.7" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
@@ -366,6 +418,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "csv" name = "csv"
version = "1.4.0" version = "1.4.0"
@@ -387,6 +445,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@@ -425,10 +498,19 @@ dependencies = [
] ]
[[package]] [[package]]
name = "find-msvc-tools" name = "fastrand"
version = "0.1.8" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "funty" name = "funty"
@@ -447,6 +529,18 @@ dependencies = [
"wasi", "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]] [[package]]
name = "globset" name = "globset"
version = "0.4.18" version = "0.4.18"
@@ -460,6 +554,17 @@ dependencies = [
"regex-syntax", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -489,45 +594,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.1.19" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
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",
]
[[package]] [[package]]
name = "ignore" name = "ignore"
@@ -555,12 +624,32 @@ dependencies = [
"hashbrown 0.16.1", "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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -589,12 +678,6 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@@ -628,6 +711,18 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -649,6 +744,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -672,6 +773,40 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -681,6 +816,36 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.4.0" version = "3.4.0"
@@ -728,6 +893,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "radium" name = "radium"
version = "0.7.0" version = "0.7.0"
@@ -761,7 +932,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
] ]
[[package]] [[package]]
@@ -813,6 +984,18 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.13" version = "0.4.13"
@@ -868,6 +1051,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"
@@ -985,12 +1174,6 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@@ -999,23 +1182,25 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "sized" name = "sized"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"atty", "assert_cmd",
"byte-unit", "byte-unit",
"chrono",
"clap", "clap",
"clap_complete", "clap_complete",
"clap_mangen",
"colored", "colored",
"comfy-table", "comfy-table",
"criterion",
"csv", "csv",
"humansize",
"ignore", "ignore",
"predicates",
"rayon", "rayon",
"serde", "serde",
"serde_json", "serde_json",
"strum", "strum",
"walkdir", "tempfile",
"time",
] ]
[[package]] [[package]]
@@ -1080,6 +1265,66 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.10.0"
@@ -1171,6 +1416,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -1187,6 +1441,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.108" version = "0.2.108"
@@ -1232,6 +1495,16 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -1263,65 +1536,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -1413,6 +1633,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]] [[package]]
name = "wyz" name = "wyz"
version = "0.5.1" version = "0.5.1"
+39 -5
View File
@@ -1,13 +1,36 @@
[package] [package]
name = "sized" name = "sized"
version = "0.1.0" version = "0.2.0"
edition = "2021" 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] [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"
colored = "2.0" colored = "2.0"
comfy-table = "7.0" comfy-table = "7.0"
byte-unit = "5.0" byte-unit = "5.0"
@@ -16,6 +39,17 @@ ignore = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
csv = "1.3" csv = "1.3"
chrono = "0.4" time = { version = "0.3", features = ["macros", "formatting"] }
clap_complete = "4.4" 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
+23
View File
@@ -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
+21
View File
@@ -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.
+182
View File
@@ -0,0 +1,182 @@
# 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)
- [Comparison Mode](#comparison-mode)
- [Gitignore Support](#gitignore-support)
- [Concurrency](#concurrency)
- [Exporting Data](#exporting-data)
- [Shell Completions](#shell-completions)
## Installation
### 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 https://gitea.speelman.ca/gamertan/sized.git
cd sized
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.
```bash
sized [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
# Show current directory and its children's children
sized -d 1
```
> [!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. `sized` supports multi-column sorting.
**Syntax**: `--sort <COLUMN:DIRECTION>,[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:dsc
# 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. Supports standard units (KB, MB, GB, etc.).
```bash
# Show only items larger than 100MB
sized -m 100MB
```
### Limiting Results (`-n` / `--number`)
Limit the number of rows displayed in each table.
```bash
# Show only the top 10 largest items
sized --sort size:dsc -n 10
```
## 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.
```bash
sized --format csv
```
### JSON
Computed metrics in NDJSON format.
```bash
sized --format json
```
## Advanced Features
### Unit System (`--units`)
Switch between Binary (IEC) and Decimal (SI) units for the output.
- **Binary** (Default): GiB, MiB, KiB (multiples of 1024).
- **Decimal**: GB, MB, KB (multiples of 1000).
```bash
# Use decimal units (SI)
sized --units decimal
```
### 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
```
### 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
# Limit to 4 threads on a high-core system
sized -j 4
```
### 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, or Fish.
```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`
+25
View File
@@ -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"
+81 -7
View File
@@ -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. - **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. - **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. - **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`). - **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`). - **Export Options**: Export data to CSV or JSON for further analysis (`--format csv/json`).
- **Save to File**: Automatically save reports with timestamped filenames (`--save`). - **Save to File**: Automatically save reports with timestamped filenames (`--save`).
## Installation ## Installation
### From Source ### 🚀 Direct Download (macOS & Linux)
Ensure you have Rust and Cargo installed. Clone the repository and install: 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 ```bash
cargo install --path . 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 ## Usage
### Basic Usage ### Basic Usage
@@ -41,7 +90,9 @@ sized /path/to/directory
|------|-------------|---------| |------|-------------|---------|
| `-d`, `--depth` | Recursion depth (0 = current dir only) | `sized -d 1` | | `-d`, `--depth` | Recursion depth (0 = current dir only) | `sized -d 1` |
| `-m`, `--min-size` | Filter by minimum size | `sized -m 100MB` | | `-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` | | `-f`, `--path-full` | Force absolute paths in headers | `sized -f` |
| `-i`, `--ignore` | Respect .gitignore files | `sized -i` | | `-i`, `--ignore` | Respect .gitignore files | `sized -i` |
| `-j`, `--threads` | Set number of threads | `sized -j 4` | | `-j`, `--threads` | Set number of threads | `sized -j 4` |
@@ -55,9 +106,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
+56
View File
@@ -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<VERSION>/` 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`.
+34
View File
@@ -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), black_box(false)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+53
View File
@@ -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"
+74
View File
@@ -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<MIN_SIZE>\fR
Minimum size filter (e.g. "10MB", "1gb")
.TP
\fB\-n\fR, \fB\-\-number\fR \fI<NUMBER>\fR
Limit the number of results per table
.TP
\fB\-\-sort\fR \fI<SORT>\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<DEPTH>\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<THREADS>\fR
Number of threads to use (defaults to available logical CPUs)
.TP
\fB\-\-completions\fR \fI<COMPLETIONS>\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<FORMAT>\fR [default: text]
Output format
.br
.br
[\fIpossible values: \fRtext, csv, json]
.TP
\fB\-\-save\fR [\fI<SAVE>\fR]
Save output to file (optional filename, defaults to timestamped name)
.TP
\fB\-\-precision\fR \fI<PRECISION>\fR [default: 2]
Precision for size output (decimal places)
.TP
\fB\-\-units\fR \fI<UNITS>\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
+813
View File
@@ -0,0 +1,813 @@
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<bool>,
/// Directory to analyze
#[arg(default_value = ".")]
pub path: PathBuf,
/// Minimum size filter (e.g. "10MB", "1gb")
#[arg(short = 'm', long)]
pub min_size: Option<String>,
/// Limit the number of results per table
#[arg(short = 'n', long)]
pub number: Option<usize>,
/// Sort by columns (e.g. "type:asc,size:dsc").
/// defaults to size:dsc.
/// Columns: name, type, size, blocks.
#[arg(long)]
pub sort: Option<String>,
/// 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<usize>,
/// Generate shell completions
#[arg(long, value_enum)]
pub completions: Option<Shell>,
/// 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<PathBuf>,
/// 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<std::path::PathBuf>,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Csv,
Json,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum UnitSystem {
Binary,
Decimal,
}
#[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<Self, Self::Err> {
match s.to_lowercase().as_str() {
"name" | "n" => Ok(SortColumn::Name),
"type" | "t" => Ok(SortColumn::Type),
"size" | "s" => Ok(SortColumn::Size),
"blocks" | "b" => Ok(SortColumn::Blocks),
_ => Err(format!("Unknown column: {}", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Dsc,
}
impl FromStr for SortDirection {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"asc" | "a" => Ok(SortDirection::Asc),
"dsc" | "d" | "desc" => Ok(SortDirection::Dsc),
_ => Err(format!("Unknown direction: {}", s)),
}
}
}
#[derive(Clone, Serialize, Debug)]
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<Node>,
}
#[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,
pub size_bytes_filtered: u64,
pub blocks_filtered: u64,
}
#[derive(Serialize)]
pub struct JsonEntry<'a> {
pub path: &'a PathBuf,
pub metrics: JsonMetrics,
pub entry_type: String,
pub accessible: bool,
}
#[derive(Serialize)]
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<JsonEntry<'a>>,
pub accessible: bool,
}
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(out_dir) = args.generate_man_page {
let cmd = Args::command();
let man = clap_mangen::Man::new(cmd);
let mut buffer: Vec<u8> = 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)
.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, args.compare);
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, compare: bool) -> Node {
let metadata = path.symlink_metadata();
if let Ok(meta) = metadata {
// 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![],
};
}
}
// 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(total_ignore)
.ignore(total_ignore)
.max_depth(Some(1))
.build();
let child_paths_total: Vec<PathBuf> = walker_total
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path() != path)
.map(|e| e.path().to_path_buf())
.collect();
// Filtered set (only if compare is true)
let non_ignored_set: std::collections::HashSet<PathBuf> = 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<Node> = child_paths_total
.par_iter()
.map(|p| build_tree(p, ignore, compare))
.collect();
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
.symlink_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,
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,
}
}
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, args, 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],
args: &Args,
relative_path: &str,
depth: usize,
) {
if depth > 0 {
writeln!(writer, "{}", "-".repeat(60).dimmed()).ok();
}
writeln!(writer, "\n{}", relative_path.bold().blue()).ok();
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(),
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() {
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);
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),
);
headers.push(
Cell::new("Disk Filtered")
.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();
let color = if child.entry_type == EntryType::Dir {
Color::Blue
} else {
Color::Cyan
};
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);
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(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();
}
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<JsonEntry> = children
.iter()
.map(|c| JsonEntry {
path: &c.path,
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();
let report = JsonReport {
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();
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, 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);
}
}
+12 -521
View File
@@ -1,188 +1,23 @@
use byte_unit::Byte; use clap::Parser;
use chrono::Local; use sized::{run, Args, OutputFormat};
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 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::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use time::macros::format_description;
use time::OffsetDateTime;
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
#[command(disable_version_flag = true)]
struct Args {
/// Print version
#[arg(short = 'v', long, action = clap::ArgAction::Version)]
version: Option<bool>,
/// Directory to analyze
#[arg(default_value = ".")]
path: PathBuf,
/// Minimum size filter (e.g. "10MB", "1gb")
#[arg(short = 'm', long)]
min_size: Option<String>,
/// Limit the number of results per table
#[arg(short = 'n', long)]
number: Option<usize>,
/// Sort by columns (e.g. "type:asc,size:dsc").
/// defaults to size:dsc.
/// Columns: name, type, size, blocks.
#[arg(long)]
sort: Option<String>,
/// Depth to traverse (0 = only immediate children)
#[arg(short, long, default_value = "0")]
depth: usize,
/// Display full absolute paths
#[arg(short = 'f', long, conflicts_with = "path_relative")]
path_full: bool,
/// Display paths relative to current directory (default)
#[arg(long)]
path_relative: bool,
/// Number of threads to use (defaults to available logical CPUs)
#[arg(short = 'j', long = "threads")]
threads: Option<usize>,
/// Generate shell completions
#[arg(long, value_enum)]
completions: Option<Shell>,
/// Respect .gitignore and .ignore files
#[arg(short = 'i', long)]
ignore: bool,
/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
/// Save output to file (optional filename, defaults to timestamped name)
#[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")]
save: Option<PathBuf>,
}
#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)]
enum OutputFormat {
Text,
Csv,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SortColumn {
Name,
Type,
Size,
Blocks,
}
impl FromStr for SortColumn {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"name" | "n" => Ok(SortColumn::Name),
"type" | "t" => Ok(SortColumn::Type),
"size" | "s" => Ok(SortColumn::Size),
"blocks" | "b" => Ok(SortColumn::Blocks),
_ => Err(format!("Unknown column: {}", s)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SortDirection {
Asc,
Dsc,
}
impl FromStr for SortDirection {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"asc" | "a" => Ok(SortDirection::Asc),
"dsc" | "d" | "desc" => Ok(SortDirection::Dsc),
_ => Err(format!("Unknown direction: {}", s)),
}
}
}
#[derive(Clone, Serialize)]
struct PathMetrics {
size_bytes: u64,
blocks: u64,
}
#[derive(Serialize)]
struct EntryData {
path: PathBuf,
metrics: PathMetrics,
entry_type: String, // "File" or "Dir"
}
fn main() { fn main() {
let args = Args::parse(); let args = Args::parse();
// Handle completions // Determine output writer logic (moved mostly to main to keep lib clean,
if let Some(shell) = args.completions { // or keep in lib? Plan said simplified main. Let's keep file creation in main usually for CLI apps,
let mut cmd = Args::command(); // but the logic for "auto name" is business logic.
let name = cmd.get_name().to_string(); // Wait, the lib function `run` takes `writer`. So we need to create the writer here.
generate(shell, &mut cmd, name, &mut std::io::stdout());
return;
}
// Configure Rayon
if let Some(threads) = args.threads {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global()
.expect("Failed to build thread pool");
}
let target_path = args.path.clone();
if !target_path.exists() {
eprintln!("Error: Path '{}' does not exist.", target_path.display());
std::process::exit(1);
}
// Parse min_size
let min_bytes = if let Some(size_str) = &args.min_size {
match Byte::parse_str(size_str, true) {
Ok(byte) => byte.as_u64(),
Err(e) => {
eprintln!("Error parsing size '{}': {}", size_str, e);
std::process::exit(1);
}
}
} else {
0
};
// Parse sort
let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc"));
// Determine output writer
let mut writer: Box<dyn Write> = if let Some(path_arg) = &args.save { let mut writer: Box<dyn Write> = if let Some(path_arg) = &args.save {
let output_path = if path_arg.to_string_lossy() == "_AUTO_" { let output_path = if path_arg.to_string_lossy() == "_AUTO_" {
let timestamp = Local::now().format("%Y%m%d-%H%M%S"); let format = format_description!("[year][month][day]-[hour][minute][second]");
let dir_name = target_path let timestamp = OffsetDateTime::now_utc().format(format).unwrap();
.file_name() let dir_name = args.path.file_name().unwrap_or_default().to_string_lossy();
.unwrap_or_default()
.to_string_lossy();
let ext = match args.format { let ext = match args.format {
OutputFormat::Csv => "csv", OutputFormat::Csv => "csv",
OutputFormat::Json => "json", OutputFormat::Json => "json",
@@ -199,349 +34,5 @@ fn main() {
Box::new(std::io::stdout()) Box::new(std::io::stdout())
}; };
if args.format == OutputFormat::Csv { run(args, &mut writer);
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;
}
// 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,
);
}
fn process_directory_recursive(
writer: &mut dyn Write,
path: &PathBuf,
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<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,
}
})
.collect();
let total_metrics = calc_total(&sized_entries);
// Filter
if min_bytes > 0 {
sized_entries.retain(|e| e.metrics.size_bytes >= min_bytes);
}
// Sort
sized_entries.sort_by(|a, b| compare_entries(a, b, sort_criteria));
// Limit (per table)
if let Some(n) = args.number {
if n < sized_entries.len() {
sized_entries.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)
.display()
.to_string()
} else {
path.canonicalize()
.unwrap_or(path.clone())
.display()
.to_string()
};
print_output(
writer,
path,
&total_metrics,
&sized_entries,
args,
&relative_path,
);
// Recurse if needed
if current_depth < args.depth {
for entry in sized_entries {
if entry.entry_type == "Dir" {
process_directory_recursive(
writer,
&entry.path,
current_depth + 1,
args,
min_bytes,
sort_criteria,
);
}
}
}
}
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 {
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),
};
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,
path: &PathBuf,
metrics: &PathMetrics,
entries: &[EntryData],
args: &Args,
relative_path: &str,
) {
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();
}
}
} }
+47
View File
@@ -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\":"));
}