From 4036c8f83ebe8887c1a1f3b8099a2de17ed90f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 19:32:34 +0000 Subject: [PATCH 01/43] feat(man): add man page generation using clap_mangen Add a `pdu-man-page` binary that generates a roff-formatted man page from the clap command definition. The generated `exports/pdu.1` file is kept in sync via a `sync_man_page` test, following the same pattern as completions and help text. Update generate-completions.sh, deploy CI, test matrix, and PKGBUILD templates to include the man page. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- .github/workflows/deploy.yaml | 10 ++ Cargo.lock | 17 +++ Cargo.toml | 8 ++ cli/man_page.rs | 16 +++ exports/pdu.1 | 136 ++++++++++++++++++++++ generate-completions.sh | 1 + run.sh | 2 +- template/parallel-disk-usage-bin/PKGBUILD | 1 + template/parallel-disk-usage/PKGBUILD | 1 + test.sh | 1 + tests/sync_man_page.rs | 26 +++++ 11 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 cli/man_page.rs create mode 100644 exports/pdu.1 create mode 100644 tests/sync_man_page.rs diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1ea0dd3b..4e240bca 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -271,6 +271,16 @@ jobs: asset_name: completion.elv asset_content_type: text/plain + - name: Upload Man Page + uses: actions/upload-release-asset@v1.0.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create_release.outputs.upload_url }} + asset_path: ./exports/pdu.1 + asset_name: pdu.1 + asset_content_type: text/plain + upload_release_assets: name: Upload Release Assets diff --git a/Cargo.lock b/Cargo.lock index dcf9b338..9a5e1037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -636,6 +646,7 @@ dependencies = [ "clap", "clap-utilities", "clap_complete", + "clap_mangen", "command-extra", "dashmap", "derive_more 2.1.1", @@ -770,6 +781,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "roff" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" + [[package]] name = "rounded-div" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index f408062c..cf40a228 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,11 @@ name = "pdu-completions" path = "cli/completions.rs" required-features = ["cli-completions"] +[[bin]] +name = "pdu-man-page" +path = "cli/man_page.rs" +required-features = ["cli-man"] + [[bin]] name = "pdu-usage-md" path = "cli/usage_md.rs" @@ -59,12 +64,14 @@ default = ["cli"] json = ["serde/derive", "serde_json"] cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] cli-completions = ["cli"] +cli-man = ["cli", "clap_mangen"] ai-instructions = ["clap/derive"] [dependencies] assert-cmp = "0.3.0" clap = { version = "4.5.60", optional = true } clap_complete = { version = "4.5.66", optional = true } +clap_mangen = { version = "0.2.27", optional = true } clap-utilities = { version = "0.3.0", optional = true } dashmap = "6.1.0" derive_more = { version = "2.1.1", features = ["full"] } @@ -85,6 +92,7 @@ zero-copy-pads = "0.2.0" [dev-dependencies] build-fs-tree = "0.8.1" +clap_mangen = "0.2.27" command-extra = "1.0.0" libc = "0.2.182" maplit = "1.0.2" diff --git a/cli/man_page.rs b/cli/man_page.rs new file mode 100644 index 00000000..c04e67c9 --- /dev/null +++ b/cli/man_page.rs @@ -0,0 +1,16 @@ +use clap::CommandFactory; +use clap_mangen::Man; +use parallel_disk_usage::args::Args; +use std::{io::stdout, process::ExitCode}; + +fn main() -> ExitCode { + let command = Args::command(); + let man = Man::new(command); + match man.render(&mut stdout()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/exports/pdu.1 b/exports/pdu.1 new file mode 100644 index 00000000..8e73ecfa --- /dev/null +++ b/exports/pdu.1 @@ -0,0 +1,136 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH pdu 1 "pdu 0.21.1" +.SH NAME +pdu \- Summarize disk usage of the set of files, recursively for directories. +.SH SYNOPSIS +\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR] [\fB\-w\fR|\fB\-\-total\-width\fR] [\fB\-\-column\-width\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR] +.SH DESCRIPTION +Summarize disk usage of the set of files, recursively for directories. +.PP +Copyright: Apache\-2.0 © 2021 Hoàng Văn Khải +Sponsor: https://github.com/sponsors/KSXGitHub +.SH OPTIONS +.TP +\fB\-\-json\-input\fR +Read JSON data from stdin +.TP +\fB\-\-json\-output\fR +Print JSON data instead of an ASCII chart +.TP +\fB\-b\fR, \fB\-\-bytes\-format\fR \fI\fR [default: metric] +How to display the numbers of bytes +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +plain: Display plain number of bytes without units +.IP \(bu 2 +metric: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on +.IP \(bu 2 +binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on +.RE +.TP +\fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR +Detect and subtract the sizes of hardlinks from their parent directory totals +.TP +\fB\-x\fR, \fB\-\-one\-file\-system\fR +Skip directories on different filesystems +.TP +\fB\-\-top\-down\fR +Print the tree top\-down instead of bottom\-up +.TP +\fB\-\-align\-right\fR +Set the root of the bars to the right +.TP +\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block\-size] +Aspect of the files/directories to be measured +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +apparent\-size: Measure apparent sizes +.IP \(bu 2 +block\-size: Measure block sizes (block\-count * 512B) +.IP \(bu 2 +block\-count: Count numbers of blocks +.RE +.TP +\fB\-d\fR, \fB\-\-max\-depth\fR \fI\fR [default: 10] +Maximum depth to display the data. Could be either "inf" or a positive integer +.TP +\fB\-w\fR, \fB\-\-total\-width\fR \fI\fR +Width of the visualization +.TP +\fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR +Maximum widths of the tree column and width of the bar column +.TP +\fB\-m\fR, \fB\-\-min\-ratio\fR \fI\fR [default: 0.01] +Minimal size proportion required to appear +.TP +\fB\-\-no\-sort\fR +Do not sort the branches in the tree +.TP +\fB\-s\fR, \fB\-\-silent\-errors\fR +Prevent filesystem error messages from appearing in stderr +.TP +\fB\-p\fR, \fB\-\-progress\fR +Report progress being made at the expense of performance +.TP +\fB\-\-threads\fR \fI\fR [default: auto] +Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer +.TP +\fB\-\-omit\-json\-shared\-details\fR +Do not output `.shared.details` in the JSON output +.TP +\fB\-\-omit\-json\-shared\-summary\fR +Do not output `.shared.summary` in the JSON output +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help (see a summary with \*(Aq\-h\*(Aq) +.TP +\fB\-V\fR, \fB\-\-version\fR +Print version +.TP +[\fIFILES\fR] +List of files and/or directories +.SH EXTRA +Examples: + Show disk usage chart of current working directory + $ pdu + + Show disk usage chart of a single file or directory + $ pdu path/to/file/or/directory + + Compare disk usages of multiple files and/or directories + $ pdu file.txt dir/ + + Show chart in apparent sizes instead of block sizes + $ pdu \-\-quantity=apparent\-size + + Detect and subtract the sizes of hardlinks from their parent nodes + $ pdu \-\-deduplicate\-hardlinks + + Show sizes in plain numbers instead of metric units + $ pdu \-\-bytes\-format=plain + + Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) + $ pdu \-\-bytes\-format=binary + + Show disk usage chart of all entries regardless of size + $ pdu \-\-min\-ratio=0 + + Only show disk usage chart of entries whose size is at least 5% of total + $ pdu \-\-min\-ratio=0.05 + + Show disk usage data as JSON instead of chart + $ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq + + Visualize existing JSON representation of disk usage data + $ pdu \-\-json\-input < disk\-usage.json +.SH VERSION +v0.21.1 diff --git a/generate-completions.sh b/generate-completions.sh index e031cb1f..c3d8bf59 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -17,3 +17,4 @@ gen elvish completion.elv ./run.sh pdu --help | sed 's/[[:space:]]*$//' > exports/long.help ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md +./run.sh pdu-man-page > exports/pdu.1 diff --git a/run.sh b/run.sh index 8ebdb490..bdd3e6fa 100755 --- a/run.sh +++ b/run.sh @@ -1,3 +1,3 @@ #! /bin/bash set -o errexit -o pipefail -o nounset -exec cargo run --bin="$1" --features cli-completions,ai-instructions -- "${@:2}" +exec cargo run --bin="$1" --features cli-completions,cli-man,ai-instructions -- "${@:2}" diff --git a/template/parallel-disk-usage-bin/PKGBUILD b/template/parallel-disk-usage-bin/PKGBUILD index 83691380..ea5dfa18 100644 --- a/template/parallel-disk-usage-bin/PKGBUILD +++ b/template/parallel-disk-usage-bin/PKGBUILD @@ -21,4 +21,5 @@ package() { install -Dm644 "completion.$pkgver.bash" "$pkgdir/usr/share/bash-completion/completions/pdu" install -Dm644 "completion.$pkgver.fish" "$pkgdir/usr/share/fish/completions/pdu.fish" install -Dm644 "completion.$pkgver.zsh" "$pkgdir/usr/share/zsh/site-functions/_pdu" + install -Dm644 "pdu.$pkgver.1" "$pkgdir/usr/share/man/man1/pdu.1" } diff --git a/template/parallel-disk-usage/PKGBUILD b/template/parallel-disk-usage/PKGBUILD index 88387f8f..7e3c59aa 100644 --- a/template/parallel-disk-usage/PKGBUILD +++ b/template/parallel-disk-usage/PKGBUILD @@ -20,4 +20,5 @@ package() { install -Dm644 exports/completion.bash "$pkgdir/usr/share/bash-completion/completions/pdu" install -Dm644 exports/completion.fish "$pkgdir/usr/share/fish/completions/pdu.fish" install -Dm644 exports/completion.zsh "$pkgdir/usr/share/zsh/site-functions/_pdu" + install -Dm644 exports/pdu.1 "$pkgdir/usr/share/man/man1/pdu.1" } diff --git a/test.sh b/test.sh index e0554d02..adca2528 100755 --- a/test.sh +++ b/test.sh @@ -77,6 +77,7 @@ unit --no-default-features "$@" unit --all-features "$@" unit --features cli "$@" unit --features cli-completions "$@" +unit --features cli-man "$@" unit --features ai-instructions "$@" if [[ -f "$failure_marker" ]]; then diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs new file mode 100644 index 00000000..24cb36af --- /dev/null +++ b/tests/sync_man_page.rs @@ -0,0 +1,26 @@ +//! The following test checks whether the man page file is outdated. +//! +//! If the test fails, run `./generate-completions.sh` on the root of the repo to update the man page. + +// Since the CLI in Windows looks a little different, and I am way too lazy to make two versions +// of man page files, the following test would only run in UNIX-like environment. +#![cfg(unix)] +#![cfg(feature = "cli")] + +use clap::CommandFactory; +use clap_mangen::Man; +use parallel_disk_usage::args::Args; + +#[test] +fn man_page() { + let command = Args::command(); + let man = Man::new(command); + let mut buffer = Vec::new(); + man.render(&mut buffer).expect("render man page to buffer"); + let received = String::from_utf8(buffer).expect("man page should be valid UTF-8"); + let expected = include_str!("../exports/pdu.1"); + assert!( + received == expected, + "man page is outdated, run ./generate-completions.sh to update it", + ); +} From 35dbf947013e7f41fadf11c6a0707a5bf69628d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:32:58 +0000 Subject: [PATCH 02/43] refactor(man): rename EXTRA section to EXAMPLES and extract render function Move man page rendering into a shared `src/man_page.rs` module (gated on `cli-man` feature) so both the binary and sync test use the same logic. Replace the `.SH EXTRA` section header with `.SH EXAMPLES` in the generated output. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- Cargo.toml | 1 - cli/man_page.rs | 15 +++++++-------- exports/pdu.1 | 2 +- src/lib.rs | 2 ++ src/man_page.rs | 15 +++++++++++++++ tests/sync_man_page.rs | 12 +++--------- 6 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 src/man_page.rs diff --git a/Cargo.toml b/Cargo.toml index cf40a228..6e3433c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,6 @@ zero-copy-pads = "0.2.0" [dev-dependencies] build-fs-tree = "0.8.1" -clap_mangen = "0.2.27" command-extra = "1.0.0" libc = "0.2.182" maplit = "1.0.2" diff --git a/cli/man_page.rs b/cli/man_page.rs index c04e67c9..4c2509e0 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -1,13 +1,12 @@ -use clap::CommandFactory; -use clap_mangen::Man; -use parallel_disk_usage::args::Args; -use std::{io::stdout, process::ExitCode}; +use parallel_disk_usage::man_page::render_man_page; +use std::process::ExitCode; fn main() -> ExitCode { - let command = Args::command(); - let man = Man::new(command); - match man.render(&mut stdout()) { - Ok(()) => ExitCode::SUCCESS, + match render_man_page() { + Ok(content) => { + print!("{content}"); + ExitCode::SUCCESS + } Err(error) => { eprintln!("error: {error}"); ExitCode::FAILURE diff --git a/exports/pdu.1 b/exports/pdu.1 index 8e73ecfa..ef79fe38 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -98,7 +98,7 @@ Print version .TP [\fIFILES\fR] List of files and/or directories -.SH EXTRA +.SH EXAMPLES Examples: Show disk usage chart of current working directory $ pdu diff --git a/src/lib.rs b/src/lib.rs index 7aeb6e90..2f50ceed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ pub use serde_json; pub mod app; #[cfg(feature = "cli")] pub mod args; +#[cfg(feature = "cli-man")] +pub mod man_page; #[cfg(feature = "cli")] pub mod runtime_error; #[cfg(feature = "cli")] diff --git a/src/man_page.rs b/src/man_page.rs new file mode 100644 index 00000000..cbbeba7a --- /dev/null +++ b/src/man_page.rs @@ -0,0 +1,15 @@ +use crate::args::Args; +use clap::CommandFactory; +use clap_mangen::Man; +use std::io; + +/// Renders the man page for `pdu` as a string. +pub fn render_man_page() -> io::Result { + let command = Args::command(); + let man = Man::new(command); + let mut buffer = Vec::new(); + man.render(&mut buffer)?; + let content = String::from_utf8(buffer) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok(content.replace("\n.SH EXTRA\n", "\n.SH EXAMPLES\n")) +} diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index 24cb36af..02f08a08 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -5,19 +5,13 @@ // Since the CLI in Windows looks a little different, and I am way too lazy to make two versions // of man page files, the following test would only run in UNIX-like environment. #![cfg(unix)] -#![cfg(feature = "cli")] +#![cfg(feature = "cli-man")] -use clap::CommandFactory; -use clap_mangen::Man; -use parallel_disk_usage::args::Args; +use parallel_disk_usage::man_page::render_man_page; #[test] fn man_page() { - let command = Args::command(); - let man = Man::new(command); - let mut buffer = Vec::new(); - man.render(&mut buffer).expect("render man page to buffer"); - let received = String::from_utf8(buffer).expect("man page should be valid UTF-8"); + let received = render_man_page().expect("render man page"); let expected = include_str!("../exports/pdu.1"); assert!( received == expected, From 1fa57cc31772d7b8bf1a69449dda96e580ecf3d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:50:15 +0000 Subject: [PATCH 03/43] fix(man): clean up EXAMPLES section formatting Remove the redundant "Examples:" subheading and strip unnecessary 4-space indentation from the examples content in the generated man page. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 45 ++++++++++++++++++++++----------------------- src/man_page.rs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index ef79fe38..bf37a1f9 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -99,38 +99,37 @@ Print version [\fIFILES\fR] List of files and/or directories .SH EXAMPLES -Examples: - Show disk usage chart of current working directory - $ pdu +Show disk usage chart of current working directory +$ pdu - Show disk usage chart of a single file or directory - $ pdu path/to/file/or/directory +Show disk usage chart of a single file or directory +$ pdu path/to/file/or/directory - Compare disk usages of multiple files and/or directories - $ pdu file.txt dir/ +Compare disk usages of multiple files and/or directories +$ pdu file.txt dir/ - Show chart in apparent sizes instead of block sizes - $ pdu \-\-quantity=apparent\-size +Show chart in apparent sizes instead of block sizes +$ pdu \-\-quantity=apparent\-size - Detect and subtract the sizes of hardlinks from their parent nodes - $ pdu \-\-deduplicate\-hardlinks +Detect and subtract the sizes of hardlinks from their parent nodes +$ pdu \-\-deduplicate\-hardlinks - Show sizes in plain numbers instead of metric units - $ pdu \-\-bytes\-format=plain +Show sizes in plain numbers instead of metric units +$ pdu \-\-bytes\-format=plain - Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) - $ pdu \-\-bytes\-format=binary +Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) +$ pdu \-\-bytes\-format=binary - Show disk usage chart of all entries regardless of size - $ pdu \-\-min\-ratio=0 +Show disk usage chart of all entries regardless of size +$ pdu \-\-min\-ratio=0 - Only show disk usage chart of entries whose size is at least 5% of total - $ pdu \-\-min\-ratio=0.05 +Only show disk usage chart of entries whose size is at least 5% of total +$ pdu \-\-min\-ratio=0.05 - Show disk usage data as JSON instead of chart - $ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq +Show disk usage data as JSON instead of chart +$ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq - Visualize existing JSON representation of disk usage data - $ pdu \-\-json\-input < disk\-usage.json +Visualize existing JSON representation of disk usage data +$ pdu \-\-json\-input < disk\-usage.json .SH VERSION v0.21.1 diff --git a/src/man_page.rs b/src/man_page.rs index cbbeba7a..12c84c21 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -11,5 +11,35 @@ pub fn render_man_page() -> io::Result { man.render(&mut buffer)?; let content = String::from_utf8(buffer) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - Ok(content.replace("\n.SH EXTRA\n", "\n.SH EXAMPLES\n")) + Ok(postprocess_man_page(&content)) +} + +fn postprocess_man_page(content: &str) -> String { + let mut output = String::with_capacity(content.len()); + let mut in_examples = false; + + for line in content.lines() { + if line == ".SH EXTRA" { + output.push_str(".SH EXAMPLES\n"); + in_examples = true; + continue; + } + + if in_examples && line.starts_with(".SH ") { + in_examples = false; + } + + if in_examples { + let trimmed = line.strip_prefix(" ").unwrap_or(line); + if trimmed == "Examples:" { + continue; + } + output.push_str(trimmed); + } else { + output.push_str(line); + } + output.push('\n'); + } + + output } From ed7181b8516fbe32e909e4440533932bac2da2ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 02:08:18 +0000 Subject: [PATCH 04/43] refactor(man): replace clap_mangen with custom roff implementation Drop the clap_mangen dependency and write a custom man page renderer that produces proper roff output. This fixes several formatting issues: - DESCRIPTION section now uses .PP for paragraph breaks (copyright and sponsor lines no longer merge) - EXAMPLES section uses .TP for description/command pairs with .nf/.fi for literal command rendering - Boolean flags no longer show redundant "Possible values: true, false" - Visible aliases are included in option headers https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- Cargo.lock | 17 --- Cargo.toml | 3 +- cli/man_page.rs | 14 +- exports/pdu.1 | 94 +++++++------ src/man_page.rs | 305 ++++++++++++++++++++++++++++++++++++----- tests/sync_man_page.rs | 2 +- 6 files changed, 332 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a5e1037..dcf9b338 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,16 +161,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" -[[package]] -name = "clap_mangen" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" -dependencies = [ - "clap", - "roff", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -646,7 +636,6 @@ dependencies = [ "clap", "clap-utilities", "clap_complete", - "clap_mangen", "command-extra", "dashmap", "derive_more 2.1.1", @@ -781,12 +770,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "roff" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" - [[package]] name = "rounded-div" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 6e3433c2..8c19642c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,14 +64,13 @@ default = ["cli"] json = ["serde/derive", "serde_json"] cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] cli-completions = ["cli"] -cli-man = ["cli", "clap_mangen"] +cli-man = ["cli"] ai-instructions = ["clap/derive"] [dependencies] assert-cmp = "0.3.0" clap = { version = "4.5.60", optional = true } clap_complete = { version = "4.5.66", optional = true } -clap_mangen = { version = "0.2.27", optional = true } clap-utilities = { version = "0.3.0", optional = true } dashmap = "6.1.0" derive_more = { version = "2.1.1", features = ["full"] } diff --git a/cli/man_page.rs b/cli/man_page.rs index 4c2509e0..a1419240 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -1,15 +1,5 @@ use parallel_disk_usage::man_page::render_man_page; -use std::process::ExitCode; -fn main() -> ExitCode { - match render_man_page() { - Ok(content) => { - print!("{content}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("error: {error}"); - ExitCode::FAILURE - } - } +fn main() { + print!("{}", render_man_page()); } diff --git a/exports/pdu.1 b/exports/pdu.1 index bf37a1f9..7911b539 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -1,17 +1,19 @@ -.ie \n(.g .ds Aq \(aq -.el .ds Aq ' -.TH pdu 1 "pdu 0.21.1" +.TH pdu 1 "pdu 0.21.1" .SH NAME pdu \- Summarize disk usage of the set of files, recursively for directories. .SH SYNOPSIS -\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR] [\fB\-w\fR|\fB\-\-total\-width\fR] [\fB\-\-column\-width\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR] +\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fIFILES\fR] .SH DESCRIPTION Summarize disk usage of the set of files, recursively for directories. .PP Copyright: Apache\-2.0 © 2021 Hoàng Văn Khải +.br Sponsor: https://github.com/sponsors/KSXGitHub .SH OPTIONS .TP +[\fIFILES\fR] +List of files and/or directories +.TP \fB\-\-json\-input\fR Read JSON data from stdin .TP @@ -33,7 +35,7 @@ metric: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on .RE .TP -\fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR +\fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-detect\-links\fR, \fB\-\-dedupe\-links\fR Detect and subtract the sizes of hardlinks from their parent directory totals .TP \fB\-x\fR, \fB\-\-one\-file\-system\fR @@ -45,7 +47,7 @@ Print the tree top\-down instead of bottom\-up \fB\-\-align\-right\fR Set the root of the bars to the right .TP -\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block\-size] +\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block-size] Aspect of the files/directories to be measured .br @@ -60,10 +62,10 @@ block\-size: Measure block sizes (block\-count * 512B) block\-count: Count numbers of blocks .RE .TP -\fB\-d\fR, \fB\-\-max\-depth\fR \fI\fR [default: 10] +\fB\-d\fR, \fB\-\-max\-depth\fR, \fB\-\-depth\fR \fI\fR [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer .TP -\fB\-w\fR, \fB\-\-total\-width\fR \fI\fR +\fB\-w\fR, \fB\-\-total\-width\fR, \fB\-\-width\fR \fI\fR Width of the visualization .TP \fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR @@ -75,7 +77,7 @@ Minimal size proportion required to appear \fB\-\-no\-sort\fR Do not sort the branches in the tree .TP -\fB\-s\fR, \fB\-\-silent\-errors\fR +\fB\-s\fR, \fB\-\-silent\-errors\fR, \fB\-\-no\-errors\fR Prevent filesystem error messages from appearing in stderr .TP \fB\-p\fR, \fB\-\-progress\fR @@ -89,47 +91,61 @@ Do not output `.shared.details` in the JSON output .TP \fB\-\-omit\-json\-shared\-summary\fR Do not output `.shared.summary` in the JSON output -.TP -\fB\-h\fR, \fB\-\-help\fR -Print help (see a summary with \*(Aq\-h\*(Aq) -.TP -\fB\-V\fR, \fB\-\-version\fR -Print version -.TP -[\fIFILES\fR] -List of files and/or directories .SH EXAMPLES +.TP Show disk usage chart of current working directory -$ pdu - +.nf +\fB$ pdu\fR +.fi +.TP Show disk usage chart of a single file or directory -$ pdu path/to/file/or/directory - +.nf +\fB$ pdu path/to/file/or/directory\fR +.fi +.TP Compare disk usages of multiple files and/or directories -$ pdu file.txt dir/ - +.nf +\fB$ pdu file.txt dir/\fR +.fi +.TP Show chart in apparent sizes instead of block sizes -$ pdu \-\-quantity=apparent\-size - +.nf +\fB$ pdu \-\-quantity=apparent\-size\fR +.fi +.TP Detect and subtract the sizes of hardlinks from their parent nodes -$ pdu \-\-deduplicate\-hardlinks - +.nf +\fB$ pdu \-\-deduplicate\-hardlinks\fR +.fi +.TP Show sizes in plain numbers instead of metric units -$ pdu \-\-bytes\-format=plain - +.nf +\fB$ pdu \-\-bytes\-format=plain\fR +.fi +.TP Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) -$ pdu \-\-bytes\-format=binary - +.nf +\fB$ pdu \-\-bytes\-format=binary\fR +.fi +.TP Show disk usage chart of all entries regardless of size -$ pdu \-\-min\-ratio=0 - +.nf +\fB$ pdu \-\-min\-ratio=0\fR +.fi +.TP Only show disk usage chart of entries whose size is at least 5% of total -$ pdu \-\-min\-ratio=0.05 - +.nf +\fB$ pdu \-\-min\-ratio=0.05\fR +.fi +.TP Show disk usage data as JSON instead of chart -$ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq - +.nf +\fB$ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq\fR +.fi +.TP Visualize existing JSON representation of disk usage data -$ pdu \-\-json\-input < disk\-usage.json +.nf +\fB$ pdu \-\-json\-input < disk\-usage.json\fR +.fi .SH VERSION v0.21.1 diff --git a/src/man_page.rs b/src/man_page.rs index 12c84c21..3d504d28 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -1,45 +1,286 @@ use crate::args::Args; -use clap::CommandFactory; -use clap_mangen::Man; -use std::io; +use clap::{Arg, ArgAction, Command, CommandFactory}; +use std::fmt::Write; -/// Renders the man page for `pdu` as a string. -pub fn render_man_page() -> io::Result { +/// Renders the man page for `pdu` as a string in roff format. +pub fn render_man_page() -> String { let command = Args::command(); - let man = Man::new(command); - let mut buffer = Vec::new(); - man.render(&mut buffer)?; - let content = String::from_utf8(buffer) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - Ok(postprocess_man_page(&content)) -} - -fn postprocess_man_page(content: &str) -> String { - let mut output = String::with_capacity(content.len()); - let mut in_examples = false; - - for line in content.lines() { - if line == ".SH EXTRA" { - output.push_str(".SH EXAMPLES\n"); - in_examples = true; + let mut out = String::new(); + render_title(&mut out, &command); + render_name_section(&mut out, &command); + render_synopsis_section(&mut out, &command); + render_description_section(&mut out, &command); + render_options_section(&mut out, &command); + render_examples_section(&mut out, &command); + render_version_section(&mut out, &command); + out +} + +/// Escapes a string for roff by replacing hyphens with `\-`. +fn roff_escape(text: &str) -> String { + text.replace('-', r"\-") +} + +fn render_title(out: &mut String, command: &Command) { + let name = command.get_name(); + let version = command.get_version().unwrap_or_default(); + writeln!(out, ".TH {name} 1 \"{name} {version}\"").unwrap(); +} + +fn render_name_section(out: &mut String, command: &Command) { + let name = command.get_name(); + let about = command + .get_about() + .map(|text| text.to_string()) + .unwrap_or_default(); + writeln!(out, ".SH NAME").unwrap(); + writeln!(out, "{name} \\- {}", roff_escape(&about)).unwrap(); +} + +fn render_synopsis_section(out: &mut String, command: &Command) { + out.push_str(".SH SYNOPSIS\n"); + out.push_str(&format!("\\fB{}\\fR", command.get_name())); + for arg in command.get_arguments() { + if arg.is_positional() { continue; } - - if in_examples && line.starts_with(".SH ") { - in_examples = false; + if arg.is_hide_set() { + continue; } + out.push(' '); + render_synopsis_option(out, arg); + } + for arg in command.get_arguments() { + if !arg.is_positional() || arg.is_hide_set() { + continue; + } + out.push(' '); + render_synopsis_positional(out, arg); + } + out.push('\n'); +} - if in_examples { - let trimmed = line.strip_prefix(" ").unwrap_or(line); - if trimmed == "Examples:" { - continue; +fn render_synopsis_option(out: &mut String, arg: &Arg) { + out.push('['); + if let Some(short) = arg.get_short() { + write!(out, "\\fB\\-{}\\fR", roff_escape(&short.to_string())).unwrap(); + if arg.get_long().is_some() { + out.push('|'); + } + } + if let Some(long) = arg.get_long() { + write!(out, "\\fB\\-\\-{}\\fR", roff_escape(long)).unwrap(); + } + if arg.get_action().takes_values() { + if let Some(value_names) = arg.get_value_names() { + for name in value_names { + write!(out, " \\fI{}\\fR", roff_escape(name)).unwrap(); } - output.push_str(trimmed); + } + } + out.push(']'); +} + +fn render_synopsis_positional(out: &mut String, arg: &Arg) { + let name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()) + .unwrap_or_else(|| arg.get_id().as_str()); + if arg.is_required_set() { + write!(out, "\\fI{}\\fR", roff_escape(name)).unwrap(); + } else { + write!(out, "[\\fI{}\\fR]", roff_escape(name)).unwrap(); + } +} + +fn render_description_section(out: &mut String, command: &Command) { + out.push_str(".SH DESCRIPTION\n"); + let text = command + .get_long_about() + .or_else(|| command.get_about()) + .map(|text| text.to_string()) + .unwrap_or_default(); + render_paragraph_text(out, &text); +} + +/// Renders multi-line text with proper roff paragraph breaks. +/// +/// Empty lines in the input produce `.PP` (new paragraph) in the output. +/// Consecutive non-empty lines are joined with `.br` (line break). +fn render_paragraph_text(out: &mut String, text: &str) { + let mut need_paragraph = false; + let mut first = true; + for line in text.lines() { + if line.is_empty() { + need_paragraph = true; + continue; + } + if need_paragraph && !first { + out.push_str(".PP\n"); + need_paragraph = false; + } else if !first { + out.push_str(".br\n"); + } + first = false; + writeln!(out, "{}", roff_escape(line)).unwrap(); + } +} + +fn render_options_section(out: &mut String, command: &Command) { + out.push_str(".SH OPTIONS\n"); + for arg in command.get_arguments() { + if arg.is_hide_set() { + continue; + } + render_option_entry(out, arg); + } +} + +fn render_option_entry(out: &mut String, arg: &Arg) { + out.push_str(".TP\n"); + if arg.is_positional() { + render_option_header_positional(out, arg); + } else { + render_option_header_flag(out, arg); + } + let help = arg + .get_long_help() + .or_else(|| arg.get_help()) + .map(|text| text.to_string()) + .unwrap_or_default(); + writeln!(out, "{}", roff_escape(&help)).unwrap(); + render_possible_values(out, arg); +} + +fn render_option_header_positional(out: &mut String, arg: &Arg) { + let name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()) + .unwrap_or_else(|| arg.get_id().as_str()); + if arg.is_required_set() { + writeln!(out, "\\fI{name}\\fR").unwrap(); + } else { + writeln!(out, "[\\fI{name}\\fR]").unwrap(); + } +} + +fn render_option_header_flag(out: &mut String, arg: &Arg) { + let mut parts = Vec::new(); + if let Some(short) = arg.get_short() { + parts.push(format!("\\fB\\-{}\\fR", roff_escape(&short.to_string()))); + } + if let Some(long) = arg.get_long() { + parts.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long))); + } + for alias in arg.get_visible_aliases().unwrap_or_default() { + parts.push(format!("\\fB\\-\\-{}\\fR", roff_escape(alias))); + } + let header = parts.join(", "); + if arg.get_action().takes_values() { + let value_str = render_value_hint(arg); + writeln!(out, "{header} {value_str}").unwrap(); + } else { + writeln!(out, "{header}").unwrap(); + } +} + +fn render_value_hint(arg: &Arg) -> String { + let mut parts = Vec::new(); + if let Some(value_names) = arg.get_value_names() { + for name in value_names { + parts.push(format!("\\fI<{}>\\fR", roff_escape(name))); + } + } else { + parts.push(format!("\\fI<{}>\\fR", roff_escape(arg.get_id().as_str()))); + } + let value_part = parts.join("\\fI \\fR"); + let defaults: Vec<_> = arg + .get_default_values() + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect(); + if !defaults.is_empty() + && !arg.is_hide_default_value_set() + && !matches!(arg.get_action(), ArgAction::SetTrue) + { + format!("{value_part} [default: {}]", defaults.join(", ")) + } else { + value_part + } +} + +fn render_possible_values(out: &mut String, arg: &Arg) { + if arg.is_hide_possible_values_set() { + return; + } + if matches!( + arg.get_action(), + ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count + ) { + return; + } + let possible_values: Vec<_> = arg + .get_possible_values() + .into_iter() + .filter(|value| !value.is_hide_set()) + .collect(); + if possible_values.is_empty() { + return; + } + out.push_str(".br\n\n.br\n"); + out.push_str("\\fIPossible values:\\fR\n"); + out.push_str(".RS 14\n"); + for value in &possible_values { + let name = value.get_name(); + if let Some(help) = value.get_help() { + writeln!( + out, + ".IP \\(bu 2\n{}: {}", + roff_escape(name), + roff_escape(&help.to_string()) + ) + .unwrap(); } else { - output.push_str(line); + writeln!(out, ".IP \\(bu 2\n{}", roff_escape(name)).unwrap(); } - output.push('\n'); } + out.push_str(".RE\n"); +} - output +fn render_examples_section(out: &mut String, command: &Command) { + let text = match command.get_after_long_help() { + Some(text) => text.to_string(), + None => return, + }; + let mut lines = text.lines(); + let mut has_examples = false; + for line in lines.by_ref() { + if line.trim() == "Examples:" { + has_examples = true; + break; + } + } + if !has_examples { + return; + } + out.push_str(".SH EXAMPLES\n"); + for line in lines { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(command) = line.strip_prefix("$ ") { + writeln!(out, ".nf\n\\fB$ {}\\fR\n.fi", roff_escape(command)).unwrap(); + } else { + writeln!(out, ".TP\n{}", roff_escape(line)).unwrap(); + } + } +} + +fn render_version_section(out: &mut String, command: &Command) { + if let Some(version) = command.get_version() { + writeln!(out, ".SH VERSION\nv{version}").unwrap(); + } } diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index 02f08a08..7dbd73e4 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -11,7 +11,7 @@ use parallel_disk_usage::man_page::render_man_page; #[test] fn man_page() { - let received = render_man_page().expect("render man page"); + let received = render_man_page(); let expected = include_str!("../exports/pdu.1"); assert!( received == expected, From 8cca41ee91fcba562e65a8dd500c156205b586ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 02:31:36 +0000 Subject: [PATCH 05/43] refactor(man): remove cli-man feature, gate on cli instead The man page module only needs clap (provided by the cli feature), so there is no need for a separate cli-man feature. This matches how pdu-usage-md requires only cli. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- Cargo.toml | 3 +-- run.sh | 2 +- src/lib.rs | 2 +- test.sh | 1 - tests/sync_man_page.rs | 2 +- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8c19642c..db569865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ required-features = ["cli-completions"] [[bin]] name = "pdu-man-page" path = "cli/man_page.rs" -required-features = ["cli-man"] +required-features = ["cli"] [[bin]] name = "pdu-usage-md" @@ -64,7 +64,6 @@ default = ["cli"] json = ["serde/derive", "serde_json"] cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] cli-completions = ["cli"] -cli-man = ["cli"] ai-instructions = ["clap/derive"] [dependencies] diff --git a/run.sh b/run.sh index bdd3e6fa..8ebdb490 100755 --- a/run.sh +++ b/run.sh @@ -1,3 +1,3 @@ #! /bin/bash set -o errexit -o pipefail -o nounset -exec cargo run --bin="$1" --features cli-completions,cli-man,ai-instructions -- "${@:2}" +exec cargo run --bin="$1" --features cli-completions,ai-instructions -- "${@:2}" diff --git a/src/lib.rs b/src/lib.rs index 2f50ceed..f5a4d044 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,7 @@ pub use serde_json; pub mod app; #[cfg(feature = "cli")] pub mod args; -#[cfg(feature = "cli-man")] +#[cfg(feature = "cli")] pub mod man_page; #[cfg(feature = "cli")] pub mod runtime_error; diff --git a/test.sh b/test.sh index adca2528..e0554d02 100755 --- a/test.sh +++ b/test.sh @@ -77,7 +77,6 @@ unit --no-default-features "$@" unit --all-features "$@" unit --features cli "$@" unit --features cli-completions "$@" -unit --features cli-man "$@" unit --features ai-instructions "$@" if [[ -f "$failure_marker" ]]; then diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index 7dbd73e4..e1716e6c 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -5,7 +5,7 @@ // Since the CLI in Windows looks a little different, and I am way too lazy to make two versions // of man page files, the following test would only run in UNIX-like environment. #![cfg(unix)] -#![cfg(feature = "cli-man")] +#![cfg(feature = "cli")] use parallel_disk_usage::man_page::render_man_page; From bdd14518405aaaba3e5bcda0acc70c6582dd7c62 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 10:07:27 +0700 Subject: [PATCH 06/43] feat: generate rendered man page --- exports/pdu.1.man | 130 ++++++++++++++++++++++++++++++++++++++++ generate-completions.sh | 1 + 2 files changed, 131 insertions(+) create mode 100644 exports/pdu.1.man diff --git a/exports/pdu.1.man b/exports/pdu.1.man new file mode 100644 index 00000000..cdc3e768 --- /dev/null +++ b/exports/pdu.1.man @@ -0,0 +1,130 @@ +pdu(1) General Commands Manual pdu(1) + +NAME + pdu - Summarize disk usage of the set of files, recursively for directories. + +SYNOPSIS + pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] + [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] + [-w|--total-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] + [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ + mary] [FILES] + +DESCRIPTION + Summarize disk usage of the set of files, recursively for directories. + + Copyright: Apache-2.0 © 2021 Hoàng Văn Khải + Sponsor: https://github.com/sponsors/KSXGitHub + +OPTIONS + [FILES] + List of files and/or directories + + --json-input + Read JSON data from stdin + + --json-output + Print JSON data instead of an ASCII chart + + -b, --bytes-format [default: metric] + How to display the numbers of bytes + + Possible values: + + • plain: Display plain number of bytes without units + + • metric: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on + + • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on + + -H, --deduplicate-hardlinks, --detect-links, --dedupe-links + Detect and subtract the sizes of hardlinks from their parent directory totals + + -x, --one-file-system + Skip directories on different filesystems + + --top-down + Print the tree top-down instead of bottom-up + + --align-right + Set the root of the bars to the right + + -q, --quantity [default: block-size] + Aspect of the files/directories to be measured + + Possible values: + + • apparent-size: Measure apparent sizes + + • block-size: Measure block sizes (block-count * 512B) + + • block-count: Count numbers of blocks + + -d, --max-depth, --depth [default: 10] + Maximum depth to display the data. Could be either "inf" or a positive integer + + -w, --total-width, --width + Width of the visualization + + --column-width + Maximum widths of the tree column and width of the bar column + + -m, --min-ratio [default: 0.01] + Minimal size proportion required to appear + + --no-sort + Do not sort the branches in the tree + + -s, --silent-errors, --no-errors + Prevent filesystem error messages from appearing in stderr + + -p, --progress + Report progress being made at the expense of performance + + --threads [default: auto] + Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer + + --omit-json-shared-details + Do not output ‘.shared.details‘ in the JSON output + + --omit-json-shared-summary + Do not output ‘.shared.summary‘ in the JSON output + +EXAMPLES + Show disk usage chart of current working directory + $ pdu + + Show disk usage chart of a single file or directory + $ pdu path/to/file/or/directory + + Compare disk usages of multiple files and/or directories + $ pdu file.txt dir/ + + Show chart in apparent sizes instead of block sizes + $ pdu --quantity=apparent-size + + Detect and subtract the sizes of hardlinks from their parent nodes + $ pdu --deduplicate-hardlinks + + Show sizes in plain numbers instead of metric units + $ pdu --bytes-format=plain + + Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) + $ pdu --bytes-format=binary + + Show disk usage chart of all entries regardless of size + $ pdu --min-ratio=0 + + Only show disk usage chart of entries whose size is at least 5% of total + $ pdu --min-ratio=0.05 + + Show disk usage data as JSON instead of chart + $ pdu --min-ratio=0 --max-depth=inf --json-output | jq + + Visualize existing JSON representation of disk usage data + $ pdu --json-input < disk-usage.json + +VERSION + v0.21.1 + + pdu 0.21.1 pdu(1) diff --git a/generate-completions.sh b/generate-completions.sh index c3d8bf59..4845a9c8 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -18,3 +18,4 @@ gen elvish completion.elv ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md ./run.sh pdu-man-page > exports/pdu.1 +MANWIDTH=120 man ./exports/pdu.1 | sed 's/[[:space:]]*$//' > exports/pdu.1.man From 5c77ef20ff8708edc49292fe791741e74f550f59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:04:34 +0000 Subject: [PATCH 07/43] feat(man): add check/generate CLI with roff and man subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the simple stdout-only binary with a proper CLI that supports: - `pdu-man-page generate roff 1` — render roff source to stdout - `pdu-man-page generate man 1` — render man page via man(1) to stdout - `pdu-man-page check roff 1` — verify exports/pdu.1 is up-to-date - `pdu-man-page check man 1` — verify exports/pdu.1.man is up-to-date The sync test now spawns the binary with `check` (like sync_ai_instructions), and the `man` test is gated behind a `man-test` feature since it requires man(1) to be installed. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- Cargo.toml | 1 + cli/man_page.rs | 119 +++++++++++++++++++++++++++++++++++++++- generate-completions.sh | 4 +- tests/sync_man_page.rs | 47 +++++++++++++--- 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index db569865..b6a65ef1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ default = ["cli"] json = ["serde/derive", "serde_json"] cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] cli-completions = ["cli"] +man-test = [] ai-instructions = ["clap/derive"] [dependencies] diff --git a/cli/man_page.rs b/cli/man_page.rs index a1419240..56f308e8 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -1,5 +1,120 @@ +use clap::{Parser, ValueEnum}; use parallel_disk_usage::man_page::render_man_page; +use std::process::{Command, ExitCode}; -fn main() { - print!("{}", render_man_page()); +const MANWIDTH: &str = "120"; + +/// Manage generated man pages. +#[derive(Debug, Parser)] +struct Args { + /// Action to take. + #[clap(value_enum)] + action: Action, + /// Type of file to target. + #[clap(value_enum)] + kind: Kind, + /// Number of the man page. + #[clap(value_enum)] + page: Page, +} + +#[derive(Debug, Clone, ValueEnum)] +enum Action { + /// Check whether the man page is up-to-date. + Check, + /// Generate the man page. + Generate, +} + +#[derive(Debug, Clone, ValueEnum)] +enum Kind { + /// Check or generate the roff file (`pdu.N`) from `Args`. + Roff, + /// Check or generate the man file (`pdu.N.man`) from the generated roff file (`pdu.N`). + Man, +} + +#[derive(Debug, Clone, ValueEnum)] +enum Page { + #[clap(name = "1")] + One, +} + +impl Page { + fn number(&self) -> u8 { + match self { + Page::One => 1, + } + } +} + +fn roff_path(page_num: u8) -> String { + format!("exports/pdu.{page_num}") +} + +fn man_path(page_num: u8) -> String { + format!("exports/pdu.{page_num}.man") +} + +fn render_man_output(page_num: u8) -> Result { + let roff_file = roff_path(page_num); + let output = Command::new("man") + .env("MANWIDTH", MANWIDTH) + .arg(format!("./{roff_file}")) + .output() + .map_err(|error| format!("failed to run man: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("man failed: {stderr}")); + } + let content = String::from_utf8(output.stdout) + .map_err(|error| format!("man output is not UTF-8: {error}"))?; + Ok(content + .lines() + .map(str::trim_end) + .collect::>() + .join("\n")) +} + +fn check_file(path: &str, expected: &str) -> ExitCode { + match std::fs::read_to_string(path) { + Ok(actual) if actual == *expected => ExitCode::SUCCESS, + Ok(_) => { + eprintln!("{path} is outdated, run ./generate-completions.sh to update it"); + ExitCode::FAILURE + } + Err(error) => { + eprintln!("error reading {path}: {error}"); + ExitCode::FAILURE + } + } +} + +fn main() -> ExitCode { + let args = Args::parse(); + let page_num = args.page.number(); + match (args.action, args.kind) { + (Action::Generate, Kind::Roff) => { + print!("{}", render_man_page()); + ExitCode::SUCCESS + } + (Action::Generate, Kind::Man) => match render_man_output(page_num) { + Ok(content) => { + print!("{content}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + }, + (Action::Check, Kind::Roff) => check_file(&roff_path(page_num), &render_man_page()), + (Action::Check, Kind::Man) => match render_man_output(page_num) { + Ok(expected) => check_file(&man_path(page_num), &expected), + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + }, + } } diff --git a/generate-completions.sh b/generate-completions.sh index 4845a9c8..dd2215d7 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -17,5 +17,5 @@ gen elvish completion.elv ./run.sh pdu --help | sed 's/[[:space:]]*$//' > exports/long.help ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md -./run.sh pdu-man-page > exports/pdu.1 -MANWIDTH=120 man ./exports/pdu.1 | sed 's/[[:space:]]*$//' > exports/pdu.1.man +./run.sh pdu-man-page generate roff 1 > exports/pdu.1 +./run.sh pdu-man-page generate man 1 > exports/pdu.1.man diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index e1716e6c..4d1d5e6a 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -1,20 +1,49 @@ -//! The following test checks whether the man page file is outdated. +//! The following tests check whether the man page files are outdated. //! -//! If the test fails, run `./generate-completions.sh` on the root of the repo to update the man page. +//! If the tests fail, run `./generate-completions.sh` on the root of the repo to update the man page. // Since the CLI in Windows looks a little different, and I am way too lazy to make two versions -// of man page files, the following test would only run in UNIX-like environment. +// of man page files, the following tests would only run in UNIX-like environment. #![cfg(unix)] #![cfg(feature = "cli")] -use parallel_disk_usage::man_page::render_man_page; +use command_extra::CommandExtra; +use std::process::Command; -#[test] -fn man_page() { - let received = render_man_page(); - let expected = include_str!("../exports/pdu.1"); +const PDU_MAN_PAGE: &str = env!("CARGO_BIN_EXE_pdu-man-page"); + +fn check(kind: &str, page: &str) { + let output = Command::new(PDU_MAN_PAGE) + .with_args(["check", kind, page]) + .with_current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("spawn pdu-man-page"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = stdout.trim(); + if !stdout.is_empty() { + eprintln!("STDOUT:\n{stdout}\n"); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if !stderr.is_empty() { + eprintln!("STDERR:\n{stderr}\n"); + } assert!( - received == expected, + output.status.success(), "man page is outdated, run ./generate-completions.sh to update it", ); } + +#[test] +fn roff() { + check("roff", "1"); +} + +#[test] +#[cfg_attr( + not(feature = "man-test"), + ignore = "requires man(1); enable with --features man-test" +)] +fn man() { + check("man", "1"); +} From 8ac426b140062beb6317ac75dafaaf7268d41dab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:10:41 +0000 Subject: [PATCH 08/43] fix(man): always ignore man sync test instead of feature-gating The man(1) output varies across systems (different groff versions, line breaking, etc.), so the man sync test cannot reliably pass in CI. Make it always #[ignore] instead of gating on a man-test feature, and remove the man-test feature entirely. The pdu.1.man file is a reference file derived from pdu.1 (which IS sync-checked), so CI enforcement is not needed. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- Cargo.toml | 1 - tests/sync_man_page.rs | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b6a65ef1..db569865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,6 @@ default = ["cli"] json = ["serde/derive", "serde_json"] cli = ["clap/derive", "clap_complete", "clap-utilities", "json"] cli-completions = ["cli"] -man-test = [] ai-instructions = ["clap/derive"] [dependencies] diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index 4d1d5e6a..ed7db720 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -40,10 +40,7 @@ fn roff() { } #[test] -#[cfg_attr( - not(feature = "man-test"), - ignore = "requires man(1); enable with --features man-test" -)] +#[ignore = "requires man(1); run with --include-ignored to check"] fn man() { check("man", "1"); } From a9facf055ef596ba45cba2ff7cf2375a37814c13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:32:05 +0000 Subject: [PATCH 09/43] style(man): fix minor code style issues Remove unnecessary deref in string comparison and use descriptive name for `example_command` variable in `render_examples_section`. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 2 +- src/man_page.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 56f308e8..577c82f0 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -78,7 +78,7 @@ fn render_man_output(page_num: u8) -> Result { fn check_file(path: &str, expected: &str) -> ExitCode { match std::fs::read_to_string(path) { - Ok(actual) if actual == *expected => ExitCode::SUCCESS, + Ok(actual) if actual == expected => ExitCode::SUCCESS, Ok(_) => { eprintln!("{path} is outdated, run ./generate-completions.sh to update it"); ExitCode::FAILURE diff --git a/src/man_page.rs b/src/man_page.rs index 3d504d28..c4eaa313 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -271,8 +271,8 @@ fn render_examples_section(out: &mut String, command: &Command) { if line.is_empty() { continue; } - if let Some(command) = line.strip_prefix("$ ") { - writeln!(out, ".nf\n\\fB$ {}\\fR\n.fi", roff_escape(command)).unwrap(); + if let Some(example_command) = line.strip_prefix("$ ") { + writeln!(out, ".nf\n\\fB$ {}\\fR\n.fi", roff_escape(example_command)).unwrap(); } else { writeln!(out, ".TP\n{}", roff_escape(line)).unwrap(); } From debfc025768150d9895ec5296b4384b7ea74fe7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:34:42 +0000 Subject: [PATCH 10/43] feat(man): re-enable man sync test with runtime dependency check Re-enable the `man` test with a `which` check that panics with a diagnostic message and `TEST_SKIP` hint when `man` is not installed, following the same pattern as `fs_errors` and `cross_device_excludes_mount`. Add `man-db` to CI's external test dependency installation step and document it in CONTRIBUTING.md alongside the existing FUSE dependencies. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- .github/workflows/test.yaml | 2 +- CONTRIBUTING.md | 1 + tests/sync_man_page.rs | 9 ++++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c942c29d..765122ab 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,7 +46,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt update - sudo apt install -y squashfs-tools squashfuse fuse3 + sudo apt install -y squashfs-tools squashfuse fuse3 man-db - name: Test (dev) shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e761518..09f1c0a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -358,6 +358,7 @@ Some integration tests require external (non-Cargo) tools that are **not** manag - `squashfs-tools` (provides `mksquashfs`) — cross-device (`--one-file-system`) FUSE test - `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test - `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test +- `man-db` (provides `man`) — man page sync test Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `TEST_SKIP` variable to skip the test via `./test.sh`. diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index ed7db720..e5652f51 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -40,7 +40,14 @@ fn roff() { } #[test] -#[ignore = "requires man(1); run with --include-ignored to check"] fn man() { + if which::which("man").is_err() { + panic!( + "{}\n{}", + "error: This test requires `man` but it was not found.", + "hint: Install `man-db` for your platform, \ + or rerun via `TEST_SKIP='man' ./test.sh` to skip this test.", + ); + } check("man", "1"); } From 434c0bc85549e05c1afaeb5ee04e6d29a3977fa7 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 12:36:51 +0700 Subject: [PATCH 11/43] chore: update `pdu.1.man` --- exports/pdu.1.man | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exports/pdu.1.man b/exports/pdu.1.man index cdc3e768..96ffae5a 100644 --- a/exports/pdu.1.man +++ b/exports/pdu.1.man @@ -127,4 +127,4 @@ EXAMPLES VERSION v0.21.1 - pdu 0.21.1 pdu(1) + pdu 0.21.1 pdu(1) \ No newline at end of file From 9814ffdc5c3f8afbf115bcbcca30b9aec47e0fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:47:21 +0000 Subject: [PATCH 12/43] feat(man): use groff directly for deterministic man page rendering Replace `man` with `groff -man -T utf8` for rendering the man page reference file. This produces consistent output across systems by bypassing system-specific man(1) wrappers and configuration. Also normalize the rendered output to strip trailing whitespace per line and ensure exactly one trailing newline. Update CI to install groff-base, sync test to check for groff, and CONTRIBUTING.md to document the dependency. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- .github/workflows/test.yaml | 2 +- CONTRIBUTING.md | 2 +- cli/man_page.rs | 26 ++++++++++++++++++-------- tests/sync_man_page.rs | 6 +++--- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 765122ab..6254adeb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,7 +46,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt update - sudo apt install -y squashfs-tools squashfuse fuse3 man-db + sudo apt install -y squashfs-tools squashfuse fuse3 groff-base - name: Test (dev) shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09f1c0a3..c36979e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -358,7 +358,7 @@ Some integration tests require external (non-Cargo) tools that are **not** manag - `squashfs-tools` (provides `mksquashfs`) — cross-device (`--one-file-system`) FUSE test - `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test - `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test -- `man-db` (provides `man`) — man page sync test +- `groff-base` (provides `groff`) — man page rendering sync test Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `TEST_SKIP` variable to skip the test via `./test.sh`. diff --git a/cli/man_page.rs b/cli/man_page.rs index 577c82f0..524cd050 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -2,7 +2,7 @@ use clap::{Parser, ValueEnum}; use parallel_disk_usage::man_page::render_man_page; use std::process::{Command, ExitCode}; -const MANWIDTH: &str = "120"; +const LINE_LENGTH: &str = "120"; /// Manage generated man pages. #[derive(Debug, Parser)] @@ -58,22 +58,32 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); - let output = Command::new("man") - .env("MANWIDTH", MANWIDTH) + let output = Command::new("groff") + .args(["-man", "-T", "utf8", "-r", &format!("LL={LINE_LENGTH}n")]) .arg(format!("./{roff_file}")) .output() - .map_err(|error| format!("failed to run man: {error}"))?; + .map_err(|error| format!("failed to run groff: {error}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("man failed: {stderr}")); + return Err(format!("groff failed: {stderr}")); } let content = String::from_utf8(output.stdout) - .map_err(|error| format!("man output is not UTF-8: {error}"))?; - Ok(content + .map_err(|error| format!("groff output is not UTF-8: {error}"))?; + Ok(normalize_text(&content)) +} + +/// Strips trailing whitespace per line, trims trailing blank lines, +/// and ensures the output ends with exactly one newline. +fn normalize_text(text: &str) -> String { + let mut result: String = text .lines() .map(str::trim_end) .collect::>() - .join("\n")) + .join("\n"); + let trimmed_len = result.trim_end().len(); + result.truncate(trimmed_len); + result.push('\n'); + result } fn check_file(path: &str, expected: &str) -> ExitCode { diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index e5652f51..c0fac911 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -41,11 +41,11 @@ fn roff() { #[test] fn man() { - if which::which("man").is_err() { + if which::which("groff").is_err() { panic!( "{}\n{}", - "error: This test requires `man` but it was not found.", - "hint: Install `man-db` for your platform, \ + "error: This test requires `groff` but it was not found.", + "hint: Install `groff` (or `groff-base`) for your platform, \ or rerun via `TEST_SKIP='man' ./test.sh` to skip this test.", ); } From 3aea3d55d77d924516fff8cd5eddf007bb1d4c0b Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 12:51:13 +0700 Subject: [PATCH 13/43] chore: update `pdu.1.man` --- exports/pdu.1.man | 94 +++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/exports/pdu.1.man b/exports/pdu.1.man index 96ffae5a..674066c1 100644 --- a/exports/pdu.1.man +++ b/exports/pdu.1.man @@ -1,35 +1,35 @@ -pdu(1) General Commands Manual pdu(1) +pdu(1) General Commands Manual pdu(1) -NAME +NAME pdu - Summarize disk usage of the set of files, recursively for directories. -SYNOPSIS - pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] - [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] - [-w|--total-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] - [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ - mary] [FILES] +SYNOPSIS + pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] + [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ + tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] + [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ + mary] [FILES] -DESCRIPTION +DESCRIPTION Summarize disk usage of the set of files, recursively for directories. - Copyright: Apache-2.0 © 2021 Hoàng Văn Khải + Copyright: Apache-2.0 © 2021 Hoà ng VÄn Khải Sponsor: https://github.com/sponsors/KSXGitHub -OPTIONS - [FILES] +OPTIONS + [FILES] List of files and/or directories - --json-input + --json-input Read JSON data from stdin - --json-output + --json-output Print JSON data instead of an ASCII chart - -b, --bytes-format [default: metric] + -b, --bytes-format  [default: metric] How to display the numbers of bytes - Possible values: + Possible values: • plain: Display plain number of bytes without units @@ -37,22 +37,22 @@ OPTIONS • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on - -H, --deduplicate-hardlinks, --detect-links, --dedupe-links + -H, --deduplicate-hardlinks, --detect-links, --dedupe-links Detect and subtract the sizes of hardlinks from their parent directory totals - -x, --one-file-system + -x, --one-file-system Skip directories on different filesystems - --top-down + --top-down Print the tree top-down instead of bottom-up - --align-right + --align-right Set the root of the bars to the right - -q, --quantity [default: block-size] + -q, --quantity  [default: block-size] Aspect of the files/directories to be measured - Possible values: + Possible values: • apparent-size: Measure apparent sizes @@ -60,71 +60,71 @@ OPTIONS • block-count: Count numbers of blocks - -d, --max-depth, --depth [default: 10] + -d, --max-depth, --depth  [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer - -w, --total-width, --width + -w, --total-width, --width  Width of the visualization - --column-width + --column-width   Maximum widths of the tree column and width of the bar column - -m, --min-ratio [default: 0.01] + -m, --min-ratio  [default: 0.01] Minimal size proportion required to appear - --no-sort + --no-sort Do not sort the branches in the tree - -s, --silent-errors, --no-errors + -s, --silent-errors, --no-errors Prevent filesystem error messages from appearing in stderr - -p, --progress + -p, --progress Report progress being made at the expense of performance - --threads [default: auto] + --threads  [default: auto] Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer - --omit-json-shared-details + --omit-json-shared-details Do not output ‘.shared.details‘ in the JSON output - --omit-json-shared-summary + --omit-json-shared-summary Do not output ‘.shared.summary‘ in the JSON output -EXAMPLES +EXAMPLES Show disk usage chart of current working directory - $ pdu + $ pdu Show disk usage chart of a single file or directory - $ pdu path/to/file/or/directory + $ pdu path/to/file/or/directory Compare disk usages of multiple files and/or directories - $ pdu file.txt dir/ + $ pdu file.txt dir/ Show chart in apparent sizes instead of block sizes - $ pdu --quantity=apparent-size + $ pdu --quantity=apparent-size Detect and subtract the sizes of hardlinks from their parent nodes - $ pdu --deduplicate-hardlinks + $ pdu --deduplicate-hardlinks Show sizes in plain numbers instead of metric units - $ pdu --bytes-format=plain + $ pdu --bytes-format=plain - Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) - $ pdu --bytes-format=binary + Show sizes in base 2¹Ⱐunits (binary) instead of base 10³ units (metric) + $ pdu --bytes-format=binary Show disk usage chart of all entries regardless of size - $ pdu --min-ratio=0 + $ pdu --min-ratio=0 Only show disk usage chart of entries whose size is at least 5% of total - $ pdu --min-ratio=0.05 + $ pdu --min-ratio=0.05 Show disk usage data as JSON instead of chart - $ pdu --min-ratio=0 --max-depth=inf --json-output | jq + $ pdu --min-ratio=0 --max-depth=inf --json-output | jq Visualize existing JSON representation of disk usage data - $ pdu --json-input < disk-usage.json + $ pdu --json-input < disk-usage.json -VERSION +VERSION v0.21.1 - pdu 0.21.1 pdu(1) \ No newline at end of file + pdu 0.21.1 pdu(1) From cf9c581dc1a016a0fb749d0a81b450bc407e8ecf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:50:38 +0000 Subject: [PATCH 14/43] fix(man): ignore man sync test on non-Linux platforms CI only installs groff-base on Linux, so the man test must be skipped on macOS and Windows via cfg_attr ignore. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- tests/sync_man_page.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index c0fac911..5a611d0a 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -40,6 +40,10 @@ fn roff() { } #[test] +#[cfg_attr( + not(target_os = "linux"), + ignore = "groff is only installed on Linux CI" +)] fn man() { if which::which("groff").is_err() { panic!( From ae6b848a2d40d64b137ecc840a6bebc3320dbcb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:52:31 +0000 Subject: [PATCH 15/43] refactor(man): write files directly in generate subcommand The generate subcommand now writes to exports/ files directly instead of printing to stdout, so the shell script no longer needs redirections. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 27 +++++++++++++++++---------- generate-completions.sh | 4 ++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 524cd050..906cc580 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -1,6 +1,9 @@ use clap::{Parser, ValueEnum}; use parallel_disk_usage::man_page::render_man_page; -use std::process::{Command, ExitCode}; +use std::{ + fs, + process::{Command, ExitCode}, +}; const LINE_LENGTH: &str = "120"; @@ -86,8 +89,18 @@ fn normalize_text(text: &str) -> String { result } +fn write_file(path: &str, content: &str) -> ExitCode { + match fs::write(path, content) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error writing {path}: {error}"); + ExitCode::FAILURE + } + } +} + fn check_file(path: &str, expected: &str) -> ExitCode { - match std::fs::read_to_string(path) { + match fs::read_to_string(path) { Ok(actual) if actual == expected => ExitCode::SUCCESS, Ok(_) => { eprintln!("{path} is outdated, run ./generate-completions.sh to update it"); @@ -104,15 +117,9 @@ fn main() -> ExitCode { let args = Args::parse(); let page_num = args.page.number(); match (args.action, args.kind) { - (Action::Generate, Kind::Roff) => { - print!("{}", render_man_page()); - ExitCode::SUCCESS - } + (Action::Generate, Kind::Roff) => write_file(&roff_path(page_num), &render_man_page()), (Action::Generate, Kind::Man) => match render_man_output(page_num) { - Ok(content) => { - print!("{content}"); - ExitCode::SUCCESS - } + Ok(content) => write_file(&man_path(page_num), &content), Err(error) => { eprintln!("error: {error}"); ExitCode::FAILURE diff --git a/generate-completions.sh b/generate-completions.sh index dd2215d7..a5c9a69d 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -17,5 +17,5 @@ gen elvish completion.elv ./run.sh pdu --help | sed 's/[[:space:]]*$//' > exports/long.help ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md -./run.sh pdu-man-page generate roff 1 > exports/pdu.1 -./run.sh pdu-man-page generate man 1 > exports/pdu.1.man +./run.sh pdu-man-page generate roff 1 +./run.sh pdu-man-page generate man 1 From 0d707e59e6b68603875c2c39c86296a315709262 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 05:54:17 +0000 Subject: [PATCH 16/43] fix(man): fix groff UTF-8 handling and strip ANSI escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `-K utf8` to handle UTF-8 input correctly (fixes mojibake in non-ASCII characters like © and Vietnamese names) and `-P -c` to disable SGR escape sequences in the output. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 906cc580..51433a6d 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,7 +62,17 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args(["-man", "-T", "utf8", "-r", &format!("LL={LINE_LENGTH}n")]) + .args([ + "-man", + "-T", + "utf8", + "-K", + "utf8", + "-P", + "-c", + "-r", + &format!("LL={LINE_LENGTH}n"), + ]) .arg(format!("./{roff_file}")) .output() .map_err(|error| format!("failed to run groff: {error}"))?; From c5b91900e5029c7b8f558a40520c74e9e3fc9be1 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 12:56:01 +0700 Subject: [PATCH 17/43] chore: update `pdu.1.man` --- exports/pdu.1.man | 94 +++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/exports/pdu.1.man b/exports/pdu.1.man index 674066c1..ab2be167 100644 --- a/exports/pdu.1.man +++ b/exports/pdu.1.man @@ -1,35 +1,35 @@ -pdu(1) General Commands Manual pdu(1) +_p_d_u(1) General Commands Manual _p_d_u(1) -NAME +NNAAMMEE pdu - Summarize disk usage of the set of files, recursively for directories. -SYNOPSIS - pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] - [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ - tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] - [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ - mary] [FILES] +SSYYNNOOPPSSIISS + ppdduu [----jjssoonn--iinnppuutt] [----jjssoonn--oouuttppuutt] [--bb|----bbyytteess--ffoorrmmaatt _B_Y_T_E_S___F_O_R_M_A_T] [--HH|----ddeedduupplliiccaattee--hhaarrddlliinnkkss] + [--xx|----oonnee--ffiillee--ssyysstteemm] [----ttoopp--ddoowwnn] [----aalliiggnn--rriigghhtt] [--qq|----qquuaannttiittyy _Q_U_A_N_T_I_T_Y] [--dd|----mmaaxx--ddeepptthh _M_A_X___D_E_P_T_H] [--ww|----ttoo‐‐ + ttaall--wwiiddtthh _T_O_T_A_L___W_I_D_T_H] [----ccoolluummnn--wwiiddtthh _T_R_E_E___W_I_D_T_H _B_A_R___W_I_D_T_H] [--mm|----mmiinn--rraattiioo _M_I_N___R_A_T_I_O] [----nnoo--ssoorrtt] + [--ss|----ssiilleenntt--eerrrroorrss] [--pp|----pprrooggrreessss] [----tthhrreeaaddss _T_H_R_E_A_D_S] [----oommiitt--jjssoonn--sshhaarreedd--ddeettaaiillss] [----oommiitt--jjssoonn--sshhaarreedd--ssuumm‐‐ + mmaarryy] [_F_I_L_E_S] -DESCRIPTION +DDEESSCCRRIIPPTTIIOONN Summarize disk usage of the set of files, recursively for directories. - Copyright: Apache-2.0 © 2021 Hoà ng VÄn Khải + Copyright: Apache-2.0 © 2021 Hoàng Văn Khải Sponsor: https://github.com/sponsors/KSXGitHub -OPTIONS - [FILES] +OOPPTTIIOONNSS + [_F_I_L_E_S] List of files and/or directories - --json-input + ----jjssoonn--iinnppuutt Read JSON data from stdin - --json-output + ----jjssoonn--oouuttppuutt Print JSON data instead of an ASCII chart - -b, --bytes-format  [default: metric] + --bb, ----bbyytteess--ffoorrmmaatt _<_B_Y_T_E_S___F_O_R_M_A_T_> [default: metric] How to display the numbers of bytes - Possible values: + _P_o_s_s_i_b_l_e _v_a_l_u_e_s_: • plain: Display plain number of bytes without units @@ -37,22 +37,22 @@ • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on - -H, --deduplicate-hardlinks, --detect-links, --dedupe-links + --HH, ----ddeedduupplliiccaattee--hhaarrddlliinnkkss, ----ddeetteecctt--lliinnkkss, ----ddeedduuppee--lliinnkkss Detect and subtract the sizes of hardlinks from their parent directory totals - -x, --one-file-system + --xx, ----oonnee--ffiillee--ssyysstteemm Skip directories on different filesystems - --top-down + ----ttoopp--ddoowwnn Print the tree top-down instead of bottom-up - --align-right + ----aalliiggnn--rriigghhtt Set the root of the bars to the right - -q, --quantity  [default: block-size] + --qq, ----qquuaannttiittyy _<_Q_U_A_N_T_I_T_Y_> [default: block-size] Aspect of the files/directories to be measured - Possible values: + _P_o_s_s_i_b_l_e _v_a_l_u_e_s_: • apparent-size: Measure apparent sizes @@ -60,71 +60,71 @@ • block-count: Count numbers of blocks - -d, --max-depth, --depth  [default: 10] + --dd, ----mmaaxx--ddeepptthh, ----ddeepptthh _<_M_A_X___D_E_P_T_H_> [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer - -w, --total-width, --width  + --ww, ----ttoottaall--wwiiddtthh, ----wwiiddtthh _<_T_O_T_A_L___W_I_D_T_H_> Width of the visualization - --column-width   + ----ccoolluummnn--wwiiddtthh _<_T_R_E_E___W_I_D_T_H_> _<_B_A_R___W_I_D_T_H_> Maximum widths of the tree column and width of the bar column - -m, --min-ratio  [default: 0.01] + --mm, ----mmiinn--rraattiioo _<_M_I_N___R_A_T_I_O_> [default: 0.01] Minimal size proportion required to appear - --no-sort + ----nnoo--ssoorrtt Do not sort the branches in the tree - -s, --silent-errors, --no-errors + --ss, ----ssiilleenntt--eerrrroorrss, ----nnoo--eerrrroorrss Prevent filesystem error messages from appearing in stderr - -p, --progress + --pp, ----pprrooggrreessss Report progress being made at the expense of performance - --threads  [default: auto] + ----tthhrreeaaddss _<_T_H_R_E_A_D_S_> [default: auto] Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer - --omit-json-shared-details + ----oommiitt--jjssoonn--sshhaarreedd--ddeettaaiillss Do not output ‘.shared.details‘ in the JSON output - --omit-json-shared-summary + ----oommiitt--jjssoonn--sshhaarreedd--ssuummmmaarryy Do not output ‘.shared.summary‘ in the JSON output -EXAMPLES +EEXXAAMMPPLLEESS Show disk usage chart of current working directory - $ pdu + $$ ppdduu Show disk usage chart of a single file or directory - $ pdu path/to/file/or/directory + $$ ppdduu ppaatthh//ttoo//ffiillee//oorr//ddiirreeccttoorryy Compare disk usages of multiple files and/or directories - $ pdu file.txt dir/ + $$ ppdduu ffiillee..ttxxtt ddiirr// Show chart in apparent sizes instead of block sizes - $ pdu --quantity=apparent-size + $$ ppdduu ----qquuaannttiittyy==aappppaarreenntt--ssiizzee Detect and subtract the sizes of hardlinks from their parent nodes - $ pdu --deduplicate-hardlinks + $$ ppdduu ----ddeedduupplliiccaattee--hhaarrddlliinnkkss Show sizes in plain numbers instead of metric units - $ pdu --bytes-format=plain + $$ ppdduu ----bbyytteess--ffoorrmmaatt==ppllaaiinn - Show sizes in base 2¹Ⱐunits (binary) instead of base 10³ units (metric) - $ pdu --bytes-format=binary + Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) + $$ ppdduu ----bbyytteess--ffoorrmmaatt==bbiinnaarryy Show disk usage chart of all entries regardless of size - $ pdu --min-ratio=0 + $$ ppdduu ----mmiinn--rraattiioo==00 Only show disk usage chart of entries whose size is at least 5% of total - $ pdu --min-ratio=0.05 + $$ ppdduu ----mmiinn--rraattiioo==00..0055 Show disk usage data as JSON instead of chart - $ pdu --min-ratio=0 --max-depth=inf --json-output | jq + $$ ppdduu ----mmiinn--rraattiioo==00 ----mmaaxx--ddeepptthh==iinnff ----jjssoonn--oouuttppuutt || jjqq Visualize existing JSON representation of disk usage data - $ pdu --json-input < disk-usage.json + $$ ppdduu ----jjssoonn--iinnppuutt << ddiisskk--uussaaggee..jjssoonn -VERSION +VVEERRSSIIOONN v0.21.1 - pdu 0.21.1 pdu(1) + pdu 0.21.1 _p_d_u(1) From 2f8d907b259e72985a1dada84c60384a94f23f07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:02:45 +0000 Subject: [PATCH 18/43] fix(man): strip grotty backspace overstrikes from groff output Grotty (groff's terminal output driver) uses char+backspace+char sequences for bold and _+backspace+char for underline. Strip these overstrike sequences in the binary rather than relying on external tools like col(1) or groff's -P -c flag. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 51433a6d..76c043b5 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,17 +62,8 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args([ - "-man", - "-T", - "utf8", - "-K", - "utf8", - "-P", - "-c", - "-r", - &format!("LL={LINE_LENGTH}n"), - ]) + .args(["-man", "-T", "utf8", "-K", "utf8"]) + .arg(format!("-rLL={LINE_LENGTH}n")) .arg(format!("./{roff_file}")) .output() .map_err(|error| format!("failed to run groff: {error}"))?; @@ -82,7 +73,26 @@ fn render_man_output(page_num: u8) -> Result { } let content = String::from_utf8(output.stdout) .map_err(|error| format!("groff output is not UTF-8: {error}"))?; - Ok(normalize_text(&content)) + Ok(normalize_text(&strip_overstrikes(&content))) +} + +/// Strips backspace-based overstriking sequences produced by grotty. +/// +/// Grotty uses `X\x08X` for bold and `_\x08X` for underline. This function +/// removes the overstrike prefix (char + `\x08`) leaving only the visible character. +fn strip_overstrikes(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut result = String::with_capacity(text.len()); + let mut index = 0; + while index < chars.len() { + if index + 1 < chars.len() && chars[index + 1] == '\x08' { + index += 2; + } else { + result.push(chars[index]); + index += 1; + } + } + result } /// Strips trailing whitespace per line, trims trailing blank lines, From 6980273f515b90160b89487ee57137776ae21358 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 13:20:43 +0700 Subject: [PATCH 19/43] chore: update `pdu.1.man` --- exports/pdu.1.man | 90 +++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/exports/pdu.1.man b/exports/pdu.1.man index ab2be167..e452a59c 100644 --- a/exports/pdu.1.man +++ b/exports/pdu.1.man @@ -1,35 +1,35 @@ -_p_d_u(1) General Commands Manual _p_d_u(1) +pdu(1) General Commands Manual pdu(1) -NNAAMMEE +NAME pdu - Summarize disk usage of the set of files, recursively for directories. -SSYYNNOOPPSSIISS - ppdduu [----jjssoonn--iinnppuutt] [----jjssoonn--oouuttppuutt] [--bb|----bbyytteess--ffoorrmmaatt _B_Y_T_E_S___F_O_R_M_A_T] [--HH|----ddeedduupplliiccaattee--hhaarrddlliinnkkss] - [--xx|----oonnee--ffiillee--ssyysstteemm] [----ttoopp--ddoowwnn] [----aalliiggnn--rriigghhtt] [--qq|----qquuaannttiittyy _Q_U_A_N_T_I_T_Y] [--dd|----mmaaxx--ddeepptthh _M_A_X___D_E_P_T_H] [--ww|----ttoo‐‐ - ttaall--wwiiddtthh _T_O_T_A_L___W_I_D_T_H] [----ccoolluummnn--wwiiddtthh _T_R_E_E___W_I_D_T_H _B_A_R___W_I_D_T_H] [--mm|----mmiinn--rraattiioo _M_I_N___R_A_T_I_O] [----nnoo--ssoorrtt] - [--ss|----ssiilleenntt--eerrrroorrss] [--pp|----pprrooggrreessss] [----tthhrreeaaddss _T_H_R_E_A_D_S] [----oommiitt--jjssoonn--sshhaarreedd--ddeettaaiillss] [----oommiitt--jjssoonn--sshhaarreedd--ssuumm‐‐ - mmaarryy] [_F_I_L_E_S] +SYNOPSIS + pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] + [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ + tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] + [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ + mary] [FILES] -DDEESSCCRRIIPPTTIIOONN +DESCRIPTION Summarize disk usage of the set of files, recursively for directories. Copyright: Apache-2.0 © 2021 Hoàng Văn Khải Sponsor: https://github.com/sponsors/KSXGitHub -OOPPTTIIOONNSS - [_F_I_L_E_S] +OPTIONS + [FILES] List of files and/or directories - ----jjssoonn--iinnppuutt + --json-input Read JSON data from stdin - ----jjssoonn--oouuttppuutt + --json-output Print JSON data instead of an ASCII chart - --bb, ----bbyytteess--ffoorrmmaatt _<_B_Y_T_E_S___F_O_R_M_A_T_> [default: metric] + -b, --bytes-format  [default: metric] How to display the numbers of bytes - _P_o_s_s_i_b_l_e _v_a_l_u_e_s_: + Possible values: • plain: Display plain number of bytes without units @@ -37,22 +37,22 @@ OOPPTTIIOONNSS • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on - --HH, ----ddeedduupplliiccaattee--hhaarrddlliinnkkss, ----ddeetteecctt--lliinnkkss, ----ddeedduuppee--lliinnkkss + -H, --deduplicate-hardlinks, --detect-links, --dedupe-links Detect and subtract the sizes of hardlinks from their parent directory totals - --xx, ----oonnee--ffiillee--ssyysstteemm + -x, --one-file-system Skip directories on different filesystems - ----ttoopp--ddoowwnn + --top-down Print the tree top-down instead of bottom-up - ----aalliiggnn--rriigghhtt + --align-right Set the root of the bars to the right - --qq, ----qquuaannttiittyy _<_Q_U_A_N_T_I_T_Y_> [default: block-size] + -q, --quantity  [default: block-size] Aspect of the files/directories to be measured - _P_o_s_s_i_b_l_e _v_a_l_u_e_s_: + Possible values: • apparent-size: Measure apparent sizes @@ -60,71 +60,71 @@ OOPPTTIIOONNSS • block-count: Count numbers of blocks - --dd, ----mmaaxx--ddeepptthh, ----ddeepptthh _<_M_A_X___D_E_P_T_H_> [default: 10] + -d, --max-depth, --depth  [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer - --ww, ----ttoottaall--wwiiddtthh, ----wwiiddtthh _<_T_O_T_A_L___W_I_D_T_H_> + -w, --total-width, --width  Width of the visualization - ----ccoolluummnn--wwiiddtthh _<_T_R_E_E___W_I_D_T_H_> _<_B_A_R___W_I_D_T_H_> + --column-width   Maximum widths of the tree column and width of the bar column - --mm, ----mmiinn--rraattiioo _<_M_I_N___R_A_T_I_O_> [default: 0.01] + -m, --min-ratio  [default: 0.01] Minimal size proportion required to appear - ----nnoo--ssoorrtt + --no-sort Do not sort the branches in the tree - --ss, ----ssiilleenntt--eerrrroorrss, ----nnoo--eerrrroorrss + -s, --silent-errors, --no-errors Prevent filesystem error messages from appearing in stderr - --pp, ----pprrooggrreessss + -p, --progress Report progress being made at the expense of performance - ----tthhrreeaaddss _<_T_H_R_E_A_D_S_> [default: auto] + --threads  [default: auto] Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer - ----oommiitt--jjssoonn--sshhaarreedd--ddeettaaiillss + --omit-json-shared-details Do not output ‘.shared.details‘ in the JSON output - ----oommiitt--jjssoonn--sshhaarreedd--ssuummmmaarryy + --omit-json-shared-summary Do not output ‘.shared.summary‘ in the JSON output -EEXXAAMMPPLLEESS +EXAMPLES Show disk usage chart of current working directory - $$ ppdduu + $ pdu Show disk usage chart of a single file or directory - $$ ppdduu ppaatthh//ttoo//ffiillee//oorr//ddiirreeccttoorryy + $ pdu path/to/file/or/directory Compare disk usages of multiple files and/or directories - $$ ppdduu ffiillee..ttxxtt ddiirr// + $ pdu file.txt dir/ Show chart in apparent sizes instead of block sizes - $$ ppdduu ----qquuaannttiittyy==aappppaarreenntt--ssiizzee + $ pdu --quantity=apparent-size Detect and subtract the sizes of hardlinks from their parent nodes - $$ ppdduu ----ddeedduupplliiccaattee--hhaarrddlliinnkkss + $ pdu --deduplicate-hardlinks Show sizes in plain numbers instead of metric units - $$ ppdduu ----bbyytteess--ffoorrmmaatt==ppllaaiinn + $ pdu --bytes-format=plain Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) - $$ ppdduu ----bbyytteess--ffoorrmmaatt==bbiinnaarryy + $ pdu --bytes-format=binary Show disk usage chart of all entries regardless of size - $$ ppdduu ----mmiinn--rraattiioo==00 + $ pdu --min-ratio=0 Only show disk usage chart of entries whose size is at least 5% of total - $$ ppdduu ----mmiinn--rraattiioo==00..0055 + $ pdu --min-ratio=0.05 Show disk usage data as JSON instead of chart - $$ ppdduu ----mmiinn--rraattiioo==00 ----mmaaxx--ddeepptthh==iinnff ----jjssoonn--oouuttppuutt || jjqq + $ pdu --min-ratio=0 --max-depth=inf --json-output | jq Visualize existing JSON representation of disk usage data - $$ ppdduu ----jjssoonn--iinnppuutt << ddiisskk--uussaaggee..jjssoonn + $ pdu --json-input < disk-usage.json -VVEERRSSIIOONN +VERSION v0.21.1 - pdu 0.21.1 _p_d_u(1) + pdu 0.21.1 pdu(1) From e5eae1d6b3f53d06fb32e802cd6a84100bd8175f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:30:04 +0000 Subject: [PATCH 20/43] fix(man): suppress groff formatting via grotty flags and GROFF_NO_SGR Use GROFF_NO_SGR=1 to prevent SGR (ANSI escape) output, and -P -c/-b/-u to suppress backspace overstrikes for bold/underline. Keep strip_formatting as a safety net for any remaining escape sequences. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 76c043b5..6593fa3b 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,7 +62,10 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args(["-man", "-T", "utf8", "-K", "utf8"]) + .args([ + "-man", "-T", "utf8", "-K", "utf8", "-P", "-c", "-P", "-b", "-P", "-u", + ]) + .env("GROFF_NO_SGR", "1") .arg(format!("-rLL={LINE_LENGTH}n")) .arg(format!("./{roff_file}")) .output() @@ -73,19 +76,30 @@ fn render_man_output(page_num: u8) -> Result { } let content = String::from_utf8(output.stdout) .map_err(|error| format!("groff output is not UTF-8: {error}"))?; - Ok(normalize_text(&strip_overstrikes(&content))) + Ok(normalize_text(&strip_formatting(&content))) } -/// Strips backspace-based overstriking sequences produced by grotty. +/// Strips terminal formatting from grotty output. /// -/// Grotty uses `X\x08X` for bold and `_\x08X` for underline. This function -/// removes the overstrike prefix (char + `\x08`) leaving only the visible character. -fn strip_overstrikes(text: &str) -> String { +/// Handles two styles grotty may use: +/// - **SGR mode** (default): ANSI escape sequences like `\x1b[1m` (bold), `\x1b[0m` (reset). +/// - **Legacy mode** (`-c`): Backspace overstrikes like `X\x08X` (bold), `_\x08X` (underline). +fn strip_formatting(text: &str) -> String { let chars: Vec = text.chars().collect(); let mut result = String::with_capacity(text.len()); let mut index = 0; while index < chars.len() { - if index + 1 < chars.len() && chars[index + 1] == '\x08' { + if chars[index] == '\x1b' && index + 1 < chars.len() && chars[index + 1] == '[' { + // Skip ANSI escape: ESC [ ... m + index += 2; + while index < chars.len() && chars[index] != 'm' { + index += 1; + } + if index < chars.len() { + index += 1; // skip the 'm' + } + } else if index + 1 < chars.len() && chars[index + 1] == '\x08' { + // Skip backspace overstrike: char + BS index += 2; } else { result.push(chars[index]); From b12de320035ee8d7e60ec83226eb527a73b41778 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:36:11 +0000 Subject: [PATCH 21/43] fix(man): simplify groff invocation to match working command Use `groff -Tutf8 -P-cbou -man` which produces clean plain-text output without ANSI escapes or backspace overstrikes. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 6593fa3b..5974600f 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,10 +62,7 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args([ - "-man", "-T", "utf8", "-K", "utf8", "-P", "-c", "-P", "-b", "-P", "-u", - ]) - .env("GROFF_NO_SGR", "1") + .args(["-man", "-Tutf8", "-P-cbou"]) .arg(format!("-rLL={LINE_LENGTH}n")) .arg(format!("./{roff_file}")) .output() From a785d0ac5367858f63797b7700079d96fce4da15 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 13:36:42 +0700 Subject: [PATCH 22/43] chore: update `pdu.1.man` --- exports/pdu.1.man | 90 +++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/exports/pdu.1.man b/exports/pdu.1.man index e452a59c..157604e4 100644 --- a/exports/pdu.1.man +++ b/exports/pdu.1.man @@ -1,35 +1,35 @@ -pdu(1) General Commands Manual pdu(1) +pdu(1) General Commands Manual pdu(1) -NAME +NAME pdu - Summarize disk usage of the set of files, recursively for directories. -SYNOPSIS - pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] - [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ - tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] - [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ - mary] [FILES] +SYNOPSIS + pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] + [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ + tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] + [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ + mary] [FILES] -DESCRIPTION +DESCRIPTION Summarize disk usage of the set of files, recursively for directories. Copyright: Apache-2.0 © 2021 Hoàng Văn Khải Sponsor: https://github.com/sponsors/KSXGitHub -OPTIONS - [FILES] +OPTIONS + [FILES] List of files and/or directories - --json-input + --json-input Read JSON data from stdin - --json-output + --json-output Print JSON data instead of an ASCII chart - -b, --bytes-format  [default: metric] + -b, --bytes-format [default: metric] How to display the numbers of bytes - Possible values: + Possible values: • plain: Display plain number of bytes without units @@ -37,22 +37,22 @@ • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on - -H, --deduplicate-hardlinks, --detect-links, --dedupe-links + -H, --deduplicate-hardlinks, --detect-links, --dedupe-links Detect and subtract the sizes of hardlinks from their parent directory totals - -x, --one-file-system + -x, --one-file-system Skip directories on different filesystems - --top-down + --top-down Print the tree top-down instead of bottom-up - --align-right + --align-right Set the root of the bars to the right - -q, --quantity  [default: block-size] + -q, --quantity [default: block-size] Aspect of the files/directories to be measured - Possible values: + Possible values: • apparent-size: Measure apparent sizes @@ -60,71 +60,71 @@ • block-count: Count numbers of blocks - -d, --max-depth, --depth  [default: 10] + -d, --max-depth, --depth [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer - -w, --total-width, --width  + -w, --total-width, --width Width of the visualization - --column-width   + --column-width Maximum widths of the tree column and width of the bar column - -m, --min-ratio  [default: 0.01] + -m, --min-ratio [default: 0.01] Minimal size proportion required to appear - --no-sort + --no-sort Do not sort the branches in the tree - -s, --silent-errors, --no-errors + -s, --silent-errors, --no-errors Prevent filesystem error messages from appearing in stderr - -p, --progress + -p, --progress Report progress being made at the expense of performance - --threads  [default: auto] + --threads [default: auto] Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer - --omit-json-shared-details + --omit-json-shared-details Do not output ‘.shared.details‘ in the JSON output - --omit-json-shared-summary + --omit-json-shared-summary Do not output ‘.shared.summary‘ in the JSON output -EXAMPLES +EXAMPLES Show disk usage chart of current working directory - $ pdu + $ pdu Show disk usage chart of a single file or directory - $ pdu path/to/file/or/directory + $ pdu path/to/file/or/directory Compare disk usages of multiple files and/or directories - $ pdu file.txt dir/ + $ pdu file.txt dir/ Show chart in apparent sizes instead of block sizes - $ pdu --quantity=apparent-size + $ pdu --quantity=apparent-size Detect and subtract the sizes of hardlinks from their parent nodes - $ pdu --deduplicate-hardlinks + $ pdu --deduplicate-hardlinks Show sizes in plain numbers instead of metric units - $ pdu --bytes-format=plain + $ pdu --bytes-format=plain Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) - $ pdu --bytes-format=binary + $ pdu --bytes-format=binary Show disk usage chart of all entries regardless of size - $ pdu --min-ratio=0 + $ pdu --min-ratio=0 Only show disk usage chart of entries whose size is at least 5% of total - $ pdu --min-ratio=0.05 + $ pdu --min-ratio=0.05 Show disk usage data as JSON instead of chart - $ pdu --min-ratio=0 --max-depth=inf --json-output | jq + $ pdu --min-ratio=0 --max-depth=inf --json-output | jq Visualize existing JSON representation of disk usage data - $ pdu --json-input < disk-usage.json + $ pdu --json-input < disk-usage.json -VERSION +VERSION v0.21.1 - pdu 0.21.1 pdu(1) + pdu 0.21.1 pdu(1) From 527a37598e0909f890d38640f2e8606afd66339c Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 13:38:18 +0700 Subject: [PATCH 23/43] chore(git): revert incorrect simplification This reverts commit b12de320035ee8d7e60ec83226eb527a73b41778. --- cli/man_page.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 5974600f..6593fa3b 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,7 +62,10 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args(["-man", "-Tutf8", "-P-cbou"]) + .args([ + "-man", "-T", "utf8", "-K", "utf8", "-P", "-c", "-P", "-b", "-P", "-u", + ]) + .env("GROFF_NO_SGR", "1") .arg(format!("-rLL={LINE_LENGTH}n")) .arg(format!("./{roff_file}")) .output() From 0ba4adf16f727e5d9238b805ba67a8dfb917195d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:42:04 +0000 Subject: [PATCH 24/43] fix(man): match exact groff invocation used to generate pdu.1.man Use `groff -Tutf8 -P-cbou -man` which matches the command that generated the checked-in pdu.1.man file. The previous invocation used separate -P flags and extra options (-K utf8, GROFF_NO_SGR) that could produce subtly different output on CI. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- cli/man_page.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/man_page.rs b/cli/man_page.rs index 6593fa3b..5974600f 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -62,10 +62,7 @@ fn man_path(page_num: u8) -> String { fn render_man_output(page_num: u8) -> Result { let roff_file = roff_path(page_num); let output = Command::new("groff") - .args([ - "-man", "-T", "utf8", "-K", "utf8", "-P", "-c", "-P", "-b", "-P", "-u", - ]) - .env("GROFF_NO_SGR", "1") + .args(["-man", "-Tutf8", "-P-cbou"]) .arg(format!("-rLL={LINE_LENGTH}n")) .arg(format!("./{roff_file}")) .output() From 7a1666129f2e40af59288ba05820ac4cdeb30d8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:47:14 +0000 Subject: [PATCH 25/43] refactor(man): simplify to just pdu.1 roff file Remove all groff/man rendering complexity (pdu.1.man, strip_formatting, grotty flags, groff CI dependency). The binary now simply prints the roff content to stdout, and generate-completions.sh redirects it to exports/pdu.1. The sync test uses the library function directly. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- .github/workflows/test.yaml | 2 +- CONTRIBUTING.md | 1 - cli/man_page.rs | 167 +----------------------------------- exports/pdu.1.man | 130 ---------------------------- generate-completions.sh | 3 +- tests/sync_man_page.rs | 55 ++---------- 6 files changed, 13 insertions(+), 345 deletions(-) delete mode 100644 exports/pdu.1.man diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6254adeb..c942c29d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -46,7 +46,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt update - sudo apt install -y squashfs-tools squashfuse fuse3 groff-base + sudo apt install -y squashfs-tools squashfuse fuse3 - name: Test (dev) shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c36979e5..8e761518 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -358,7 +358,6 @@ Some integration tests require external (non-Cargo) tools that are **not** manag - `squashfs-tools` (provides `mksquashfs`) — cross-device (`--one-file-system`) FUSE test - `squashfuse` (provides `squashfuse`) — cross-device (`--one-file-system`) FUSE test - `fuse3` (provides `fusermount3`, `/dev/fuse`) — cross-device (`--one-file-system`) FUSE test -- `groff-base` (provides `groff`) — man page rendering sync test Tests that need these tools will panic with a diagnostic message if they are missing. The panic message includes the specific `TEST_SKIP` variable to skip the test via `./test.sh`. diff --git a/cli/man_page.rs b/cli/man_page.rs index 5974600f..a1419240 100644 --- a/cli/man_page.rs +++ b/cli/man_page.rs @@ -1,168 +1,5 @@ -use clap::{Parser, ValueEnum}; use parallel_disk_usage::man_page::render_man_page; -use std::{ - fs, - process::{Command, ExitCode}, -}; -const LINE_LENGTH: &str = "120"; - -/// Manage generated man pages. -#[derive(Debug, Parser)] -struct Args { - /// Action to take. - #[clap(value_enum)] - action: Action, - /// Type of file to target. - #[clap(value_enum)] - kind: Kind, - /// Number of the man page. - #[clap(value_enum)] - page: Page, -} - -#[derive(Debug, Clone, ValueEnum)] -enum Action { - /// Check whether the man page is up-to-date. - Check, - /// Generate the man page. - Generate, -} - -#[derive(Debug, Clone, ValueEnum)] -enum Kind { - /// Check or generate the roff file (`pdu.N`) from `Args`. - Roff, - /// Check or generate the man file (`pdu.N.man`) from the generated roff file (`pdu.N`). - Man, -} - -#[derive(Debug, Clone, ValueEnum)] -enum Page { - #[clap(name = "1")] - One, -} - -impl Page { - fn number(&self) -> u8 { - match self { - Page::One => 1, - } - } -} - -fn roff_path(page_num: u8) -> String { - format!("exports/pdu.{page_num}") -} - -fn man_path(page_num: u8) -> String { - format!("exports/pdu.{page_num}.man") -} - -fn render_man_output(page_num: u8) -> Result { - let roff_file = roff_path(page_num); - let output = Command::new("groff") - .args(["-man", "-Tutf8", "-P-cbou"]) - .arg(format!("-rLL={LINE_LENGTH}n")) - .arg(format!("./{roff_file}")) - .output() - .map_err(|error| format!("failed to run groff: {error}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("groff failed: {stderr}")); - } - let content = String::from_utf8(output.stdout) - .map_err(|error| format!("groff output is not UTF-8: {error}"))?; - Ok(normalize_text(&strip_formatting(&content))) -} - -/// Strips terminal formatting from grotty output. -/// -/// Handles two styles grotty may use: -/// - **SGR mode** (default): ANSI escape sequences like `\x1b[1m` (bold), `\x1b[0m` (reset). -/// - **Legacy mode** (`-c`): Backspace overstrikes like `X\x08X` (bold), `_\x08X` (underline). -fn strip_formatting(text: &str) -> String { - let chars: Vec = text.chars().collect(); - let mut result = String::with_capacity(text.len()); - let mut index = 0; - while index < chars.len() { - if chars[index] == '\x1b' && index + 1 < chars.len() && chars[index + 1] == '[' { - // Skip ANSI escape: ESC [ ... m - index += 2; - while index < chars.len() && chars[index] != 'm' { - index += 1; - } - if index < chars.len() { - index += 1; // skip the 'm' - } - } else if index + 1 < chars.len() && chars[index + 1] == '\x08' { - // Skip backspace overstrike: char + BS - index += 2; - } else { - result.push(chars[index]); - index += 1; - } - } - result -} - -/// Strips trailing whitespace per line, trims trailing blank lines, -/// and ensures the output ends with exactly one newline. -fn normalize_text(text: &str) -> String { - let mut result: String = text - .lines() - .map(str::trim_end) - .collect::>() - .join("\n"); - let trimmed_len = result.trim_end().len(); - result.truncate(trimmed_len); - result.push('\n'); - result -} - -fn write_file(path: &str, content: &str) -> ExitCode { - match fs::write(path, content) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("error writing {path}: {error}"); - ExitCode::FAILURE - } - } -} - -fn check_file(path: &str, expected: &str) -> ExitCode { - match fs::read_to_string(path) { - Ok(actual) if actual == expected => ExitCode::SUCCESS, - Ok(_) => { - eprintln!("{path} is outdated, run ./generate-completions.sh to update it"); - ExitCode::FAILURE - } - Err(error) => { - eprintln!("error reading {path}: {error}"); - ExitCode::FAILURE - } - } -} - -fn main() -> ExitCode { - let args = Args::parse(); - let page_num = args.page.number(); - match (args.action, args.kind) { - (Action::Generate, Kind::Roff) => write_file(&roff_path(page_num), &render_man_page()), - (Action::Generate, Kind::Man) => match render_man_output(page_num) { - Ok(content) => write_file(&man_path(page_num), &content), - Err(error) => { - eprintln!("error: {error}"); - ExitCode::FAILURE - } - }, - (Action::Check, Kind::Roff) => check_file(&roff_path(page_num), &render_man_page()), - (Action::Check, Kind::Man) => match render_man_output(page_num) { - Ok(expected) => check_file(&man_path(page_num), &expected), - Err(error) => { - eprintln!("error: {error}"); - ExitCode::FAILURE - } - }, - } +fn main() { + print!("{}", render_man_page()); } diff --git a/exports/pdu.1.man b/exports/pdu.1.man deleted file mode 100644 index 157604e4..00000000 --- a/exports/pdu.1.man +++ /dev/null @@ -1,130 +0,0 @@ -pdu(1) General Commands Manual pdu(1) - -NAME - pdu - Summarize disk usage of the set of files, recursively for directories. - -SYNOPSIS - pdu [--json-input] [--json-output] [-b|--bytes-format BYTES_FORMAT] [-H|--deduplicate-hardlinks] - [-x|--one-file-system] [--top-down] [--align-right] [-q|--quantity QUANTITY] [-d|--max-depth MAX_DEPTH] [-w|--to‐ - tal-width TOTAL_WIDTH] [--column-width TREE_WIDTH BAR_WIDTH] [-m|--min-ratio MIN_RATIO] [--no-sort] - [-s|--silent-errors] [-p|--progress] [--threads THREADS] [--omit-json-shared-details] [--omit-json-shared-sum‐ - mary] [FILES] - -DESCRIPTION - Summarize disk usage of the set of files, recursively for directories. - - Copyright: Apache-2.0 © 2021 Hoàng Văn Khải - Sponsor: https://github.com/sponsors/KSXGitHub - -OPTIONS - [FILES] - List of files and/or directories - - --json-input - Read JSON data from stdin - - --json-output - Print JSON data instead of an ASCII chart - - -b, --bytes-format [default: metric] - How to display the numbers of bytes - - Possible values: - - • plain: Display plain number of bytes without units - - • metric: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on - - • binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on - - -H, --deduplicate-hardlinks, --detect-links, --dedupe-links - Detect and subtract the sizes of hardlinks from their parent directory totals - - -x, --one-file-system - Skip directories on different filesystems - - --top-down - Print the tree top-down instead of bottom-up - - --align-right - Set the root of the bars to the right - - -q, --quantity [default: block-size] - Aspect of the files/directories to be measured - - Possible values: - - • apparent-size: Measure apparent sizes - - • block-size: Measure block sizes (block-count * 512B) - - • block-count: Count numbers of blocks - - -d, --max-depth, --depth [default: 10] - Maximum depth to display the data. Could be either "inf" or a positive integer - - -w, --total-width, --width - Width of the visualization - - --column-width - Maximum widths of the tree column and width of the bar column - - -m, --min-ratio [default: 0.01] - Minimal size proportion required to appear - - --no-sort - Do not sort the branches in the tree - - -s, --silent-errors, --no-errors - Prevent filesystem error messages from appearing in stderr - - -p, --progress - Report progress being made at the expense of performance - - --threads [default: auto] - Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer - - --omit-json-shared-details - Do not output ‘.shared.details‘ in the JSON output - - --omit-json-shared-summary - Do not output ‘.shared.summary‘ in the JSON output - -EXAMPLES - Show disk usage chart of current working directory - $ pdu - - Show disk usage chart of a single file or directory - $ pdu path/to/file/or/directory - - Compare disk usages of multiple files and/or directories - $ pdu file.txt dir/ - - Show chart in apparent sizes instead of block sizes - $ pdu --quantity=apparent-size - - Detect and subtract the sizes of hardlinks from their parent nodes - $ pdu --deduplicate-hardlinks - - Show sizes in plain numbers instead of metric units - $ pdu --bytes-format=plain - - Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric) - $ pdu --bytes-format=binary - - Show disk usage chart of all entries regardless of size - $ pdu --min-ratio=0 - - Only show disk usage chart of entries whose size is at least 5% of total - $ pdu --min-ratio=0.05 - - Show disk usage data as JSON instead of chart - $ pdu --min-ratio=0 --max-depth=inf --json-output | jq - - Visualize existing JSON representation of disk usage data - $ pdu --json-input < disk-usage.json - -VERSION - v0.21.1 - - pdu 0.21.1 pdu(1) diff --git a/generate-completions.sh b/generate-completions.sh index a5c9a69d..c3d8bf59 100755 --- a/generate-completions.sh +++ b/generate-completions.sh @@ -17,5 +17,4 @@ gen elvish completion.elv ./run.sh pdu --help | sed 's/[[:space:]]*$//' > exports/long.help ./run.sh pdu -h | sed 's/[[:space:]]*$//' > exports/short.help ./run.sh pdu-usage-md > USAGE.md -./run.sh pdu-man-page generate roff 1 -./run.sh pdu-man-page generate man 1 +./run.sh pdu-man-page > exports/pdu.1 diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index 5a611d0a..e1716e6c 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -1,57 +1,20 @@ -//! The following tests check whether the man page files are outdated. +//! The following test checks whether the man page file is outdated. //! -//! If the tests fail, run `./generate-completions.sh` on the root of the repo to update the man page. +//! If the test fails, run `./generate-completions.sh` on the root of the repo to update the man page. // Since the CLI in Windows looks a little different, and I am way too lazy to make two versions -// of man page files, the following tests would only run in UNIX-like environment. +// of man page files, the following test would only run in UNIX-like environment. #![cfg(unix)] #![cfg(feature = "cli")] -use command_extra::CommandExtra; -use std::process::Command; +use parallel_disk_usage::man_page::render_man_page; -const PDU_MAN_PAGE: &str = env!("CARGO_BIN_EXE_pdu-man-page"); - -fn check(kind: &str, page: &str) { - let output = Command::new(PDU_MAN_PAGE) - .with_args(["check", kind, page]) - .with_current_dir(env!("CARGO_MANIFEST_DIR")) - .output() - .expect("spawn pdu-man-page"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stdout = stdout.trim(); - if !stdout.is_empty() { - eprintln!("STDOUT:\n{stdout}\n"); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr = stderr.trim(); - if !stderr.is_empty() { - eprintln!("STDERR:\n{stderr}\n"); - } +#[test] +fn man_page() { + let received = render_man_page(); + let expected = include_str!("../exports/pdu.1"); assert!( - output.status.success(), + received == expected, "man page is outdated, run ./generate-completions.sh to update it", ); } - -#[test] -fn roff() { - check("roff", "1"); -} - -#[test] -#[cfg_attr( - not(target_os = "linux"), - ignore = "groff is only installed on Linux CI" -)] -fn man() { - if which::which("groff").is_err() { - panic!( - "{}\n{}", - "error: This test requires `groff` but it was not found.", - "hint: Install `groff` (or `groff-base`) for your platform, \ - or rerun via `TEST_SKIP='man' ./test.sh` to skip this test.", - ); - } - check("man", "1"); -} From c2bb61ec10ec59115fc1223561da86b90848ec82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 06:58:34 +0000 Subject: [PATCH 26/43] fix(man): add man page to bin PKGBUILD source and checksums The pdu.1 man page was being installed in the -bin PKGBUILD package() but was missing from the source array and sha1sums. Add it to the generate-pkgbuild.py3 script (as pdu.{ver}.1) and add a SKIP entry in the sha1sums template. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- ci/github-actions/generate-pkgbuild.py3 | 3 ++- template/parallel-disk-usage-bin/PKGBUILD | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ci/github-actions/generate-pkgbuild.py3 b/ci/github-actions/generate-pkgbuild.py3 index 42ffcc50..f258261f 100755 --- a/ci/github-actions/generate-pkgbuild.py3 +++ b/ci/github-actions/generate-pkgbuild.py3 @@ -50,7 +50,8 @@ with open('./pkgbuild/parallel-disk-usage-bin/PKGBUILD', 'w') as pkgbuild: f'completion.{release_tag}.{ext}::{source_url_prefix}/completion.{ext}' for ext in supported_completions ) - content += f'source=(pdu-{checksum}::{source_url} {completion_source} {readme_url} {license_url})\n' + man_page_source = f'pdu.{release_tag}.1::{source_url_prefix}/pdu.1' + content += f'source=(pdu-{checksum}::{source_url} {completion_source} {man_page_source} {readme_url} {license_url})\n' content += f'_checksum={checksum}\n' completion_checksums = ' '.join('SKIP' for _ in supported_completions) content += f'_completion_checksums=({completion_checksums})\n' diff --git a/template/parallel-disk-usage-bin/PKGBUILD b/template/parallel-disk-usage-bin/PKGBUILD index ea5dfa18..d28a116f 100644 --- a/template/parallel-disk-usage-bin/PKGBUILD +++ b/template/parallel-disk-usage-bin/PKGBUILD @@ -10,6 +10,7 @@ conflicts=(parallel-disk-usage) sha1sums=( "$_checksum" # for the pdu binary "${_completion_checksums[@]}" # for the completion files + SKIP # for the man page SKIP # for the readme file SKIP # for the license file ) From bf60d05483bf7a58dda8409959998ded9e2add35 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 16:28:46 +0700 Subject: [PATCH 27/43] refactor: reduce closure --- src/man_page.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/man_page.rs b/src/man_page.rs index c4eaa313..4d1d9ba8 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -31,7 +31,7 @@ fn render_name_section(out: &mut String, command: &Command) { let name = command.get_name(); let about = command .get_about() - .map(|text| text.to_string()) + .map(ToString::to_string) .unwrap_or_default(); writeln!(out, ".SH NAME").unwrap(); writeln!(out, "{name} \\- {}", roff_escape(&about)).unwrap(); @@ -99,7 +99,7 @@ fn render_description_section(out: &mut String, command: &Command) { let text = command .get_long_about() .or_else(|| command.get_about()) - .map(|text| text.to_string()) + .map(ToString::to_string) .unwrap_or_default(); render_paragraph_text(out, &text); } @@ -147,7 +147,7 @@ fn render_option_entry(out: &mut String, arg: &Arg) { let help = arg .get_long_help() .or_else(|| arg.get_help()) - .map(|text| text.to_string()) + .map(ToString::to_string) .unwrap_or_default(); writeln!(out, "{}", roff_escape(&help)).unwrap(); render_possible_values(out, arg); From 509924a51c720b730a88c356174c1ec2f8878f20 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 16:30:54 +0700 Subject: [PATCH 28/43] refactor: break a line --- src/man_page.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/man_page.rs b/src/man_page.rs index 4d1d9ba8..1c0c6110 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -1,6 +1,6 @@ use crate::args::Args; use clap::{Arg, ArgAction, Command, CommandFactory}; -use std::fmt::Write; +use std::{borrow::Cow, fmt::Write}; /// Renders the man page for `pdu` as a string in roff format. pub fn render_man_page() -> String { @@ -199,7 +199,8 @@ fn render_value_hint(arg: &Arg) -> String { let defaults: Vec<_> = arg .get_default_values() .iter() - .map(|value| value.to_string_lossy().into_owned()) + .map(|value| value.to_string_lossy()) + .map(Cow::into_owned) .collect(); if !defaults.is_empty() && !arg.is_hide_default_value_set() From d0d11f30a5bbf9c728724acdb8a3e865aa63d1ee Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 16:32:01 +0700 Subject: [PATCH 29/43] refactor: invert `if` --- src/man_page.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/man_page.rs b/src/man_page.rs index 1c0c6110..4aeda692 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -202,13 +202,13 @@ fn render_value_hint(arg: &Arg) -> String { .map(|value| value.to_string_lossy()) .map(Cow::into_owned) .collect(); - if !defaults.is_empty() - && !arg.is_hide_default_value_set() - && !matches!(arg.get_action(), ArgAction::SetTrue) + if defaults.is_empty() + || arg.is_hide_default_value_set() + || matches!(arg.get_action(), ArgAction::SetTrue) { - format!("{value_part} [default: {}]", defaults.join(", ")) - } else { value_part + } else { + format!("{value_part} [default: {}]", defaults.join(", ")) } } From 2486e7e32fd768f256486db986b806495c4ab86d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 10:03:59 +0000 Subject: [PATCH 30/43] feat(man): replace possible values list with flag-value pairs Replace the "Possible values:" bullet list with tagged paragraphs showing each value as a flag-value pair (e.g. `--bytes-format plain`), matching conventional man page style. Also add conflict documentation for options that have declared conflicts (e.g. --json-input conflicts with --quantity, etc.) using clap's `get_arg_conflicts_with` API. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 54 ++++++++++++++++++++++++++++--------------------- src/man_page.rs | 41 ++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 7911b539..6d6993e2 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -2,7 +2,7 @@ .SH NAME pdu \- Summarize disk usage of the set of files, recursively for directories. .SH SYNOPSIS -\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fIFILES\fR] +\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR] .SH DESCRIPTION Summarize disk usage of the set of files, recursively for directories. .PP @@ -16,23 +16,24 @@ List of files and/or directories .TP \fB\-\-json\-input\fR Read JSON data from stdin +.br +Conflicts with \fB\-\-quantity\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-one\-file\-system\fR. .TP \fB\-\-json\-output\fR Print JSON data instead of an ASCII chart .TP \fB\-b\fR, \fB\-\-bytes\-format\fR \fI\fR [default: metric] How to display the numbers of bytes -.br - -.br -\fIPossible values:\fR -.RS 14 -.IP \(bu 2 -plain: Display plain number of bytes without units -.IP \(bu 2 -metric: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on -.IP \(bu 2 -binary: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on +.RS +.TP +\fB\-\-bytes\-format plain\fR +Display plain number of bytes without units +.TP +\fB\-\-bytes\-format metric\fR +Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on +.TP +\fB\-\-bytes\-format binary\fR +Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on .RE .TP \fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-detect\-links\fR, \fB\-\-dedupe\-links\fR @@ -49,17 +50,16 @@ Set the root of the bars to the right .TP \fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block-size] Aspect of the files/directories to be measured -.br - -.br -\fIPossible values:\fR -.RS 14 -.IP \(bu 2 -apparent\-size: Measure apparent sizes -.IP \(bu 2 -block\-size: Measure block sizes (block\-count * 512B) -.IP \(bu 2 -block\-count: Count numbers of blocks +.RS +.TP +\fB\-\-quantity apparent\-size\fR +Measure apparent sizes +.TP +\fB\-\-quantity block\-size\fR +Measure block sizes (block\-count * 512B) +.TP +\fB\-\-quantity block\-count\fR +Count numbers of blocks .RE .TP \fB\-d\fR, \fB\-\-max\-depth\fR, \fB\-\-depth\fR \fI\fR [default: 10] @@ -67,6 +67,8 @@ Maximum depth to display the data. Could be either "inf" or a positive integer .TP \fB\-w\fR, \fB\-\-total\-width\fR, \fB\-\-width\fR \fI\fR Width of the visualization +.br +Conflicts with \fB\-\-column\-width\fR. .TP \fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR Maximum widths of the tree column and width of the bar column @@ -91,6 +93,12 @@ Do not output `.shared.details` in the JSON output .TP \fB\-\-omit\-json\-shared\-summary\fR Do not output `.shared.summary` in the JSON output +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help (see a summary with '\-h') +.TP +\fB\-V\fR, \fB\-\-version\fR +Print version .SH EXAMPLES .TP Show disk usage chart of current working directory diff --git a/src/man_page.rs b/src/man_page.rs index 4aeda692..2a8594ba 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -4,7 +4,8 @@ use std::{borrow::Cow, fmt::Write}; /// Renders the man page for `pdu` as a string in roff format. pub fn render_man_page() -> String { - let command = Args::command(); + let mut command = Args::command(); + command.build(); let mut out = String::new(); render_title(&mut out, &command); render_name_section(&mut out, &command); @@ -133,11 +134,11 @@ fn render_options_section(out: &mut String, command: &Command) { if arg.is_hide_set() { continue; } - render_option_entry(out, arg); + render_option_entry(out, command, arg); } } -fn render_option_entry(out: &mut String, arg: &Arg) { +fn render_option_entry(out: &mut String, command: &Command, arg: &Arg) { out.push_str(".TP\n"); if arg.is_positional() { render_option_header_positional(out, arg); @@ -151,6 +152,7 @@ fn render_option_entry(out: &mut String, arg: &Arg) { .unwrap_or_default(); writeln!(out, "{}", roff_escape(&help)).unwrap(); render_possible_values(out, arg); + render_conflicts(out, command, arg); } fn render_option_header_positional(out: &mut String, arg: &Arg) { @@ -230,26 +232,47 @@ fn render_possible_values(out: &mut String, arg: &Arg) { if possible_values.is_empty() { return; } - out.push_str(".br\n\n.br\n"); - out.push_str("\\fIPossible values:\\fR\n"); - out.push_str(".RS 14\n"); + let flag = arg + .get_long() + .map(|long| format!("\\-\\-{}", roff_escape(long))) + .unwrap_or_default(); + out.push_str(".RS\n"); for value in &possible_values { let name = value.get_name(); if let Some(help) = value.get_help() { writeln!( out, - ".IP \\(bu 2\n{}: {}", + ".TP\n\\fB{flag} {}\\fR\n{}", roff_escape(name), - roff_escape(&help.to_string()) + roff_escape(&help.to_string()), ) .unwrap(); } else { - writeln!(out, ".IP \\(bu 2\n{}", roff_escape(name)).unwrap(); + writeln!(out, ".TP\n\\fB{flag} {}\\fR", roff_escape(name)).unwrap(); } } out.push_str(".RE\n"); } +fn render_conflicts(out: &mut String, command: &Command, arg: &Arg) { + let conflicts = command.get_arg_conflicts_with(arg); + if conflicts.is_empty() { + return; + } + let conflict_names: Vec<_> = conflicts + .iter() + .filter_map(|conflict_arg| { + conflict_arg + .get_long() + .map(|long| format!("\\fB\\-\\-{}\\fR", roff_escape(long))) + }) + .collect(); + if conflict_names.is_empty() { + return; + } + writeln!(out, ".br\nConflicts with {}.", conflict_names.join(", ")).unwrap(); +} + fn render_examples_section(out: &mut String, command: &Command) { let text = match command.get_after_long_help() { Some(text) => text.to_string(), From aa3059b10c4a596ff28ddea7edf26e7a65b0c86a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 10:59:22 +0000 Subject: [PATCH 31/43] feat(man): bidirectional conflicts with user-facing wording Build a bidirectional conflict map so conflicts are shown from both sides (e.g. --json-input shows --quantity and --quantity shows --json-input). Use .PP for the conflict paragraph and user-facing wording "Cannot be used with" instead of the developer-facing "Conflicts with". https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 16 ++++++++--- src/man_page.rs | 72 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 6d6993e2..54177502 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -16,8 +16,8 @@ List of files and/or directories .TP \fB\-\-json\-input\fR Read JSON data from stdin -.br -Conflicts with \fB\-\-quantity\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-one\-file\-system\fR. +.PP +Cannot be used with \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-one\-file\-system\fR, \fB\-\-quantity\fR. .TP \fB\-\-json\-output\fR Print JSON data instead of an ASCII chart @@ -38,9 +38,13 @@ Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on .TP \fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-detect\-links\fR, \fB\-\-dedupe\-links\fR Detect and subtract the sizes of hardlinks from their parent directory totals +.PP +Cannot be used with \fB\-\-json\-input\fR. .TP \fB\-x\fR, \fB\-\-one\-file\-system\fR Skip directories on different filesystems +.PP +Cannot be used with \fB\-\-json\-input\fR. .TP \fB\-\-top\-down\fR Print the tree top\-down instead of bottom\-up @@ -61,17 +65,21 @@ Measure block sizes (block\-count * 512B) \fB\-\-quantity block\-count\fR Count numbers of blocks .RE +.PP +Cannot be used with \fB\-\-json\-input\fR. .TP \fB\-d\fR, \fB\-\-max\-depth\fR, \fB\-\-depth\fR \fI\fR [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer .TP \fB\-w\fR, \fB\-\-total\-width\fR, \fB\-\-width\fR \fI\fR Width of the visualization -.br -Conflicts with \fB\-\-column\-width\fR. +.PP +Cannot be used with \fB\-\-column\-width\fR. .TP \fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR Maximum widths of the tree column and width of the bar column +.PP +Cannot be used with \fB\-\-total\-width\fR. .TP \fB\-m\fR, \fB\-\-min\-ratio\fR \fI\fR [default: 0.01] Minimal size proportion required to appear diff --git a/src/man_page.rs b/src/man_page.rs index 2a8594ba..ce509685 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -1,22 +1,56 @@ use crate::args::Args; use clap::{Arg, ArgAction, Command, CommandFactory}; -use std::{borrow::Cow, fmt::Write}; +use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; + +/// A map from argument ID to the set of argument IDs it conflicts with (bidirectional). +type ConflictMap = BTreeMap>; /// Renders the man page for `pdu` as a string in roff format. pub fn render_man_page() -> String { let mut command = Args::command(); command.build(); + let conflict_map = build_conflict_map(&command); let mut out = String::new(); render_title(&mut out, &command); render_name_section(&mut out, &command); render_synopsis_section(&mut out, &command); render_description_section(&mut out, &command); - render_options_section(&mut out, &command); + render_options_section(&mut out, &command, &conflict_map); render_examples_section(&mut out, &command); render_version_section(&mut out, &command); out } +/// Builds a bidirectional conflict map from clap's one-directional conflict declarations. +fn build_conflict_map(command: &Command) -> ConflictMap { + let mut map = ConflictMap::new(); + for arg in command.get_arguments() { + let arg_id = arg.get_id().to_string(); + for conflict in command.get_arg_conflicts_with(arg) { + let conflict_id = conflict.get_id().to_string(); + map.entry(arg_id.clone()) + .or_default() + .push(conflict_id.clone()); + map.entry(conflict_id).or_default().push(arg_id.clone()); + } + } + // Deduplicate each entry + for conflicts in map.values_mut() { + conflicts.sort(); + conflicts.dedup(); + } + map +} + +/// Resolves an argument ID to its `--long` flag name for display. +fn resolve_flag_name(command: &Command, arg_id: &str) -> Option { + command + .get_arguments() + .find(|arg| arg.get_id().as_str() == arg_id) + .and_then(|arg| arg.get_long()) + .map(|long| format!("\\fB\\-\\-{}\\fR", roff_escape(long))) +} + /// Escapes a string for roff by replacing hyphens with `\-`. fn roff_escape(text: &str) -> String { text.replace('-', r"\-") @@ -128,17 +162,17 @@ fn render_paragraph_text(out: &mut String, text: &str) { } } -fn render_options_section(out: &mut String, command: &Command) { +fn render_options_section(out: &mut String, command: &Command, conflict_map: &ConflictMap) { out.push_str(".SH OPTIONS\n"); for arg in command.get_arguments() { if arg.is_hide_set() { continue; } - render_option_entry(out, command, arg); + render_option_entry(out, command, arg, conflict_map); } } -fn render_option_entry(out: &mut String, command: &Command, arg: &Arg) { +fn render_option_entry(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) { out.push_str(".TP\n"); if arg.is_positional() { render_option_header_positional(out, arg); @@ -152,7 +186,7 @@ fn render_option_entry(out: &mut String, command: &Command, arg: &Arg) { .unwrap_or_default(); writeln!(out, "{}", roff_escape(&help)).unwrap(); render_possible_values(out, arg); - render_conflicts(out, command, arg); + render_conflicts(out, command, arg, conflict_map); } fn render_option_header_positional(out: &mut String, arg: &Arg) { @@ -254,23 +288,25 @@ fn render_possible_values(out: &mut String, arg: &Arg) { out.push_str(".RE\n"); } -fn render_conflicts(out: &mut String, command: &Command, arg: &Arg) { - let conflicts = command.get_arg_conflicts_with(arg); - if conflicts.is_empty() { - return; - } - let conflict_names: Vec<_> = conflicts +fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) { + let arg_id = arg.get_id().as_str(); + let conflict_ids = match conflict_map.get(arg_id) { + Some(ids) if !ids.is_empty() => ids, + _ => return, + }; + let conflict_names: Vec<_> = conflict_ids .iter() - .filter_map(|conflict_arg| { - conflict_arg - .get_long() - .map(|long| format!("\\fB\\-\\-{}\\fR", roff_escape(long))) - }) + .filter_map(|conflict_id| resolve_flag_name(command, conflict_id)) .collect(); if conflict_names.is_empty() { return; } - writeln!(out, ".br\nConflicts with {}.", conflict_names.join(", ")).unwrap(); + writeln!( + out, + ".PP\nCannot be used with {}.", + conflict_names.join(", ") + ) + .unwrap(); } fn render_examples_section(out: &mut String, command: &Command) { From 0c633ff0f79b43435d6e852046c0b205f946a0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 11:06:40 +0000 Subject: [PATCH 32/43] fix(man): indent conflict paragraph under its option Wrap the "Cannot be used with" line in .RS/.RE so it stays indented under the option it belongs to, instead of breaking out to the base indentation level. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 12 ++++++++++++ src/man_page.rs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 54177502..a24ca958 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -16,8 +16,10 @@ List of files and/or directories .TP \fB\-\-json\-input\fR Read JSON data from stdin +.RS .PP Cannot be used with \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-one\-file\-system\fR, \fB\-\-quantity\fR. +.RE .TP \fB\-\-json\-output\fR Print JSON data instead of an ASCII chart @@ -38,13 +40,17 @@ Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on .TP \fB\-H\fR, \fB\-\-deduplicate\-hardlinks\fR, \fB\-\-detect\-links\fR, \fB\-\-dedupe\-links\fR Detect and subtract the sizes of hardlinks from their parent directory totals +.RS .PP Cannot be used with \fB\-\-json\-input\fR. +.RE .TP \fB\-x\fR, \fB\-\-one\-file\-system\fR Skip directories on different filesystems +.RS .PP Cannot be used with \fB\-\-json\-input\fR. +.RE .TP \fB\-\-top\-down\fR Print the tree top\-down instead of bottom\-up @@ -65,21 +71,27 @@ Measure block sizes (block\-count * 512B) \fB\-\-quantity block\-count\fR Count numbers of blocks .RE +.RS .PP Cannot be used with \fB\-\-json\-input\fR. +.RE .TP \fB\-d\fR, \fB\-\-max\-depth\fR, \fB\-\-depth\fR \fI\fR [default: 10] Maximum depth to display the data. Could be either "inf" or a positive integer .TP \fB\-w\fR, \fB\-\-total\-width\fR, \fB\-\-width\fR \fI\fR Width of the visualization +.RS .PP Cannot be used with \fB\-\-column\-width\fR. +.RE .TP \fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR Maximum widths of the tree column and width of the bar column +.RS .PP Cannot be used with \fB\-\-total\-width\fR. +.RE .TP \fB\-m\fR, \fB\-\-min\-ratio\fR \fI\fR [default: 0.01] Minimal size proportion required to appear diff --git a/src/man_page.rs b/src/man_page.rs index ce509685..6ea459ed 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -303,7 +303,7 @@ fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map } writeln!( out, - ".PP\nCannot be used with {}.", + ".RS\n.PP\nCannot be used with {}.\n.RE", conflict_names.join(", ") ) .unwrap(); From b708c402412c5aa536ad3a84be12a65c0be9b1cf Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sat, 28 Mar 2026 18:15:12 +0700 Subject: [PATCH 33/43] docs: remove unnecessary comment --- src/man_page.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/man_page.rs b/src/man_page.rs index 6ea459ed..0ee98900 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -34,7 +34,6 @@ fn build_conflict_map(command: &Command) -> ConflictMap { map.entry(conflict_id).or_default().push(arg_id.clone()); } } - // Deduplicate each entry for conflicts in map.values_mut() { conflicts.sort(); conflicts.dedup(); From 1d325a380bc4bf3341e7c5922fe0bc984bd4af5d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:22:36 +0700 Subject: [PATCH 34/43] fix(man): use plain space to join value placeholders; use cfg_attr for unix test (#379) Agent-Logs-Url: https://github.com/KSXGitHub/parallel-disk-usage/sessions/adf0e69e-fdc7-40a5-84c0-25f92110fa5f Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: KSXGitHub <11488886+KSXGitHub@users.noreply.github.com> --- exports/pdu.1 | 2 +- src/man_page.rs | 2 +- tests/sync_man_page.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index a24ca958..93cfaef7 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -86,7 +86,7 @@ Width of the visualization Cannot be used with \fB\-\-column\-width\fR. .RE .TP -\fB\-\-column\-width\fR \fI\fR\fI \fR\fI\fR +\fB\-\-column\-width\fR \fI\fR \fI\fR Maximum widths of the tree column and width of the bar column .RS .PP diff --git a/src/man_page.rs b/src/man_page.rs index 0ee98900..1b7e7723 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -230,7 +230,7 @@ fn render_value_hint(arg: &Arg) -> String { } else { parts.push(format!("\\fI<{}>\\fR", roff_escape(arg.get_id().as_str()))); } - let value_part = parts.join("\\fI \\fR"); + let value_part = parts.join(" "); let defaults: Vec<_> = arg .get_default_values() .iter() diff --git a/tests/sync_man_page.rs b/tests/sync_man_page.rs index e1716e6c..f4a6db43 100644 --- a/tests/sync_man_page.rs +++ b/tests/sync_man_page.rs @@ -4,12 +4,12 @@ // Since the CLI in Windows looks a little different, and I am way too lazy to make two versions // of man page files, the following test would only run in UNIX-like environment. -#![cfg(unix)] #![cfg(feature = "cli")] use parallel_disk_usage::man_page::render_man_page; #[test] +#[cfg_attr(not(unix), ignore = "man page test only runs on Unix-like platforms")] fn man_page() { let received = render_man_page(); let expected = include_str!("../exports/pdu.1"); From 81bcadeb0bc70f32dd9d15273f6164eb720b89ba Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sun, 29 Mar 2026 00:42:41 +0700 Subject: [PATCH 35/43] refactor: functional style, some micro optimizations --- src/man_page.rs | 99 ++++++++++++++++++++++++------------------------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/src/man_page.rs b/src/man_page.rs index 1b7e7723..b4603872 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -1,5 +1,6 @@ use crate::args::Args; use clap::{Arg, ArgAction, Command, CommandFactory}; +use itertools::Itertools; use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; /// A map from argument ID to the set of argument IDs it conflicts with (bidirectional). @@ -73,21 +74,20 @@ fn render_name_section(out: &mut String, command: &Command) { fn render_synopsis_section(out: &mut String, command: &Command) { out.push_str(".SH SYNOPSIS\n"); - out.push_str(&format!("\\fB{}\\fR", command.get_name())); - for arg in command.get_arguments() { - if arg.is_positional() { - continue; - } - if arg.is_hide_set() { - continue; - } + write!(out, "\\fB{}\\fR", command.get_name()).unwrap(); + let options = command + .get_arguments() + .filter(|arg| !arg.is_positional()) + .filter(|arg| !arg.is_hide_set()); + for arg in options { out.push(' '); render_synopsis_option(out, arg); } - for arg in command.get_arguments() { - if !arg.is_positional() || arg.is_hide_set() { - continue; - } + let positionals = command + .get_arguments() + .filter(|arg| arg.is_positional()) + .filter(|arg| !arg.is_hide_set()); + for arg in positionals { out.push(' '); render_synopsis_positional(out, arg); } @@ -202,17 +202,21 @@ fn render_option_header_positional(out: &mut String, arg: &Arg) { } fn render_option_header_flag(out: &mut String, arg: &Arg) { - let mut parts = Vec::new(); - if let Some(short) = arg.get_short() { - parts.push(format!("\\fB\\-{}\\fR", roff_escape(&short.to_string()))); - } - if let Some(long) = arg.get_long() { - parts.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long))); - } - for alias in arg.get_visible_aliases().unwrap_or_default() { - parts.push(format!("\\fB\\-\\-{}\\fR", roff_escape(alias))); - } - let header = parts.join(", "); + let short = arg + .get_short() + .map(|short| roff_escape(&short.to_string())) + .map(|short| format!("\\fB\\-{short}\\fR")); + let long = arg + .get_long() + .map(roff_escape) + .map(|long| format!("\\fB\\-\\-{long}\\fR")); + let aliases = arg + .get_visible_aliases() + .into_iter() + .flatten() + .map(roff_escape) + .map(|arg| format!("\\fB\\-\\-{arg}\\fR")); + let header = short.into_iter().chain(long).chain(aliases).join(", "); if arg.get_action().takes_values() { let value_str = render_value_hint(arg); writeln!(out, "{header} {value_str}").unwrap(); @@ -222,25 +226,26 @@ fn render_option_header_flag(out: &mut String, arg: &Arg) { } fn render_value_hint(arg: &Arg) -> String { - let mut parts = Vec::new(); - if let Some(value_names) = arg.get_value_names() { - for name in value_names { - parts.push(format!("\\fI<{}>\\fR", roff_escape(name))); - } - } else { - parts.push(format!("\\fI<{}>\\fR", roff_escape(arg.get_id().as_str()))); - } - let value_part = parts.join(" "); + let value_part = arg + .get_value_names() + .map(<[_]>::iter) + .map(|names| names.map(|name| name.as_str())) + .map(|names| names.collect::>()) + .unwrap_or_else(|| vec![arg.get_id().as_str()]) + .into_iter() + .map(roff_escape) + .map(|name| format!("\\fI<{name}>\\fR")) + .join(" "); let defaults: Vec<_> = arg .get_default_values() .iter() .map(|value| value.to_string_lossy()) .map(Cow::into_owned) .collect(); - if defaults.is_empty() + let hide_defaults = defaults.is_empty() || arg.is_hide_default_value_set() - || matches!(arg.get_action(), ArgAction::SetTrue) - { + || matches!(arg.get_action(), ArgAction::SetTrue); + if hide_defaults { value_part } else { format!("{value_part} [default: {}]", defaults.join(", ")) @@ -289,23 +294,15 @@ fn render_possible_values(out: &mut String, arg: &Arg) { fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) { let arg_id = arg.get_id().as_str(); - let conflict_ids = match conflict_map.get(arg_id) { - Some(ids) if !ids.is_empty() => ids, - _ => return, - }; - let conflict_names: Vec<_> = conflict_ids - .iter() - .filter_map(|conflict_id| resolve_flag_name(command, conflict_id)) - .collect(); - if conflict_names.is_empty() { - return; + let conflicts = conflict_map + .get(arg_id) + .into_iter() + .flatten() + .filter_map(|id| resolve_flag_name(command, id)) + .join(", "); + if !conflicts.is_empty() { + writeln!(out, ".RS\n.PP\nCannot be used with {conflicts}.\n.RE").unwrap(); } - writeln!( - out, - ".RS\n.PP\nCannot be used with {}.\n.RE", - conflict_names.join(", ") - ) - .unwrap(); } fn render_examples_section(out: &mut String, command: &Command) { From 58af45b977469401e4ac192601af4a680094bbc9 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sun, 29 Mar 2026 00:48:28 +0700 Subject: [PATCH 36/43] refactor: use `Vec::from_iter` --- src/man_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/man_page.rs b/src/man_page.rs index b4603872..51a110e6 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -230,7 +230,7 @@ fn render_value_hint(arg: &Arg) -> String { .get_value_names() .map(<[_]>::iter) .map(|names| names.map(|name| name.as_str())) - .map(|names| names.collect::>()) + .map(Vec::from_iter) .unwrap_or_else(|| vec![arg.get_id().as_str()]) .into_iter() .map(roff_escape) From d901315c439ba79daff9cb674fd4565a4d861107 Mon Sep 17 00:00:00 2001 From: khai96_ Date: Sun, 29 Mar 2026 01:35:22 +0700 Subject: [PATCH 37/43] refactor: more functional --- src/man_page.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/man_page.rs b/src/man_page.rs index 51a110e6..b452b08e 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -272,22 +272,17 @@ fn render_possible_values(out: &mut String, arg: &Arg) { } let flag = arg .get_long() - .map(|long| format!("\\-\\-{}", roff_escape(long))) + .map(roff_escape) + .map(|long| format!("\\-\\-{long}")) .unwrap_or_default(); out.push_str(".RS\n"); for value in &possible_values { - let name = value.get_name(); - if let Some(help) = value.get_help() { - writeln!( - out, - ".TP\n\\fB{flag} {}\\fR\n{}", - roff_escape(name), - roff_escape(&help.to_string()), - ) - .unwrap(); - } else { - writeln!(out, ".TP\n\\fB{flag} {}\\fR", roff_escape(name)).unwrap(); - } + let name = roff_escape(value.get_name()); + let help = value + .get_help() + .map(|help| format!("\n{}", roff_escape(&help.to_string()))) + .unwrap_or_default(); + writeln!(out, ".TP\n\\fB{flag} {name}\\fR{help}").unwrap(); } out.push_str(".RE\n"); } From 26412387362d2d2c9712ea963c70842d4c15737b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 18:54:30 +0000 Subject: [PATCH 38/43] fix(man): escape default values in roff and fix .TH field order Apply roff_escape to default values so hyphens render consistently (e.g. block-size becomes block\-size). Also fix .TH header to place the version string in the source field instead of the date field per man(7) convention. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 4 ++-- src/man_page.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 93cfaef7..85d36f99 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -1,4 +1,4 @@ -.TH pdu 1 "pdu 0.21.1" +.TH pdu 1 "" "pdu 0.21.1" .SH NAME pdu \- Summarize disk usage of the set of files, recursively for directories. .SH SYNOPSIS @@ -58,7 +58,7 @@ Print the tree top\-down instead of bottom\-up \fB\-\-align\-right\fR Set the root of the bars to the right .TP -\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block-size] +\fB\-q\fR, \fB\-\-quantity\fR \fI\fR [default: block\-size] Aspect of the files/directories to be measured .RS .TP diff --git a/src/man_page.rs b/src/man_page.rs index b452b08e..dd1ec40b 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -1,7 +1,7 @@ use crate::args::Args; use clap::{Arg, ArgAction, Command, CommandFactory}; use itertools::Itertools; -use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; +use std::{collections::BTreeMap, fmt::Write}; /// A map from argument ID to the set of argument IDs it conflicts with (bidirectional). type ConflictMap = BTreeMap>; @@ -59,7 +59,7 @@ fn roff_escape(text: &str) -> String { fn render_title(out: &mut String, command: &Command) { let name = command.get_name(); let version = command.get_version().unwrap_or_default(); - writeln!(out, ".TH {name} 1 \"{name} {version}\"").unwrap(); + writeln!(out, ".TH {name} 1 \"\" \"{name} {version}\"").unwrap(); } fn render_name_section(out: &mut String, command: &Command) { @@ -236,19 +236,19 @@ fn render_value_hint(arg: &Arg) -> String { .map(roff_escape) .map(|name| format!("\\fI<{name}>\\fR")) .join(" "); - let defaults: Vec<_> = arg + let defaults = arg .get_default_values() .iter() .map(|value| value.to_string_lossy()) - .map(Cow::into_owned) - .collect(); + .map(|value| roff_escape(&value)) + .join(", "); let hide_defaults = defaults.is_empty() || arg.is_hide_default_value_set() || matches!(arg.get_action(), ArgAction::SetTrue); if hide_defaults { value_part } else { - format!("{value_part} [default: {}]", defaults.join(", ")) + format!("{value_part} [default: {defaults}]") } } From cb4dbb6828c308e6c4771f4dd0264807b8c0351e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:15:25 +0000 Subject: [PATCH 39/43] fix(man): remove empty date field from .TH header Revert the .TH change that added an empty "" date field. The simpler form `.TH pdu 1 "pdu 0.21.1"` is the conventional approach used by clap_mangen and other tools. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 2 +- src/man_page.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 85d36f99..6b3d6b4e 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -1,4 +1,4 @@ -.TH pdu 1 "" "pdu 0.21.1" +.TH pdu 1 "pdu 0.21.1" .SH NAME pdu \- Summarize disk usage of the set of files, recursively for directories. .SH SYNOPSIS diff --git a/src/man_page.rs b/src/man_page.rs index dd1ec40b..ebe11896 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -59,7 +59,7 @@ fn roff_escape(text: &str) -> String { fn render_title(out: &mut String, command: &Command) { let name = command.get_name(); let version = command.get_version().unwrap_or_default(); - writeln!(out, ".TH {name} 1 \"\" \"{name} {version}\"").unwrap(); + writeln!(out, ".TH {name} 1 \"{name} {version}\"").unwrap(); } fn render_name_section(out: &mut String, command: &Command) { From 918f7cbaf293f83bb0d47482ca6a38837680685a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:44:44 +0000 Subject: [PATCH 40/43] fix(man): add ellipsis for multi-value positional arguments Append `...` to positional arguments that accept multiple values (e.g. `[FILES]...`), matching the `--help` output. Applies to both the SYNOPSIS and OPTIONS sections. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- exports/pdu.1 | 4 ++-- src/man_page.rs | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/exports/pdu.1 b/exports/pdu.1 index 6b3d6b4e..5d76c63f 100644 --- a/exports/pdu.1 +++ b/exports/pdu.1 @@ -2,7 +2,7 @@ .SH NAME pdu \- Summarize disk usage of the set of files, recursively for directories. .SH SYNOPSIS -\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR] +\fBpdu\fR [\fB\-\-json\-input\fR] [\fB\-\-json\-output\fR] [\fB\-b\fR|\fB\-\-bytes\-format\fR \fIBYTES_FORMAT\fR] [\fB\-H\fR|\fB\-\-deduplicate\-hardlinks\fR] [\fB\-x\fR|\fB\-\-one\-file\-system\fR] [\fB\-\-top\-down\fR] [\fB\-\-align\-right\fR] [\fB\-q\fR|\fB\-\-quantity\fR \fIQUANTITY\fR] [\fB\-d\fR|\fB\-\-max\-depth\fR \fIMAX_DEPTH\fR] [\fB\-w\fR|\fB\-\-total\-width\fR \fITOTAL_WIDTH\fR] [\fB\-\-column\-width\fR \fITREE_WIDTH\fR \fIBAR_WIDTH\fR] [\fB\-m\fR|\fB\-\-min\-ratio\fR \fIMIN_RATIO\fR] [\fB\-\-no\-sort\fR] [\fB\-s\fR|\fB\-\-silent\-errors\fR] [\fB\-p\fR|\fB\-\-progress\fR] [\fB\-\-threads\fR \fITHREADS\fR] [\fB\-\-omit\-json\-shared\-details\fR] [\fB\-\-omit\-json\-shared\-summary\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fIFILES\fR]... .SH DESCRIPTION Summarize disk usage of the set of files, recursively for directories. .PP @@ -11,7 +11,7 @@ Copyright: Apache\-2.0 © 2021 Hoàng Văn Khải bool { + arg.get_num_args() + .map(|range| range.max_values() > 1) + .unwrap_or(false) +} + fn render_synopsis_positional(out: &mut String, arg: &Arg) { let name = arg .get_value_names() .and_then(|names| names.first()) .map(|name| name.as_str()) .unwrap_or_else(|| arg.get_id().as_str()); + let ellipsis = if is_multiple(arg) { "..." } else { "" }; if arg.is_required_set() { - write!(out, "\\fI{}\\fR", roff_escape(name)).unwrap(); + write!(out, "\\fI{}\\fR{ellipsis}", roff_escape(name)).unwrap(); } else { - write!(out, "[\\fI{}\\fR]", roff_escape(name)).unwrap(); + write!(out, "[\\fI{}\\fR]{ellipsis}", roff_escape(name)).unwrap(); } } @@ -194,10 +201,11 @@ fn render_option_header_positional(out: &mut String, arg: &Arg) { .and_then(|names| names.first()) .map(|name| name.as_str()) .unwrap_or_else(|| arg.get_id().as_str()); + let ellipsis = if is_multiple(arg) { "..." } else { "" }; if arg.is_required_set() { - writeln!(out, "\\fI{name}\\fR").unwrap(); + writeln!(out, "\\fI{name}\\fR{ellipsis}").unwrap(); } else { - writeln!(out, "[\\fI{name}\\fR]").unwrap(); + writeln!(out, "[\\fI{name}\\fR]{ellipsis}").unwrap(); } } From d52a24050e6cf4b13e2dc0cde3d2c88dad059e1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 20:14:21 +0000 Subject: [PATCH 41/43] style(man): rename misleading closure parameter from `arg` to `alias` https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- src/man_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/man_page.rs b/src/man_page.rs index 0878cd3c..0471b2b5 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -223,7 +223,7 @@ fn render_option_header_flag(out: &mut String, arg: &Arg) { .into_iter() .flatten() .map(roff_escape) - .map(|arg| format!("\\fB\\-\\-{arg}\\fR")); + .map(|alias| format!("\\fB\\-\\-{alias}\\fR")); let header = short.into_iter().chain(long).chain(aliases).join(", "); if arg.get_action().takes_values() { let value_str = render_value_hint(arg); From 4f3978a46d7eae66f372c4530ffede1b511bb13d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 21:26:31 +0000 Subject: [PATCH 42/43] fix(man): exclude hidden args from conflict map Skip hidden args (e.g. --deduplicate-hardlinks on non-Unix) when building the conflict map so the man page doesn't reference options that are not listed on the current platform. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- src/man_page.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/man_page.rs b/src/man_page.rs index 0471b2b5..998e95fd 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -23,11 +23,20 @@ pub fn render_man_page() -> String { } /// Builds a bidirectional conflict map from clap's one-directional conflict declarations. +/// +/// Hidden args are excluded so the man page doesn't reference options +/// that are not listed on the current platform. fn build_conflict_map(command: &Command) -> ConflictMap { let mut map = ConflictMap::new(); for arg in command.get_arguments() { + if arg.is_hide_set() { + continue; + } let arg_id = arg.get_id().to_string(); for conflict in command.get_arg_conflicts_with(arg) { + if conflict.is_hide_set() { + continue; + } let conflict_id = conflict.get_id().to_string(); map.entry(arg_id.clone()) .or_default() From f22810cdfddd65f281463d61ca954fa13e7cdb8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 21:34:25 +0000 Subject: [PATCH 43/43] fix(man): clear need_paragraph unconditionally on non-empty lines Previously, need_paragraph was only cleared inside the `need_paragraph && !first` branch, so leading empty lines would leave it set and cause a spurious .PP before the second non-empty line. https://claude.ai/code/session_01CrXuWDMVQsiUBoy6ceACsF --- src/man_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/man_page.rs b/src/man_page.rs index 998e95fd..40791a52 100644 --- a/src/man_page.rs +++ b/src/man_page.rs @@ -168,10 +168,10 @@ fn render_paragraph_text(out: &mut String, text: &str) { } if need_paragraph && !first { out.push_str(".PP\n"); - need_paragraph = false; } else if !first { out.push_str(".br\n"); } + need_paragraph = false; first = false; writeln!(out, "{}", roff_escape(line)).unwrap(); }