Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3b26ddd
Initial plan
Copilot Feb 28, 2026
b83af13
docs: add CLI usage documentation and sync_help tests
Copilot Feb 28, 2026
d17b9c1
fix: restrict sync_help tests to unix only
Copilot Feb 28, 2026
81b8f0d
feat: parse long.help to generate structured USAGE.md
Copilot Feb 28, 2026
18be173
refactor: parse flags/aliases/examples properly; fix Clippy warnings;…
Copilot Feb 28, 2026
8dce0c9
refactor(usage_md): heading-based options, periods on descriptions, i…
Copilot Feb 28, 2026
b30f626
fix: remove redundant .iter() on Vec in render_option_item
Copilot Feb 28, 2026
991ac44
refactor(usage_md): move render to src/usage_md, use Args::command(),…
Copilot Feb 28, 2026
40f6184
refactor(usage_md): address review comments - iterator return type, C…
Copilot Feb 28, 2026
30c6e42
refactor(usage_md): use strip_prefix consistently for anchor id gener…
Copilot Feb 28, 2026
d92ac00
refactor(usage_md): rewrite render to use clap introspection API
Copilot Feb 28, 2026
9632a78
fix(lib): move pub mod usage_md into the cfg(feature = \"cli\") group
Copilot Feb 28, 2026
c4b5c02
refactor(usage_md): address all review comments and fix CI failures
Copilot Feb 28, 2026
de881ba
docs: remove unnecessary text
KSXGitHub Feb 28, 2026
73077f4
refactor: undo
KSXGitHub Feb 28, 2026
8e17223
docs: remove unnecessary documentation
KSXGitHub Feb 28, 2026
55655c8
style: imports together
KSXGitHub Feb 28, 2026
08a13a3
style: use shadowing
KSXGitHub Feb 28, 2026
94bf09c
refactor: simplify
KSXGitHub Feb 28, 2026
01f0676
refactor: correct a function name
KSXGitHub Feb 28, 2026
d5f4d65
refactor: stop using short variable
KSXGitHub Feb 28, 2026
96062b0
refactor: remove temporary `Vec`
KSXGitHub Feb 28, 2026
055dad0
refactor: remove duplicated `to_string_lossy`
KSXGitHub Feb 28, 2026
8316641
refactor: rename a variable
KSXGitHub Feb 28, 2026
f4d6360
refactor: unabbreviate a variable
KSXGitHub Feb 28, 2026
fc15472
refactor: unabbreviate a variable
KSXGitHub Feb 28, 2026
54fad48
refactor: unabbreviate some variables
KSXGitHub Feb 28, 2026
22236c8
refactor: unabbreviate some variables
KSXGitHub Feb 28, 2026
a6adb98
refactor: unabbreviate a variable
KSXGitHub Feb 28, 2026
c642f25
refactor: unabbreviate a variable
KSXGitHub Feb 28, 2026
2d745ae
refactor(usage_md): extract helper functions, fix var names, use curr…
Copilot Feb 28, 2026
91a452f
refactor: simplify
KSXGitHub Feb 28, 2026
5ecaea3
refactor: shorten some variable names
KSXGitHub Feb 28, 2026
c7895dd
refactor: prefer turbo fish
KSXGitHub Feb 28, 2026
691f321
refactor: remove unnecessary bindings
KSXGitHub Feb 28, 2026
2309236
refactor: stop using temporary `Vec`
KSXGitHub Feb 28, 2026
46b9109
refactor: make `out: &String` the first arg
KSXGitHub Feb 28, 2026
9917068
refactor: remove potential panic
KSXGitHub Feb 28, 2026
8036fdf
refactor: remove unnecessary allocation
KSXGitHub Feb 28, 2026
0fa4aa8
fix(docs/usage): stop concatenating
KSXGitHub Feb 28, 2026
c74bc7f
fix(docs): unnecessary comments
KSXGitHub Feb 28, 2026
5fc9f28
fix: required positional args use angle brackets; use get_action() to…
Copilot Feb 28, 2026
03ec521
refactor: reduce allocation
KSXGitHub Feb 28, 2026
d2b6169
feat(docs): still show default of `"true"`
KSXGitHub Feb 28, 2026
c178b5d
refactor: replace the brittle string replacement
KSXGitHub Feb 28, 2026
32628c6
refactor: remove unused link
KSXGitHub Feb 28, 2026
bf66a60
fix: strip trailing whitespace from help exports and normalize in tests
Copilot Feb 28, 2026
22fd0af
refactor: use `pipe`
KSXGitHub Feb 28, 2026
38b2369
test: remove unnecessary trimming
KSXGitHub Feb 28, 2026
ef37654
refactor: use macro
KSXGitHub Feb 28, 2026
b801758
refactor: rename a test
KSXGitHub Feb 28, 2026
b516907
chore(cargo): include `USAGE.md`
KSXGitHub Feb 28, 2026
05e0285
refactor: split the tests
KSXGitHub Feb 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ name = "pdu-completions"
path = "cli/completions.rs"
required-features = ["cli-completions"]

[[bin]]
name = "pdu-usage-md"
path = "cli/usage_md.rs"
required-features = ["cli"]

[features]
default = ["cli"]
json = ["serde/derive", "serde_json"]
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ The benchmark was generated by [a GitHub Workflow](https://github.com/KSXGitHub/
* Do not differentiate filesystem: Mounted folders are counted as normal folders.
* The runtime is optimized at the expense of binary size.

## Usage

See [USAGE.md](./USAGE.md) for the full help text.
Comment thread
KSXGitHub marked this conversation as resolved.

## Development

### Prerequisites
Expand Down
105 changes: 105 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Usage

```sh
pdu [OPTIONS] [FILES]...
```

## Arguments

* `[FILES]...`: List of files and/or directories
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated

## Options

* `--json-input`: Read JSON data from stdin
* `--json-output`: Print JSON data instead of an ASCII chart
* `-b <BYTES_FORMAT>`, `--bytes-format <BYTES_FORMAT>`: How to display the numbers of bytes (default: `metric`)
* `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
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
* `-H`, `--deduplicate-hardlinks`, `--detect-links`, `--dedupe-links`: Detect and subtract the sizes of hardlinks from their parent directory totals
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
* `--top-down`: Print the tree top-down instead of bottom-up
* `--align-right`: Set the root of the bars to the right
* `-q <QUANTITY>`, `--quantity <QUANTITY>`: Aspect of the files/directories to be measured (default: `block-size`)
* `apparent-size`: Measure apparent sizes
* `block-size`: Measure block sizes (block-count * 512B)
* `block-count`: Count numbers of blocks
* `-d <MAX_DEPTH>`, `--max-depth <MAX_DEPTH>`, `--depth <MAX_DEPTH>`: Maximum depth to display the data. Could be either "inf" or a positive integer (default: `10`)
* `-w <TOTAL_WIDTH>`, `--total-width <TOTAL_WIDTH>`, `--width <TOTAL_WIDTH>`: Width of the visualization
* `--column-width <TREE_WIDTH> <BAR_WIDTH>`: Maximum widths of the tree column and width of the bar column
* `-m <MIN_RATIO>`, `--min-ratio <MIN_RATIO>`: Minimal size proportion required to appear (default: `0.01`)
* `--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 <THREADS>`: Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer (default: `auto`)
* `--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
* `-h`, `--help`: Print help (see a summary with '-h')
* `-V`, `--version`: Print version

## Examples

### Show disk usage chart of current working directory

```sh
pdu
```

### Show disk usage chart of a single file or directory

```sh
pdu path/to/file/or/directory
```

### Compare disk usages of multiple files and/or directories

```sh
pdu file.txt dir/
```

### Show chart in apparent sizes instead of block sizes

```sh
pdu --quantity=apparent-size
```

### Detect and subtract the sizes of hardlinks from their parent nodes

```sh
pdu --deduplicate-hardlinks
```

### Show sizes in plain numbers instead of metric units

```sh
pdu --bytes-format=plain
```

### Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric)

```sh
pdu --bytes-format=binary
```

### Show disk usage chart of all entries regardless of size

```sh
pdu --min-ratio=0
```

### Only show disk usage chart of entries whose size is at least 5% of total

```sh
pdu --min-ratio=0.05
```

### Show disk usage data as JSON instead of chart

```sh
pdu --min-ratio=0 --max-depth=inf --json-output | jq
```

### Visualize existing JSON representation of disk usage data

```sh
pdu --json-input < disk-usage.json
```
226 changes: 226 additions & 0 deletions cli/usage_md.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
fn main() {
let help = include_str!("../exports/long.help");
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
println!("{}", render(help).trim_end());
}

fn render(input: &str) -> String {
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
let mut out = String::new();
let lines: Vec<&str> = input.lines().collect();

let section_positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| is_section_header(l))
.map(|(i, _)| i)
.collect();

let first_section = section_positions.first().copied().unwrap_or(lines.len());
render_preamble(&lines[..first_section], &mut out);

for (idx, &start) in section_positions.iter().enumerate() {
let end = section_positions
.get(idx + 1)
.copied()
.unwrap_or(lines.len());
let name = lines[start].strip_suffix(':').unwrap_or(lines[start]);
render_section(name, &lines[start + 1..end], &mut out);
}

out
}

/// A top-level section header is a single unindented word followed by `:`
/// (e.g. `Arguments:`, `Options:`, `Examples:`).
/// Lines like `Usage: pdu …` or `Copyright: …` do not qualify because they
/// either contain spaces before `:` content or have trailing content.
fn is_section_header(line: &str) -> bool {
!line.starts_with(' ')
&& line
.strip_suffix(':')
.is_some_and(|s| !s.is_empty() && s.chars().all(|c| c.is_alphabetic()))
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
}

fn render_preamble(lines: &[&str], out: &mut String) {
for line in lines {
if let Some(rest) = line.strip_prefix("Usage: ") {
out.push_str("# Usage\n\n```sh\n");
out.push_str(rest);
out.push_str("\n```\n\n");
}
}
}

fn render_section(name: &str, lines: &[&str], out: &mut String) {
out.push_str(&format!("## {name}\n\n"));
match name {
"Arguments" | "Options" => render_flag_section(lines, out),
"Examples" => render_examples_section(lines, out),
_ => {}
}
}

fn render_flag_section(lines: &[&str], out: &mut String) {
// Flag / argument names are indented 2–6 spaces; descriptions are indented 10+.
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
let item_starts: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| {
let trimmed = l.trim_start();
if trimmed.is_empty() {
return false;
}
let indent = l.len() - trimmed.len();
(2..=6).contains(&indent)
})
.map(|(i, _)| i)
.collect();

for (idx, &start) in item_starts.iter().enumerate() {
let end = item_starts.get(idx + 1).copied().unwrap_or(lines.len());
render_flag_item(&lines[start..end], out);
}
out.push('\n');
}

