use std::io::Write;
use clap::{Arg, ArgAction, Command};
use clap_mangen::Man;
use clap_mangen::roff::{Inline, Roff, bold, italic, roman};
pub fn render_consolidated(
cli: Command,
w: &mut dyn Write,
) -> std::io::Result<()> {
let mut cli = cli.disable_help_subcommand(true);
cli.build();
let top = Man::new(cli.clone());
top.render_title(w)?;
top.render_name_section(w)?;
top.render_synopsis_section(w)?;
top.render_description_section(w)?;
if has_visible_args(&cli) {
top.render_options_section(w)?;
}
let mut sub_roff = Roff::default();
sub_roff.control("SH", ["SUBCOMMANDS"]);
let top_name = cli.get_bin_name().unwrap_or_else(|| cli.get_name()).to_owned();
walk_subcommands(&cli, &mut sub_roff, &[top_name.as_str()]);
sub_roff.to_writer(w)?;
Ok(())
}
fn walk_subcommands(cmd: &Command, roff: &mut Roff, path: &[&str]) {
for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
let mut sub_path: Vec<&str> = path.to_vec();
sub_path.push(sub.get_name());
let heading = sub_path.join(" ");
roff.control("SS", [heading.as_str()]);
if let Some(about) = sub.get_long_about().or_else(|| sub.get_about()) {
for line in about.to_string().lines() {
if line.trim().is_empty() {
roff.control("PP", []);
} else {
roff.text([roman(line)]);
}
}
}
render_args(sub, roff);
walk_subcommands(sub, roff, &sub_path);
}
}
fn render_args(cmd: &Command, roff: &mut Roff) {
let opts: Vec<&Arg> = cmd
.get_arguments()
.filter(|a| !a.is_hide_set())
.filter(|a| !a.is_positional())
.collect();
let positionals: Vec<&Arg> = cmd.get_positionals().collect();
if opts.is_empty() && positionals.is_empty() {
return;
}
for opt in opts {
let (lhs, rhs) = option_markers(opt);
let mut tag: Vec<Inline> = Vec::new();
match (opt.get_short(), opt.get_long()) {
(Some(short), Some(long)) => {
tag.push(roman(lhs));
tag.push(bold(format!("-{short}")));
tag.push(roman(", "));
tag.push(bold(format!("--{long}")));
tag.push(roman(rhs));
}
(Some(short), None) => {
tag.push(roman(lhs));
tag.push(bold(format!("-{short}")));
tag.push(roman(rhs));
}
(None, Some(long)) => {
tag.push(roman(lhs));
tag.push(bold(format!("--{long}")));
tag.push(roman(rhs));
}
(None, None) => continue,
}
if matches!(opt.get_action(), ArgAction::Count) {
tag.push(roman("..."));
}
if let Some(value_names) = opt.get_value_names() {
tag.push(roman(" "));
tag.push(italic(format!("<{}>", value_names.join(" "))));
}
roff.control("TP", []);
roff.text(tag);
if let Some(help) = opt.get_long_help().or_else(|| opt.get_help()) {
roff.text([roman(help.to_string())]);
}
}
for arg in positionals {
let (lhs, rhs) = option_markers(arg);
let mut tag: Vec<Inline> = Vec::new();
tag.push(roman(lhs));
if let Some(value_names) = arg.get_value_names() {
tag.push(italic(value_names.join(" ")));
} else {
tag.push(italic(arg.get_id().as_str().to_owned()));
}
tag.push(roman(rhs));
roff.control("TP", []);
roff.text(tag);
if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
roff.text([roman(help.to_string())]);
}
}
}
fn option_markers(arg: &Arg) -> (&'static str, &'static str) {
if arg.is_required_set() { ("", "") } else { ("[", "]") }
}
fn has_visible_args(cmd: &Command) -> bool {
cmd.get_arguments().any(|a| !a.is_hide_set())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{Command, arg};
fn tiny_cli() -> Command {
Command::new("toy")
.about("Toy CLI for testing the consolidated man page")
.arg(arg!(-v --verbose "Enable verbose output"))
.subcommand(
Command::new("get")
.about("Read-only queries")
.subcommand(
Command::new("sessions")
.about("List sessions")
.arg(arg!(-n --name <NAME> "Session name filter")),
),
)
}
#[test]
fn render_consolidated_emits_top_level_and_subcommands() {
let mut buf: Vec<u8> = Vec::new();
render_consolidated(tiny_cli(), &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains(".SH NAME"), "missing NAME header: {text}");
assert!(text.contains(".SH OPTIONS"), "missing OPTIONS header: {text}");
assert_eq!(
text.matches(".SH SUBCOMMANDS").count(),
1,
"expected one SUBCOMMANDS header: {text}"
);
assert!(
text.contains(".SS \"toy get\""),
"missing top-level subcommand SS: {text}"
);
assert!(
text.contains(".SS \"toy get sessions\""),
"missing nested SS: {text}"
);
}
#[test]
fn render_consolidated_inlines_subcommand_option_tables() {
let mut buf: Vec<u8> = Vec::new();
render_consolidated(tiny_cli(), &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(
text.contains(r"\-\-name"),
"subcommand option missing from page: {text}"
);
assert!(
text.contains("Session name filter"),
"subcommand help missing from page: {text}"
);
}
#[test]
fn render_consolidated_handles_command_with_no_subcommands() {
let cli = Command::new("solo")
.about("No subcommands here")
.arg(arg!(-f --force "Skip confirmation"));
let mut buf: Vec<u8> = Vec::new();
render_consolidated(cli, &mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains(".SH SUBCOMMANDS"));
assert!(!text.contains(".SS "), "no SS expected: {text}");
}
}