Skip to content

Commit 81b8f0d

Browse files
CopilotKSXGitHub
andcommitted
feat: parse long.help to generate structured USAGE.md
Co-authored-by: KSXGitHub <11488886+KSXGitHub@users.noreply.github.com>
1 parent d17b9c1 commit 81b8f0d

6 files changed

Lines changed: 272 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ name = "pdu-completions"
4242
path = "cli/completions.rs"
4343
required-features = ["cli-completions"]
4444

45+
[[bin]]
46+
name = "pdu-usage-md"
47+
path = "cli/usage_md.rs"
48+
required-features = ["cli"]
49+
4550
[features]
4651
default = ["cli"]
4752
json = ["serde/derive", "serde_json"]

USAGE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Usage
2+
3+
```sh
4+
pdu [OPTIONS] [FILES]...
5+
```
6+
7+
## Arguments
8+
9+
* `[FILES]...`: List of files and/or directories
10+
11+
## Options
12+
13+
* `--json-input`: Read JSON data from stdin
14+
* `--json-output`: Print JSON data instead of an ASCII chart
15+
* `-b, --bytes-format <BYTES_FORMAT>`: How to display the numbers of bytes (default: `metric`)
16+
* `plain`: Display plain number of bytes without units
17+
* `metric`: Use metric scale, i.e. 1K = 1000B, 1M = 1000K, and so on
18+
* `binary`: Use binary scale, i.e. 1K = 1024B, 1M = 1024K, and so on
19+
* `-H, --deduplicate-hardlinks`: Detect and subtract the sizes of hardlinks from their parent directory totals
20+
* Aliases: `--detect-links`, `--dedupe-links`
21+
* `--top-down`: Print the tree top-down instead of bottom-up
22+
* `--align-right`: Set the root of the bars to the right
23+
* `-q, --quantity <QUANTITY>`: Aspect of the files/directories to be measured (default: `block-size`)
24+
* `apparent-size`: Measure apparent sizes
25+
* `block-size`: Measure block sizes (block-count * 512B)
26+
* `block-count`: Count numbers of blocks
27+
* `-d, --max-depth <MAX_DEPTH>`: Maximum depth to display the data. Could be either "inf" or a positive integer (default: `10`)
28+
* Aliases: `--depth`
29+
* `-w, --total-width <TOTAL_WIDTH>`: Width of the visualization
30+
* Aliases: `--width`
31+
* `--column-width <TREE_WIDTH> <BAR_WIDTH>`: Maximum widths of the tree column and width of the bar column
32+
* `-m, --min-ratio <MIN_RATIO>`: Minimal size proportion required to appear (default: `0.01`)
33+
* `--no-sort`: Do not sort the branches in the tree
34+
* `-s, --silent-errors`: Prevent filesystem error messages from appearing in stderr
35+
* Aliases: `--no-errors`
36+
* `-p, --progress`: Report progress being made at the expense of performance
37+
* `--threads <THREADS>`: Set the maximum number of threads to spawn. Could be either "auto", "max", or a positive integer (default: `auto`)
38+
* `--omit-json-shared-details`: Do not output `.shared.details` in the JSON output
39+
* `--omit-json-shared-summary`: Do not output `.shared.summary` in the JSON output
40+
* `-h, --help`: Print help (see a summary with '-h')
41+
* `-V, --version`: Print version
42+
43+
## Examples
44+
45+
* Show disk usage chart of current working directory: `pdu`
46+
* Show disk usage chart of a single file or directory: `pdu path/to/file/or/directory`
47+
* Compare disk usages of multiple files and/or directories: `pdu file.txt dir/`
48+
* Show chart in apparent sizes instead of block sizes: `pdu --quantity=apparent-size`
49+
* Detect and subtract the sizes of hardlinks from their parent nodes: `pdu --deduplicate-hardlinks`
50+
* Show sizes in plain numbers instead of metric units: `pdu --bytes-format=plain`
51+
* Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric): `pdu --bytes-format=binary`
52+
* Show disk usage chart of all entries regardless of size: `pdu --min-ratio=0`
53+
* Only show disk usage chart of entries whose size is at least 5% of total: `pdu --min-ratio=0.05`
54+
* Show disk usage data as JSON instead of chart: `pdu --min-ratio=0 --max-depth=inf --json-output | jq`
55+
* Visualize existing JSON representation of disk usage data: `pdu --json-input < disk-usage.json`