/// Format a flag line and its aliases as a comma-separated list of backtick-quoted names.
///
/// For example, `-b, --bytes-format <BYTES_FORMAT>` with no aliases becomes
/// `` `-b <BYTES_FORMAT>`, `--bytes-format <BYTES_FORMAT>` ``.
///
/// For `-H, --deduplicate-hardlinks` with aliases `--detect-links, --dedupe-links` the
/// result is `` `-H`, `--deduplicate-hardlinks`, `--detect-links`, `--dedupe-links` ``.
fn format_flag_names(flag_line: &str, aliases: Option<&str>) -> String {
let parts: Vec<&str> = flag_line.split(", ").collect();

// Extract the value placeholder suffix from the last part (e.g. " <BYTES_FORMAT>").
let value_suffix = parts
.last()
.and_then(|p| p.find(' ').map(|i| &p[i..]))
.unwrap_or("");

let mut names: Vec<String> = parts
.iter()
.enumerate()
.map(|(i, part)| {
if i < parts.len() - 1 && !value_suffix.is_empty() {
format!("{part}{value_suffix}")
} else {
part.to_string()
}
})
.collect();

if let Some(a) = aliases {
for alias in a.split(", ") {
if value_suffix.is_empty() {
names.push(alias.to_string());
} else {
names.push(format!("{alias}{value_suffix}"));
}
}
}

names
.iter()
.map(|n| format!("`{n}`"))
.collect::<Vec<_>>()
.join(", ")
Comment thread
KSXGitHub marked this conversation as resolved.
Outdated
}

