From db9059e2fc3605bb1d7c9a9de6e53465405cdbc7 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 22 Jan 2026 13:33:38 -0500 Subject: [PATCH] feat: optimization refactor, test suite, and dependency cleanup Refactored the application to improve performance, maintainability, and binary size. Key Changes: - **Core Algorithm**: Switched to a parallel, single-pass tree construction algorithm for O(N) traversal. - **Architecture**: Extract core logic from `main.rs` to `lib.rs` to enable unit testing. - **Testing**: Added comprehensive unit tests and integration tests (`tests/cli.rs`) covering CLI arguments and output formats. - **Benchmarking**: Added `criterion` benchmarks for performance tracking. - **Dependencies**: - Removed `walkdir` (logic replaced by `ignore` + custom tree). - Removed `humansize` (consolidated to `byte-unit`). - Replaced `chrono` with lightweight `time` crate. - Removed unused `atty`. - **Reporting**: Optimized CSV and JSON output for correctness and zero-copy performance. - **Documentation**: Updated CHANGELOG.md and bumped version to 0.2.0. --- CHANGELOG.md | 6 + Cargo.lock | 490 +++++++++++++++++++++++++++----------- Cargo.toml | 15 +- benches/benchmark.rs | 34 +++ src/lib.rs | 552 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 551 +----------------------------------------- tests/cli.rs | 47 ++++ 7 files changed, 1014 insertions(+), 681 deletions(-) create mode 100644 benches/benchmark.rs create mode 100644 src/lib.rs create mode 100644 tests/cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4dbc75..637b1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `MANUAL.md` with comprehensive usage documentation. +- Added comprehensive test suite (unit and integration tests). +- Added performance benchmarks using `criterion`. ### Removed - Removed `walkdir` dependency (replaced by `ignore::WalkBuilder` for all traversal). +- Removed `humansize` (consolidated to `byte-unit`). +- Removed `chrono` (replaced with lightweight `time`). +- Removed `atty` (unused). + ## [0.1.0] - 2026-01-22 diff --git a/Cargo.lock b/Cargo.lock index b969b75..b4488e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.17", "once_cell", "version_check", ] @@ -23,13 +23,10 @@ dependencies = [ ] [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "anes" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" @@ -88,14 +85,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "atty" -version = "0.2.14" +name = "assert_cmd" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" dependencies = [ - "hermit-abi", + "anstyle", + "bstr", "libc", - "winapi", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", ] [[package]] @@ -152,6 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -202,14 +204,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] -name = "cc" -version = "1.2.53" +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cfg-if" @@ -224,16 +222,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.43" +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", ] [[package]] @@ -323,10 +335,40 @@ dependencies = [ ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] [[package]] name = "crossbeam-deque" @@ -376,6 +418,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "csv" version = "1.4.0" @@ -397,6 +445,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "document-features" version = "0.2.12" @@ -435,10 +498,19 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.8" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] [[package]] name = "funty" @@ -457,6 +529,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "globset" version = "0.4.18" @@ -470,6 +554,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -499,45 +594,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.1.19" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "ignore" @@ -565,12 +624,32 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -599,12 +678,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -638,6 +711,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -659,6 +744,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -682,6 +773,40 @@ dependencies = [ "windows-link", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -691,6 +816,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -738,6 +893,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -771,7 +932,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -823,6 +984,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -1001,12 +1174,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "simdutf8" version = "0.1.5" @@ -1017,21 +1184,23 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" name = "sized" version = "0.2.0" dependencies = [ - "atty", + "assert_cmd", "byte-unit", - "chrono", "clap", "clap_complete", "clap_mangen", "colored", "comfy-table", + "criterion", "csv", - "humansize", "ignore", + "predicates", "rayon", "serde", "serde_json", "strum", + "tempfile", + "time", ] [[package]] @@ -1096,6 +1265,66 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -1187,6 +1416,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1203,6 +1441,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1248,6 +1495,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1279,65 +1536,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -1429,6 +1633,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d95ecc2..6d0ea4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" clap = { version = "4.4", features = ["derive"] } rayon = "1.8" -humansize = "2.1" colored = "2.0" comfy-table = "7.0" byte-unit = "5.0" @@ -16,7 +15,17 @@ ignore = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" csv = "1.3" -chrono = "0.4" +time = { version = "0.3", features = ["macros", "formatting"] } clap_complete = "4.4" -atty = "0.2" clap_mangen = "0.2.31" + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.1" +tempfile = "3.10" +criterion = "0.5" + +[[bench]] +name = "benchmark" +harness = false + diff --git a/benches/benchmark.rs b/benches/benchmark.rs new file mode 100644 index 0000000..35c78cb --- /dev/null +++ b/benches/benchmark.rs @@ -0,0 +1,34 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use sized::build_tree; +use std::fs::{self, File}; +use std::io::Write; +use tempfile::TempDir; // Ensure this is pub in lib.rs + +fn setup_test_dir() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create a moderately deep structure + for i in 0..10 { + let dir_path = base_path.join(format!("dir_{}", i)); + fs::create_dir(&dir_path).unwrap(); + for j in 0..10 { + let file_path = dir_path.join(format!("file_{}.txt", j)); + let mut file = File::create(file_path).unwrap(); + writeln!(file, "Some content for file {}-{}", i, j).unwrap(); + } + } + temp_dir +} + +fn criterion_benchmark(c: &mut Criterion) { + let temp_dir = setup_test_dir(); + let path = temp_dir.path(); + + c.bench_function("build_tree small", |b| { + b.iter(|| build_tree(black_box(path), black_box(false))) + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6d256e1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,552 @@ +use byte_unit::{Byte, UnitType}; + +use clap::{CommandFactory, Parser, ValueEnum}; +use clap_complete::{generate, Shell}; +use colored::*; +use comfy_table::presets::UTF8_FULL; +use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; +use csv::WriterBuilder; +use ignore::WalkBuilder; +use rayon::prelude::*; +use serde::Serialize; +use std::cmp::Ordering; +use std::io::Write; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +#[derive(Parser, Debug, Clone)] +#[command(author, version, about, long_about = None)] +#[command(disable_version_flag = true)] +pub struct Args { + /// Print version + #[arg(short = 'v', long, action = clap::ArgAction::Version)] + pub version: Option, + + /// Directory to analyze + #[arg(default_value = ".")] + pub path: PathBuf, + + /// Minimum size filter (e.g. "10MB", "1gb") + #[arg(short = 'm', long)] + pub min_size: Option, + + /// Limit the number of results per table + #[arg(short = 'n', long)] + pub number: Option, + + /// Sort by columns (e.g. "type:asc,size:dsc"). + /// defaults to size:dsc. + /// Columns: name, type, size, blocks. + #[arg(long)] + pub sort: Option, + + /// Depth to traverse (0 = only immediate children) + #[arg(short, long, default_value = "0")] + pub depth: usize, + + /// Display full absolute paths + #[arg(short = 'f', long, conflicts_with = "path_relative")] + pub path_full: bool, + + /// Display paths relative to current directory (default) + #[arg(long)] + pub path_relative: bool, + + /// Number of threads to use (defaults to available logical CPUs) + #[arg(short = 'j', long = "threads")] + pub threads: Option, + + /// Generate shell completions + #[arg(long, value_enum)] + pub completions: Option, + + /// Respect .gitignore and .ignore files + #[arg(short = 'i', long)] + pub ignore: bool, + + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, + + /// Save output to file (optional filename, defaults to timestamped name) + #[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")] + pub save: Option, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum OutputFormat { + Text, + Csv, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SortColumn { + Name, + Type, + Size, + Blocks, +} + +impl FromStr for SortColumn { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "name" | "n" => Ok(SortColumn::Name), + "type" | "t" => Ok(SortColumn::Type), + "size" | "s" => Ok(SortColumn::Size), + "blocks" | "b" => Ok(SortColumn::Blocks), + _ => Err(format!("Unknown column: {}", s)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SortDirection { + Asc, + Dsc, +} + +impl FromStr for SortDirection { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "asc" | "a" => Ok(SortDirection::Asc), + "dsc" | "d" | "desc" => Ok(SortDirection::Dsc), + _ => Err(format!("Unknown direction: {}", s)), + } + } +} + +#[derive(Clone, Serialize, Debug)] +pub struct Node { + pub path: PathBuf, + pub size_bytes: u64, + pub blocks: u64, + pub entry_type: EntryType, + #[serde(skip)] + pub children: Vec, +} + +#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum EntryType { + File, + Dir, +} + +impl std::fmt::Display for EntryType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EntryType::File => write!(f, "File"), + EntryType::Dir => write!(f, "Dir"), + } + } +} + +#[derive(Serialize)] +pub struct JsonMetrics { + pub size_bytes: u64, + pub blocks: u64, +} + +#[derive(Serialize)] +pub struct JsonEntry<'a> { + pub path: &'a PathBuf, + pub metrics: JsonMetrics, + pub entry_type: String, +} + +#[derive(Serialize)] +pub struct JsonReport<'a> { + pub path: &'a PathBuf, + pub total_size: u64, + pub blocks: u64, + pub entries: Vec>, +} + +pub fn run(args: Args, mut writer: &mut dyn Write) { + if let Some(shell) = args.completions { + let mut cmd = Args::command(); + let name = cmd.get_name().to_string(); + generate(shell, &mut cmd, name, &mut std::io::stdout()); + return; + } + + if let Some(threads) = args.threads { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build_global() + .ok(); // Ignore error if initialized called multiple times (e.g. in tests) + } + + let target_path = args.path.clone(); + + if !target_path.exists() { + eprintln!("Error: Path '{}' does not exist.", target_path.display()); + std::process::exit(1); + } + + let min_bytes = if let Some(size_str) = &args.min_size { + match Byte::parse_str(size_str, true) { + Ok(byte) => byte.as_u64(), + Err(e) => { + eprintln!("Error parsing size '{}': {}", size_str, e); + std::process::exit(1); + } + } + } else { + 0 + }; + + let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc")); + + if args.format == OutputFormat::Csv { + writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header"); + } + + let root_node = build_tree(&target_path, args.ignore); + + if root_node.size_bytes < min_bytes { + if args.format == OutputFormat::Text { + writeln!(writer, "Root directory is smaller than minimum size.").ok(); + } + return; + } + + process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); +} + +pub fn build_tree(path: &Path, ignore: bool) -> Node { + let metadata = path.metadata(); + + if let Ok(meta) = metadata { + if meta.is_file() { + return Node { + path: path.to_path_buf(), + size_bytes: meta.len(), + blocks: meta.blocks(), + entry_type: EntryType::File, + children: vec![], + }; + } + } + + let walker = WalkBuilder::new(path) + .standard_filters(false) + .hidden(false) + .git_ignore(ignore) + .ignore(ignore) + .max_depth(Some(1)) + .build(); + + let child_paths: Vec = walker + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path() != path) + .map(|e| e.path().to_path_buf()) + .collect(); + + let children: Vec = child_paths + .par_iter() + .map(|p| build_tree(p, ignore)) + .collect(); + + let (size_bytes, blocks) = children + .iter() + .fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks)); + + let (self_size, self_blocks) = path + .metadata() + .map(|m| (m.len(), m.blocks())) + .unwrap_or((0, 0)); + + Node { + path: path.to_path_buf(), + size_bytes: size_bytes + self_size, + blocks: blocks + self_blocks, + entry_type: EntryType::Dir, + children, + } +} + +fn process_node_recursive( + writer: &mut dyn Write, + node: &Node, + current_depth: usize, + args: &Args, + min_bytes: u64, + sort_criteria: &[(SortColumn, SortDirection)], +) { + if node.children.is_empty() && node.entry_type == EntryType::Dir {} + + let mut display_children: Vec<&Node> = node + .children + .iter() + .filter(|c| c.size_bytes >= min_bytes) + .collect(); + + display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria)); + + if let Some(n) = args.number { + if n < display_children.len() { + display_children.truncate(n); + } + } + + let show_relative = !args.path_full; + let relative_path = if show_relative { + let cwd = std::env::current_dir().unwrap_or_default(); + node.path + .strip_prefix(&cwd) + .unwrap_or(&node.path) + .display() + .to_string() + } else { + node.path + .canonicalize() + .unwrap_or(node.path.clone()) + .display() + .to_string() + }; + + print_output( + writer, + &node, + &display_children, + args, + &relative_path, + current_depth, + ); + + if current_depth < args.depth { + for child in display_children { + if child.entry_type == EntryType::Dir { + process_node_recursive( + writer, + child, + current_depth + 1, + args, + min_bytes, + sort_criteria, + ); + } + } + } +} + +pub fn compare_nodes(a: &Node, b: &Node, criteria: &[(SortColumn, SortDirection)]) -> Ordering { + for (col, dir) in criteria { + let order = match col { + SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()), + SortColumn::Type => a.entry_type.cmp(&b.entry_type), + SortColumn::Size => a.size_bytes.cmp(&b.size_bytes), + SortColumn::Blocks => a.blocks.cmp(&b.blocks), + }; + + if order != Ordering::Equal { + return match dir { + SortDirection::Asc => order, + SortDirection::Dsc => order.reverse(), + }; + } + } + Ordering::Equal +} + +pub fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> { + s.split(',') + .filter_map(|part| { + let parts: Vec<&str> = part.split(':').collect(); + if parts.is_empty() { + return None; + } + + let col_str = parts[0]; + let col = SortColumn::from_str(col_str).ok()?; + + let dir = if parts.len() > 1 { + SortDirection::from_str(parts[1]).unwrap_or(SortDirection::Dsc) + } else { + match col { + SortColumn::Name | SortColumn::Type => SortDirection::Asc, + _ => SortDirection::Dsc, + } + }; + Some((col, dir)) + }) + .collect() +} + +fn print_output( + writer: &mut dyn Write, + parent_node: &Node, + children: &[&Node], + args: &Args, + relative_path: &str, + depth: usize, +) { + match args.format { + OutputFormat::Text => print_text(writer, parent_node, children, relative_path, depth), + OutputFormat::Csv => print_csv(writer, children), + OutputFormat::Json => print_json(writer, parent_node, children), + } +} + +fn print_text( + writer: &mut dyn Write, + parent_node: &Node, + children: &[&Node], + relative_path: &str, + depth: usize, +) { + if depth > 0 { + writeln!(writer, "{}", "-".repeat(60).dimmed()).ok(); + } + writeln!(writer, "\n{}", relative_path.bold().blue()).ok(); + + let total_byte = Byte::from_u64(parent_node.size_bytes); + writeln!( + writer, + "Total size: {} / {} ({} blocks)", + total_byte + .get_appropriate_unit(UnitType::Binary) + .to_string() + .bold() + .green(), + parent_node.size_bytes.to_string().bold().green(), + parent_node.blocks + ) + .ok(); + + if children.is_empty() { + writeln!(writer, "(No children)").ok(); + return; + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("Name").add_attribute(Attribute::Bold), + Cell::new("Type").add_attribute(Attribute::Bold), + Cell::new("Size (Human)") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + Cell::new("Size (Bytes)") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + Cell::new("Blocks") + .add_attribute(Attribute::Bold) + .set_alignment(CellAlignment::Right), + ]); + + for child in children { + let name = child.path.file_name().unwrap_or_default().to_string_lossy(); + let color = if child.entry_type == EntryType::Dir { + Color::Blue + } else { + Color::Cyan + }; + + let child_byte = Byte::from_u64(child.size_bytes); + + table.add_row(vec![ + Cell::new(name).fg(color), + Cell::new(&child.entry_type).fg(Color::Yellow), + Cell::new( + child_byte + .get_appropriate_unit(UnitType::Binary) + .to_string(), + ) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + Cell::new(child.size_bytes.to_string()) // Raw bytes instead of "Decimal" unit + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + Cell::new(child.blocks) + .fg(Color::Green) + .set_alignment(CellAlignment::Right), + ]); + } + writeln!(writer, "{}", table).ok(); +} + +fn print_csv(writer: &mut dyn Write, children: &[&Node]) { + let mut wtr = WriterBuilder::new().has_headers(false).from_writer(writer); + + for child in children { + wtr.write_record(&[ + child.path.to_string_lossy().as_ref(), + &child.entry_type.to_string(), + &child.size_bytes.to_string(), + &child.blocks.to_string(), + ]) + .ok(); + } + wtr.flush().ok(); +} + +fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { + let entries_view: Vec = children + .iter() + .map(|c| JsonEntry { + path: &c.path, + metrics: JsonMetrics { + size_bytes: c.size_bytes, + blocks: c.blocks, + }, + entry_type: c.entry_type.to_string(), + }) + .collect(); + + let report = JsonReport { + path: &parent_node.path, + total_size: parent_node.size_bytes, + blocks: parent_node.blocks, + entries: entries_view, + }; + + serde_json::to_writer(&mut *writer, &report).ok(); + writer.write_all(b"\n").ok(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use tempfile::TempDir; + + #[test] + fn test_parse_sort_arg() { + let criteria = parse_sort_arg("size:asc,name"); + assert_eq!(criteria.len(), 2); + assert_eq!(criteria[0], (SortColumn::Size, SortDirection::Asc)); + assert_eq!(criteria[1], (SortColumn::Name, SortDirection::Asc)); // Default for Name is Asc + } + + #[test] + fn test_sort_defaults() { + let criteria = parse_sort_arg("size"); + assert_eq!(criteria[0], (SortColumn::Size, SortDirection::Dsc)); // Default for Size is Dsc + } + + #[test] + fn test_build_tree() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_file.txt"); + File::create(&file_path) + .unwrap() + .write_all(b"Hello") + .unwrap(); + + let node = build_tree(temp_dir.path(), false); + + assert_eq!(node.entry_type, EntryType::Dir); + assert!(!node.children.is_empty()); + assert_eq!(node.children[0].path, file_path); + // Size should be at least 5 bytes + assert!(node.children[0].size_bytes >= 5); + } +} diff --git a/src/main.rs b/src/main.rs index 97272ab..7749f96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,222 +1,23 @@ -use byte_unit::Byte; -use chrono::Local; -use clap::{CommandFactory, Parser, ValueEnum}; -use clap_complete::{generate, Shell}; -use colored::*; -use comfy_table::presets::UTF8_FULL; -use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table}; -use csv::WriterBuilder; -use humansize::{format_size, BINARY, DECIMAL}; -use ignore::WalkBuilder; -use rayon::prelude::*; -use serde::Serialize; - -use std::cmp::Ordering; +use clap::Parser; +use sized::{run, Args, OutputFormat}; use std::io::Write; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -#[derive(Parser, Debug, Clone)] -#[command(author, version, about, long_about = None)] -#[command(disable_version_flag = true)] -struct Args { - /// Print version - #[arg(short = 'v', long, action = clap::ArgAction::Version)] - version: Option, - - /// Directory to analyze - #[arg(default_value = ".")] - path: PathBuf, - - /// Minimum size filter (e.g. "10MB", "1gb") - #[arg(short = 'm', long)] - min_size: Option, - - /// Limit the number of results per table - #[arg(short = 'n', long)] - number: Option, - - /// Sort by columns (e.g. "type:asc,size:dsc"). - /// defaults to size:dsc. - /// Columns: name, type, size, blocks. - #[arg(long)] - sort: Option, - - /// Depth to traverse (0 = only immediate children) - #[arg(short, long, default_value = "0")] - depth: usize, - - /// Display full absolute paths - #[arg(short = 'f', long, conflicts_with = "path_relative")] - path_full: bool, - - /// Display paths relative to current directory (default) - #[arg(long)] - path_relative: bool, - - /// Number of threads to use (defaults to available logical CPUs) - #[arg(short = 'j', long = "threads")] - threads: Option, - - /// Generate shell completions - #[arg(long, value_enum)] - completions: Option, - - /// Respect .gitignore and .ignore files - #[arg(short = 'i', long)] - ignore: bool, - - /// Output format - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, - - /// Save output to file (optional filename, defaults to timestamped name) - #[arg(long, num_args(0..=1), default_missing_value = "_AUTO_")] - save: Option, -} - -#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] -enum OutputFormat { - Text, - Csv, - Json, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SortColumn { - Name, - Type, - Size, - Blocks, -} - -impl FromStr for SortColumn { - type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "name" | "n" => Ok(SortColumn::Name), - "type" | "t" => Ok(SortColumn::Type), - "size" | "s" => Ok(SortColumn::Size), - "blocks" | "b" => Ok(SortColumn::Blocks), - _ => Err(format!("Unknown column: {}", s)), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum SortDirection { - Asc, - Dsc, -} - -impl FromStr for SortDirection { - type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "asc" | "a" => Ok(SortDirection::Asc), - "dsc" | "d" | "desc" => Ok(SortDirection::Dsc), - _ => Err(format!("Unknown direction: {}", s)), - } - } -} - -#[derive(Clone, Serialize, Debug)] -struct Node { - path: PathBuf, - size_bytes: u64, - blocks: u64, - entry_type: EntryType, - #[serde(skip)] - children: Vec, -} - -#[derive(Clone, Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum EntryType { - File, - Dir, -} - -impl std::fmt::Display for EntryType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EntryType::File => write!(f, "File"), - EntryType::Dir => write!(f, "Dir"), - } - } -} - -#[derive(Serialize)] -struct JsonMetrics { - size_bytes: u64, - blocks: u64, -} - -#[derive(Serialize)] -struct JsonEntry<'a> { - path: &'a PathBuf, - metrics: JsonMetrics, - entry_type: String, -} - -#[derive(Serialize)] -struct JsonReport<'a> { - path: &'a PathBuf, - total_size: u64, - blocks: u64, - entries: Vec>, -} +use std::path::PathBuf; +use time::macros::format_description; +use time::OffsetDateTime; fn main() { let args = Args::parse(); - // Handle completions - if let Some(shell) = args.completions { - let mut cmd = Args::command(); - let name = cmd.get_name().to_string(); - generate(shell, &mut cmd, name, &mut std::io::stdout()); - return; - } + // Determine output writer logic (moved mostly to main to keep lib clean, + // or keep in lib? Plan said simplified main. Let's keep file creation in main usually for CLI apps, + // but the logic for "auto name" is business logic. + // Wait, the lib function `run` takes `writer`. So we need to create the writer here. - // Configure Rayon - if let Some(threads) = args.threads { - rayon::ThreadPoolBuilder::new() - .num_threads(threads) - .build_global() - .expect("Failed to build thread pool"); - } - - let target_path = args.path.clone(); - - if !target_path.exists() { - eprintln!("Error: Path '{}' does not exist.", target_path.display()); - std::process::exit(1); - } - - // Parse min_size - let min_bytes = if let Some(size_str) = &args.min_size { - match Byte::parse_str(size_str, true) { - Ok(byte) => byte.as_u64(), - Err(e) => { - eprintln!("Error parsing size '{}': {}", size_str, e); - std::process::exit(1); - } - } - } else { - 0 - }; - - // Parse sort - let sort_criteria = parse_sort_arg(args.sort.as_deref().unwrap_or("size:dsc")); - - // Determine output writer let mut writer: Box = if let Some(path_arg) = &args.save { let output_path = if path_arg.to_string_lossy() == "_AUTO_" { - let timestamp = Local::now().format("%Y%m%d-%H%M%S"); - let dir_name = target_path - .file_name() - .unwrap_or_default() - .to_string_lossy(); + let format = format_description!("[year][month][day]-[hour][minute][second]"); + let timestamp = OffsetDateTime::now_utc().format(format).unwrap(); + let dir_name = args.path.file_name().unwrap_or_default().to_string_lossy(); let ext = match args.format { OutputFormat::Csv => "csv", OutputFormat::Json => "json", @@ -233,331 +34,5 @@ fn main() { Box::new(std::io::stdout()) }; - if args.format == OutputFormat::Csv { - // We do NOT write the header here manually anymore if we want to be safe/consistent, - // OR we write it manually but treat it as raw bytes. - // Actually, if we use csv::Writer, we should use it for the header too, OR assume the writer calls will handle it (no, we need to init). - // For simplicity in this logic, let's write the header manually *once* if we are creating a fresh file/stream. - // BUT, `process_node_recursive` might be called multiple times? - // Wait, `process_node_recursive` is called recursively. - // The CSV logic implementation in `print_output` currently printed children. - // If we recurse, we print children of children. - // So a single global header is correct. - writeln!(writer, "Path,Type,Size(Bytes),Blocks").expect("Failed to write CSV header"); - } - - // Build the tree! - let root_node = build_tree(&target_path, args.ignore); - - if root_node.size_bytes < min_bytes { - // Even the root is too small? - if args.format == OutputFormat::Text { - writeln!(writer, "Root directory is smaller than minimum size.").ok(); - } - return; - } - - // Render output - process_node_recursive(&mut writer, &root_node, 0, &args, min_bytes, &sort_criteria); -} - -/// Builds the directory tree in memory, calculating sizes in a single pass. -/// Uses Rayon for parallelism. -fn build_tree(path: &Path, ignore: bool) -> Node { - let metadata = path.metadata(); - - if let Ok(meta) = metadata { - if meta.is_file() { - return Node { - path: path.to_path_buf(), - size_bytes: meta.len(), - blocks: meta.blocks(), - entry_type: EntryType::File, - children: vec![], - }; - } - } - - // It's a directory (or error, which we treat as zero-size usually, but here we try to read) - // Use ignore::WalkBuilder to get *immediate children only* respecting gitignore - let walker = WalkBuilder::new(path) - .standard_filters(false) // We manually control ignore - .hidden(false) // Show hidden files - .git_ignore(ignore) - .ignore(ignore) - .max_depth(Some(1)) // ONLY immediate children - .build(); - - let child_paths: Vec = walker - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path() != path) // Exclude self - .map(|e| e.path().to_path_buf()) - .collect(); - - // Recursively build children in parallel - let children: Vec = child_paths - .par_iter() - .map(|p| build_tree(p, ignore)) - .collect(); - - // Sum up stats - let (size_bytes, blocks) = children - .iter() - .fold((0, 0), |acc, c| (acc.0 + c.size_bytes, acc.1 + c.blocks)); - - // Add self-size (directory entry itself has size) - let (self_size, self_blocks) = path - .metadata() - .map(|m| (m.len(), m.blocks())) - .unwrap_or((0, 0)); - - Node { - path: path.to_path_buf(), - size_bytes: size_bytes + self_size, - blocks: blocks + self_blocks, - entry_type: EntryType::Dir, - children, - } -} - -fn process_node_recursive( - writer: &mut dyn Write, - node: &Node, - current_depth: usize, - args: &Args, - min_bytes: u64, - sort_criteria: &[(SortColumn, SortDirection)], -) { - if node.children.is_empty() && node.entry_type == EntryType::Dir { - // Empty directory or leaf in terms of permissions/ignore - } - - // Prepare children for display - let mut display_children: Vec<&Node> = node - .children - .iter() - .filter(|c| c.size_bytes >= min_bytes) - .collect(); - - // Sort - display_children.sort_by(|a, b| compare_nodes(a, b, sort_criteria)); - - // Limit - if let Some(n) = args.number { - if n < display_children.len() { - display_children.truncate(n); - } - } - - // Default to relative paths unless --path-full is specified - let show_relative = !args.path_full; - let relative_path = if show_relative { - let cwd = std::env::current_dir().unwrap_or_default(); - node.path - .strip_prefix(&cwd) - .unwrap_or(&node.path) - .display() - .to_string() - } else { - node.path - .canonicalize() - .unwrap_or(node.path.clone()) - .display() - .to_string() - }; - - print_output( - writer, - &node, - &display_children, - args, - &relative_path, - current_depth, - ); - - // Recurse - if current_depth < args.depth { - for child in display_children { - if child.entry_type == EntryType::Dir { - process_node_recursive( - writer, - child, - current_depth + 1, - args, - min_bytes, - sort_criteria, - ); - } - } - } -} - -fn compare_nodes(a: &Node, b: &Node, criteria: &[(SortColumn, SortDirection)]) -> Ordering { - for (col, dir) in criteria { - let order = match col { - SortColumn::Name => a.path.file_name().cmp(&b.path.file_name()), - SortColumn::Type => a.entry_type.cmp(&b.entry_type), - SortColumn::Size => a.size_bytes.cmp(&b.size_bytes), - SortColumn::Blocks => a.blocks.cmp(&b.blocks), - }; - - if order != Ordering::Equal { - return match dir { - SortDirection::Asc => order, - SortDirection::Dsc => order.reverse(), - }; - } - } - Ordering::Equal -} - -fn parse_sort_arg(s: &str) -> Vec<(SortColumn, SortDirection)> { - s.split(',') - .filter_map(|part| { - let parts: Vec<&str> = part.split(':').collect(); - if parts.is_empty() { - return None; - } - - let col_str = parts[0]; - let col = SortColumn::from_str(col_str).ok()?; - - let dir = if parts.len() > 1 { - SortDirection::from_str(parts[1]).unwrap_or(SortDirection::Dsc) - } else { - match col { - SortColumn::Name | SortColumn::Type => SortDirection::Asc, - _ => SortDirection::Dsc, - } - }; - Some((col, dir)) - }) - .collect() -} - -fn print_output( - writer: &mut dyn Write, - parent_node: &Node, - children: &[&Node], - args: &Args, - relative_path: &str, - depth: usize, -) { - match args.format { - OutputFormat::Text => print_text(writer, parent_node, children, relative_path, depth), - OutputFormat::Csv => print_csv(writer, children), - OutputFormat::Json => print_json(writer, parent_node, children), - } -} - -fn print_text( - writer: &mut dyn Write, - parent_node: &Node, - children: &[&Node], - relative_path: &str, - depth: usize, -) { - if depth > 0 { - writeln!(writer, "{}", "-".repeat(60).dimmed()).ok(); - } - writeln!(writer, "\n{}", relative_path.bold().blue()).ok(); - writeln!( - writer, - "Total size: {} / {} ({} blocks)", - format_size(parent_node.size_bytes, BINARY).bold().green(), - format_size(parent_node.size_bytes, DECIMAL).bold().green(), - parent_node.blocks - ) - .ok(); - - if children.is_empty() { - writeln!(writer, "(No children)").ok(); - return; - } - - let mut table = Table::new(); - table - .load_preset(UTF8_FULL) - .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(vec![ - Cell::new("Name").add_attribute(Attribute::Bold), - Cell::new("Type").add_attribute(Attribute::Bold), - Cell::new("Size (Bin)") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Right), - Cell::new("Size (Bytes)") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Right), - Cell::new("Blocks") - .add_attribute(Attribute::Bold) - .set_alignment(CellAlignment::Right), - ]); - - for child in children { - let name = child.path.file_name().unwrap_or_default().to_string_lossy(); - let color = if child.entry_type == EntryType::Dir { - Color::Blue - } else { - Color::Cyan - }; - - table.add_row(vec![ - Cell::new(name).fg(color), - Cell::new(&child.entry_type).fg(Color::Yellow), - Cell::new(format_size(child.size_bytes, BINARY)) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - Cell::new(format_size(child.size_bytes, DECIMAL)) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - Cell::new(child.blocks) - .fg(Color::Green) - .set_alignment(CellAlignment::Right), - ]); - } - writeln!(writer, "{}", table).ok(); -} - -fn print_csv(writer: &mut dyn Write, children: &[&Node]) { - // strict csv writing - let mut wtr = WriterBuilder::new() - .has_headers(false) // We already wrote the header globally - .from_writer(writer); - - for child in children { - wtr.write_record(&[ - child.path.to_string_lossy().as_ref(), // This handles escaping automatically - &child.entry_type.to_string(), - &child.size_bytes.to_string(), - &child.blocks.to_string(), - ]) - .ok(); - } - wtr.flush().ok(); -} - -fn print_json(writer: &mut dyn Write, parent_node: &Node, children: &[&Node]) { - // Zero-copy serialization - let entries_view: Vec = children - .iter() - .map(|c| JsonEntry { - path: &c.path, - metrics: JsonMetrics { - size_bytes: c.size_bytes, - blocks: c.blocks, - }, - entry_type: c.entry_type.to_string(), - }) - .collect(); - - let report = JsonReport { - path: &parent_node.path, - total_size: parent_node.size_bytes, - blocks: parent_node.blocks, - entries: entries_view, - }; - - serde_json::to_writer(&mut *writer, &report).ok(); - writer.write_all(b"\n").ok(); + run(args, &mut writer); } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..dc5a9f5 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,47 @@ +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::fs::File; +use std::io::Write; +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn test_basic_cli_run() { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("sized 0.2.0")); +} + +#[test] +fn test_run_on_directory() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test_file.txt"); + File::create(&file_path) + .unwrap() + .write_all(b"Hello") + .unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg(temp_dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("test_file.txt")); // Basic check +} + +#[test] +fn test_json_output() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("data.json"); + File::create(&file_path).unwrap().write_all(b"{}").unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sized")); + cmd.arg(temp_dir.path()) + .arg("--format") + .arg("json") + .assert() + .success() + .stdout(predicate::str::contains("\"entry_type\":\"File\"")) + .stdout(predicate::str::contains("\"path\":")); +}