cli/usage_md.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
fn main() {
2+
let help = include_str!("../exports/long.help");
3+
print!("{}\n", render(help).trim_end());
4+
}
5+
6+
fn render(input: &str) -> String {
7+
let mut out = String::new();
8+
let lines: Vec<&str> = input.lines().collect();
9+
10+
let section_positions: Vec<usize> = lines
11+
.iter()
12+
.enumerate()
13+
.filter(|(_, l)| is_section_header(l))
14+
.map(|(i, _)| i)
15+
.collect();
16+
17+
let first_section = section_positions.first().copied().unwrap_or(lines.len());
18+
render_preamble(&lines[..first_section], &mut out);
19+
20+
for (idx, &start) in section_positions.iter().enumerate() {
21+
let end = section_positions
22+
.get(idx + 1)
23+
.copied()
24+
.unwrap_or(lines.len());
25+
let name = lines[start].trim_end_matches(':');
26+
render_section(name, &lines[start + 1..end], &mut out);
27+
}
28+
29+
out
30+
}
31+
32+
/// A top-level section header is a single unindented word followed by `:`
33+
/// (e.g. `Arguments:`, `Options:`, `Examples:`).
34+
/// Lines like `Usage: pdu …` or `Copyright: …` do not qualify because they
35+
/// either contain spaces before `:` content or have trailing content.
36+
fn is_section_header(line: &str) -> bool {
37+
!line.starts_with(' ')
38+
&& line
39+
.strip_suffix(':')
40+
.is_some_and(|s| !s.is_empty() && s.chars().all(|c| c.is_alphabetic()))
41+
}
42+
43+
fn render_preamble(lines: &[&str], out: &mut String) {
44+
for line in lines {
45+
if let Some(rest) = line.strip_prefix("Usage: ") {
46+
out.push_str("# Usage\n\n```sh\n");
47+
out.push_str(rest);
48+
out.push_str("\n```\n\n");
49+
}
50+
}
51+
}
52+
53+
fn render_section(name: &str, lines: &[&str], out: &mut String) {
54+
out.push_str(&format!("## {name}\n\n"));
55+
match name {
56+
"Arguments" | "Options" => render_flag_section(lines, out),
57+
"Examples" => render_examples_section(lines, out),
58+
_ => {}
59+
}
60+
}
61+
62+
fn render_flag_section(lines: &[&str], out: &mut String) {
63+
// Flag / argument names are indented 2–6 spaces; descriptions are indented 10+.
64+
let item_starts: Vec<usize> = lines
65+
.iter()
66+
.enumerate()
67+
.filter(|(_, l)| {
68+
let trimmed = l.trim_start();
69+
if trimmed.is_empty() {
70+
return false;
71+
}
72+
let indent = l.len() - trimmed.len();
73+
indent >= 2 && indent <= 6
74+
})
75+
.map(|(i, _)| i)
76+
.collect();
77+
78+
for (idx, &start) in item_starts.iter().enumerate() {
79+
let end = item_starts.get(idx + 1).copied().unwrap_or(lines.len());
80+
render_flag_item(&lines[start..end], out);
81+
}
82+
out.push('\n');
83+
}
84+
85+
fn render_flag_item(lines: &[&str], out: &mut String) {
86+
if lines.is_empty() {
87+
return;
88+
}
89+
let flag = lines[0].trim();
90+
91+
let mut desc_parts: Vec<&str> = Vec::new();
92+
let mut possible_values: Vec<(&str, &str)> = Vec::new();
93+
let mut default_value: Option<&str> = None;
94+
let mut aliases_value: Option<&str> = None;
95+
let mut in_possible_values = false;
96+
97+
for line in &lines[1..] {
98+
let trimmed = line.trim();
99+
if trimmed.is_empty() {
100+
in_possible_values = false;
101+
continue;
102+
}
103+
if trimmed == "Possible values:" {
104+
in_possible_values = true;
105+
continue;
106+
}
107+
if let Some(inner) = trimmed
108+
.strip_prefix("[default: ")
109+
.and_then(|s| s.strip_suffix(']'))
110+
{
111+
default_value = Some(inner);
112+
continue;
113+
}
114+
if let Some(inner) = trimmed
115+
.strip_prefix("[aliases: ")
116+
.and_then(|s| s.strip_suffix(']'))
117+
{
118+
aliases_value = Some(inner);
119+
continue;
120+
}
121+
if in_possible_values {
122+
if let Some(entry) = trimmed.strip_prefix("- ") {
123+
let (name, desc) = if let Some(colon) = entry.find(':') {
124+
(entry[..colon].trim(), entry[colon + 1..].trim())
125+
} else {
126+
(entry.trim(), "")
127+
};
128+
possible_values.push((name, desc));
129+
}
130+
continue;
131+
}
132+
desc_parts.push(trimmed);
133+
}
134+
135+
let description = desc_parts.join(" ");
136+
out.push_str(&format!("* `{flag}`: {description}"));
137+
if let Some(d) = default_value {
138+
out.push_str(&format!(" (default: `{d}`)"));
139+
}
140+
out.push('\n');
141+
142+
for (name, desc) in &possible_values {
143+
if desc.is_empty() {
144+
out.push_str(&format!(" * `{name}`\n"));
145+
} else {
146+
out.push_str(&format!(" * `{name}`: {desc}\n"));
147+
}
148+
}
149+
150+
if let Some(a) = aliases_value {
151+
let formatted = a
152+
.split(", ")
153+
.map(|s| format!("`{s}`"))
154+
.collect::<Vec<_>>()
155+
.join(", ");
156+
out.push_str(&format!(" * Aliases: {formatted}\n"));
157+
}
158+
}
159+
160+
fn render_examples_section(lines: &[&str], out: &mut String) {
161+
let mut i = 0;
162+
while i < lines.len() {
163+
let trimmed = lines[i].trim();
164+
if trimmed.is_empty() {
165+
i += 1;
166+
continue;
167+
}
168+
// A line starting with `$ ` is a bare command (no preceding description).
169+
if let Some(cmd) = trimmed.strip_prefix("$ ") {
170+
out.push_str(&format!("* `{cmd}`\n"));
171+
i += 1;
172+
} else {
173+
// Description line — the very next non-empty line should be `$ <cmd>`.
174+
let desc = trimmed;
175+
i += 1;
176+
while i < lines.len() && lines[i].trim().is_empty() {
177+
i += 1;
178+
}
179+
if i < lines.len() {
180+
if let Some(cmd) = lines[i].trim().strip_prefix("$ ") {
181+
out.push_str(&format!("* {desc}: `{cmd}`\n"));
182+
i += 1;
183+
continue;
184+
}
185+
}
186+
out.push_str(&format!("* {desc}\n"));
187+
}
188+
}
189+
out.push('\n');
190+
}