fn render_flag_item(lines: &[&str], out: &mut String) {
if lines.is_empty() {
return;
}
let flag = lines[0].trim();

let mut desc_parts: Vec<&str> = Vec::new();
let mut possible_values: Vec<(&str, &str)> = Vec::new();
let mut default_value: Option<&str> = None;
let mut aliases_value: Option<&str> = None;
let mut in_possible_values = false;

for line in &lines[1..] {
let trimmed = line.trim();
if trimmed.is_empty() {
in_possible_values = false;
continue;
}
if trimmed == "Possible values:" {
in_possible_values = true;
continue;
}
if let Some(inner) = trimmed
.strip_prefix("[default: ")
.and_then(|s| s.strip_suffix(']'))
{
default_value = Some(inner);
continue;
}
if let Some(inner) = trimmed
.strip_prefix("[aliases: ")
.and_then(|s| s.strip_suffix(']'))
{
aliases_value = Some(inner);
continue;
}
if in_possible_values {
if let Some(entry) = trimmed.strip_prefix("- ") {
let (name, desc) = if let Some(colon) = entry.find(':') {
(entry[..colon].trim(), entry[colon + 1..].trim())
} else {
(entry.trim(), "")
};
possible_values.push((name, desc));
}
continue;
}
desc_parts.push(trimmed);
}

let names = format_flag_names(flag, aliases_value);
let description = desc_parts.join(" ");
out.push_str(&format!("* {names}: {description}"));
if let Some(d) = default_value {
out.push_str(&format!(" (default: `{d}`)"));
}
out.push('\n');

for (name, desc) in &possible_values {
if desc.is_empty() {
out.push_str(&format!(" * `{name}`\n"));
} else {
out.push_str(&format!(" * `{name}`: {desc}\n"));
}
}
}

fn render_examples_section(lines: &[&str], out: &mut String) {
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
if trimmed.is_empty() {
i += 1;
continue;
}
// A line starting with `$ ` is a bare command (no preceding description).
if let Some(cmd) = trimmed.strip_prefix("$ ") {
out.push_str(&format!("### `{cmd}`\n\n```sh\n{cmd}\n```\n\n"));
i += 1;
} else {
// Description line — the very next non-empty line should be `$ <cmd>`.
let desc = trimmed;
i += 1;
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
if i < lines.len() {
if let Some(cmd) = lines[i].trim().strip_prefix("$ ") {
out.push_str(&format!("### {desc}\n\n```sh\n{cmd}\n```\n\n"));
i += 1;
continue;
}
}
out.push_str(&format!("### {desc}\n\n"));
}
}
}
Loading