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.toml b/Cargo.toml
index f408062c..db569865 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"]
+
[[bin]]
name = "pdu-usage-md"
path = "cli/usage_md.rs"
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/cli/man_page.rs b/cli/man_page.rs
new file mode 100644
index 00000000..a1419240
--- /dev/null
+++ b/cli/man_page.rs
@@ -0,0 +1,5 @@
+use parallel_disk_usage::man_page::render_man_page;
+
+fn main() {
+ print!("{}", render_man_page());
+}
diff --git a/exports/pdu.1 b/exports/pdu.1
new file mode 100644
index 00000000..5d76c63f
--- /dev/null
+++ b/exports/pdu.1
@@ -0,0 +1,179 @@
+.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 \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
+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
+.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
+.TP
+\fB\-b\fR, \fB\-\-bytes\-format\fR \fI\fR [default: metric]
+How to display the numbers of bytes
+.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
+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
+.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
+.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
+.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
+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
+.TP
+\fB\-\-no\-sort\fR
+Do not sort the branches in the tree
+.TP
+\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
+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 '\-h')
+.TP
+\fB\-V\fR, \fB\-\-version\fR
+Print version
+.SH EXAMPLES
+.TP
+Show disk usage chart of current working directory
+.nf
+\fB$ pdu\fR
+.fi
+.TP
+Show disk usage chart of a single file or directory
+.nf
+\fB$ pdu path/to/file/or/directory\fR
+.fi
+.TP
+Compare disk usages of multiple files and/or directories
+.nf
+\fB$ pdu file.txt dir/\fR
+.fi
+.TP
+Show chart in apparent sizes instead of block sizes
+.nf
+\fB$ pdu \-\-quantity=apparent\-size\fR
+.fi
+.TP
+Detect and subtract the sizes of hardlinks from their parent nodes
+.nf
+\fB$ pdu \-\-deduplicate\-hardlinks\fR
+.fi
+.TP
+Show sizes in plain numbers instead of metric units
+.nf
+\fB$ pdu \-\-bytes\-format=plain\fR
+.fi
+.TP
+Show sizes in base 2¹⁰ units (binary) instead of base 10³ units (metric)
+.nf
+\fB$ pdu \-\-bytes\-format=binary\fR
+.fi
+.TP
+Show disk usage chart of all entries regardless of size
+.nf
+\fB$ pdu \-\-min\-ratio=0\fR
+.fi
+.TP
+Only show disk usage chart of entries whose size is at least 5% of total
+.nf
+\fB$ pdu \-\-min\-ratio=0.05\fR
+.fi
+.TP
+Show disk usage data as JSON instead of chart
+.nf
+\fB$ pdu \-\-min\-ratio=0 \-\-max\-depth=inf \-\-json\-output | jq\fR
+.fi
+.TP
+Visualize existing JSON representation of disk usage data
+.nf
+\fB$ pdu \-\-json\-input < disk\-usage.json\fR
+.fi
+.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/src/lib.rs b/src/lib.rs
index 7aeb6e90..f5a4d044 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,6 +15,8 @@ pub mod app;
#[cfg(feature = "cli")]
pub mod args;
#[cfg(feature = "cli")]
+pub mod man_page;
+#[cfg(feature = "cli")]
pub mod runtime_error;
#[cfg(feature = "cli")]
pub mod usage_md;
diff --git a/src/man_page.rs b/src/man_page.rs
new file mode 100644
index 00000000..40791a52
--- /dev/null
+++ b/src/man_page.rs
@@ -0,0 +1,354 @@
+use crate::args::Args;
+use clap::{Arg, ArgAction, Command, CommandFactory};
+use itertools::Itertools;
+use std::{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, &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.
+///
+/// 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()
+ .push(conflict_id.clone());
+ map.entry(conflict_id).or_default().push(arg_id.clone());
+ }
+ }
+ 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"\-")
+}
+
+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(ToString::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");
+ 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);
+ }
+ 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);
+ }
+ out.push('\n');
+}
+
+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();
+ }
+ }
+ }
+ out.push(']');
+}
+
+fn is_multiple(arg: &Arg) -> 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{ellipsis}", roff_escape(name)).unwrap();
+ } else {
+ write!(out, "[\\fI{}\\fR]{ellipsis}", 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(ToString::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");
+ } else if !first {
+ out.push_str(".br\n");
+ }
+ need_paragraph = false;
+ first = false;
+ writeln!(out, "{}", roff_escape(line)).unwrap();
+ }
+}
+
+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, conflict_map);
+ }
+}
+
+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);
+ } else {
+ render_option_header_flag(out, arg);
+ }
+ let help = arg
+ .get_long_help()
+ .or_else(|| arg.get_help())
+ .map(ToString::to_string)
+ .unwrap_or_default();
+ writeln!(out, "{}", roff_escape(&help)).unwrap();
+ render_possible_values(out, arg);
+ render_conflicts(out, command, arg, conflict_map);
+}
+
+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());
+ let ellipsis = if is_multiple(arg) { "..." } else { "" };
+ if arg.is_required_set() {
+ writeln!(out, "\\fI{name}\\fR{ellipsis}").unwrap();
+ } else {
+ writeln!(out, "[\\fI{name}\\fR]{ellipsis}").unwrap();
+ }
+}
+
+fn render_option_header_flag(out: &mut String, arg: &Arg) {
+ 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(|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);
+ writeln!(out, "{header} {value_str}").unwrap();
+ } else {
+ writeln!(out, "{header}").unwrap();
+ }
+}
+
+fn render_value_hint(arg: &Arg) -> String {
+ let value_part = arg
+ .get_value_names()
+ .map(<[_]>::iter)
+ .map(|names| names.map(|name| name.as_str()))
+ .map(Vec::from_iter)
+ .unwrap_or_else(|| vec![arg.get_id().as_str()])
+ .into_iter()
+ .map(roff_escape)
+ .map(|name| format!("\\fI<{name}>\\fR"))
+ .join(" ");
+ let defaults = arg
+ .get_default_values()
+ .iter()
+ .map(|value| value.to_string_lossy())
+ .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}]")
+ }
+}
+
+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;
+ }
+ let flag = arg
+ .get_long()
+ .map(roff_escape)
+ .map(|long| format!("\\-\\-{long}"))
+ .unwrap_or_default();
+ out.push_str(".RS\n");
+ for value in &possible_values {
+ 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");
+}
+
+fn render_conflicts(out: &mut String, command: &Command, arg: &Arg, conflict_map: &ConflictMap) {
+ let arg_id = arg.get_id().as_str();
+ 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();
+ }
+}
+
+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(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();
+ }
+ }
+}
+
+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/template/parallel-disk-usage-bin/PKGBUILD b/template/parallel-disk-usage-bin/PKGBUILD
index 83691380..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
)
@@ -21,4 +22,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/tests/sync_man_page.rs b/tests/sync_man_page.rs
new file mode 100644
index 00000000..f4a6db43
--- /dev/null
+++ b/tests/sync_man_page.rs
@@ -0,0 +1,20 @@
+//! 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(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");
+ assert!(
+ received == expected,
+ "man page is outdated, run ./generate-completions.sh to update it",
+ );
+}