generate-completions.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ gen elvish completion.elv
1616

1717
./run.sh pdu --help > exports/long.help
1818
./run.sh pdu -h > exports/short.help
19+
./run.sh pdu-usage-md > USAGE.md

tests/_utils.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,9 @@ where
442442
/// Path to the `pdu` executable
443443
pub const PDU: &str = env!("CARGO_BIN_EXE_pdu");
444444

445+
/// Path to the `pdu-usage-md` executable
446+
pub const PDU_USAGE_MD: &str = env!("CARGO_BIN_EXE_pdu-usage-md");
447+
445448
/// Representation of a `pdu` command.
446449
#[derive(Debug, Default, Clone)]
447450
pub struct CommandRepresentation<'a> {

tests/sync_help.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,25 @@ macro_rules! check {
3333
);
3434
}
3535
};
36+
($name:ident: bin $bin:expr => $path:literal) => {
37+
#[test]
38+
fn $name() {
39+
let actual = Command::new($bin)
40+
.with_stdin(Stdio::null())
41+
.with_stdout(Stdio::piped())
42+
.with_stderr(Stdio::null())
43+
.output()
44+
.expect("get actual help text")
45+
.pipe(stdout_text);
46+
let expected = include_str!($path);
47+
assert!(
48+
actual == expected.trim_end(),
49+
"help text is outdated, run ./generate-completions.sh to update it",
50+
);
51+
}
52+
};
3653
}
3754

3855
check!(long_help_is_up_to_date: "--help" => "../exports/long.help");
3956
check!(short_help_is_up_to_date: "-h" => "../exports/short.help");
57+
check!(usage_md_is_up_to_date: bin PDU_USAGE_MD => "../USAGE.md");

0 commit comments

Comments
 (0)