use crate::tabular::{Column, FlatDataSpec, Width};
use crate::topics::TopicRegistry;
use clap::Command;
use serde::Serialize;
use std::collections::BTreeMap;
use super::config::{CommandGroup, HelpLength};
const NAME_COLUMN_MIN: usize = 12;
const COLUMN_SEPARATOR: &str = " ";
const ASSUMED_TERMINAL_WIDTH: usize = 80;
pub(crate) fn resolve_name_column(names: &[&str]) -> usize {
let spec = FlatDataSpec::builder()
.column(Column::new(Width::Bounded {
min: Some(NAME_COLUMN_MIN),
max: None,
}))
.column(Column::new(Width::Fill))
.separator(COLUMN_SEPARATOR)
.build();
let rows: Vec<Vec<&str>> = names.iter().map(|name| vec![*name]).collect();
let terminal_width = standout_render::detect_terminal_width().unwrap_or(ASSUMED_TERMINAL_WIDTH);
spec.resolve_widths_from_data(terminal_width, &rows)
.get(0)
.unwrap_or(NAME_COLUMN_MIN)
}
#[derive(Serialize)]
pub(crate) struct HelpData {
pub name: String,
pub about: String,
pub usage: String,
pub subcommands: Vec<Group<Subcommand>>,
pub subcommands_width: usize,
pub arguments: Vec<Group<OptionData>>,
pub arguments_width: usize,
pub options: Vec<Group<OptionData>>,
pub options_width: usize,
pub examples: String,
pub learn_more: Vec<TopicListItem>,
pub learn_more_width: usize,
}
#[derive(Serialize)]
pub(crate) struct Group<T> {
pub title: Option<String>,
pub help: Option<String>,
pub items: Vec<T>,
}
#[derive(Serialize)]
pub(crate) struct Subcommand {
pub name: String,
pub about: String,
pub separator: bool,
}
#[derive(Serialize)]
pub(crate) struct OptionData {
pub name: String,
pub help: String,
pub short: Option<char>,
pub long: Option<String>,
pub default: Option<String>,
pub possible_values: Vec<String>,
}
#[derive(Serialize)]
pub(crate) struct TopicListItem {
pub name: String,
pub title: String,
}
fn flag_name(arg: &clap::Arg) -> String {
let mut name = String::new();
if let Some(short) = arg.get_short() {
name.push_str(&format!("-{}", short));
}
if let Some(long) = arg.get_long() {
if !name.is_empty() {
name.push_str(", ");
}
name.push_str(&format!("--{}", long));
}
if name.is_empty() {
name = arg.get_id().to_string();
}
name
}
fn positional_name(arg: &clap::Arg) -> String {
arg.get_value_names()
.and_then(|names| names.first())
.map(|name| name.to_string())
.unwrap_or_else(|| arg.get_id().to_string())
}
fn default_value(arg: &clap::Arg) -> Option<String> {
let defaults = arg.get_default_values();
if defaults.is_empty() {
return None;
}
Some(
defaults
.iter()
.map(|value| value.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", "),
)
}
fn possible_values(arg: &clap::Arg) -> Vec<String> {
arg.get_possible_values()
.iter()
.filter(|value| !value.is_hide_set())
.map(|value| value.get_name().to_string())
.collect()
}
fn option_row(name: String, arg: &clap::Arg) -> OptionData {
OptionData {
name,
help: arg.get_help().map(|s| s.to_string()).unwrap_or_default(),
short: arg.get_short(),
long: arg.get_long().map(|s| s.to_string()),
default: default_value(arg),
possible_values: possible_values(arg),
}
}
fn group_by_heading(rows: Vec<(Option<String>, OptionData)>) -> Vec<Group<OptionData>> {
let mut by_heading: BTreeMap<Option<String>, Vec<OptionData>> = BTreeMap::new();
for (heading, row) in rows {
by_heading.entry(heading).or_default().push(row);
}
by_heading
.into_iter()
.map(|(title, items)| Group {
title,
help: None,
items,
})
.collect()
}
fn section_names(groups: &[Group<OptionData>]) -> Vec<&str> {
groups
.iter()
.flat_map(|group| group.items.iter().map(|item| item.name.as_str()))
.collect()
}
fn only_the_help_word(subs: &[&Command]) -> bool {
matches!(subs, [single] if single.get_name() == "help")
}
pub(crate) fn extract_help_data(
cmd: &Command,
command_groups: Option<&[CommandGroup]>,
length: HelpLength,
) -> HelpData {
extract(cmd, command_groups, length, None)
}
pub(crate) fn extract_help_data_with_topics(
cmd: &Command,
registry: &TopicRegistry,
command_groups: Option<&[CommandGroup]>,
length: HelpLength,
) -> HelpData {
extract(cmd, command_groups, length, Some(registry))
}
fn extract(
cmd: &Command,
command_groups: Option<&[CommandGroup]>,
length: HelpLength,
registry: Option<&TopicRegistry>,
) -> HelpData {
let name = cmd.get_name().to_string();
let about = match length {
HelpLength::Long => cmd.get_long_about().or_else(|| cmd.get_about()),
HelpLength::Short => cmd.get_about(),
}
.map(|s| s.to_string())
.unwrap_or_default();
let usage = cmd
.clone()
.render_usage()
.to_string()
.strip_prefix("Usage: ")
.unwrap_or(&cmd.clone().render_usage().to_string())
.to_string();
let topics = registry
.map(|registry| registry.list_topics())
.unwrap_or_default();
let mut subs: Vec<_> = cmd.get_subcommands().filter(|s| !s.is_hide_set()).collect();
subs.sort_by_key(|s| s.get_display_order());
if only_the_help_word(&subs) && topics.is_empty() {
subs.clear();
}
let subcommands = if let Some(groups) = command_groups {
extract_grouped_subcommands(&subs, groups)
} else {
extract_default_subcommands(&subs)
};
let subcommands_width = resolve_name_column(
&subcommands
.iter()
.flat_map(|group| {
group
.items
.iter()
.filter(|item| !item.separator)
.map(|item| item.name.as_str())
})
.collect::<Vec<_>>(),
);
let mut args: Vec<_> = cmd.get_arguments().filter(|a| !a.is_hide_set()).collect();
args.sort_by_key(|a| a.get_display_order());
let (positionals, flags): (Vec<_>, Vec<_>) =
args.into_iter().partition(|arg| arg.is_positional());
let arguments = group_by_heading(
positionals
.into_iter()
.map(|arg| {
(
arg.get_help_heading().map(|s| s.to_string()),
option_row(positional_name(arg), arg),
)
})
.collect(),
);
let arguments_width = resolve_name_column(§ion_names(&arguments));
let options = group_by_heading(
flags
.into_iter()
.map(|arg| {
(
arg.get_help_heading().map(|s| s.to_string()),
option_row(flag_name(arg), arg),
)
})
.collect(),
);
let options_width = resolve_name_column(§ion_names(&options));
let learn_more: Vec<TopicListItem> = topics
.iter()
.map(|topic| TopicListItem {
name: topic.name.clone(),
title: topic.title.clone(),
})
.collect();
let learn_more_width = resolve_name_column(
&learn_more
.iter()
.map(|topic| topic.name.as_str())
.collect::<Vec<_>>(),
);
HelpData {
name,
about,
usage,
subcommands,
subcommands_width,
arguments,
arguments_width,
options,
options_width,
examples: String::new(),
learn_more,
learn_more_width,
}
}
fn subcommand_row(sub: &Command) -> Subcommand {
Subcommand {
name: sub.get_name().to_string(),
about: sub.get_about().map(|s| s.to_string()).unwrap_or_default(),
separator: false,
}
}
fn extract_default_subcommands(subs: &[&Command]) -> Vec<Group<Subcommand>> {
if subs.is_empty() {
return vec![];
}
vec![Group {
title: Some("Commands".to_string()),
help: None,
items: subs.iter().map(|sub| subcommand_row(sub)).collect(),
}]
}
fn extract_grouped_subcommands(
subs: &[&Command],
groups: &[CommandGroup],
) -> Vec<Group<Subcommand>> {
use std::collections::HashMap;
let mut sub_map: HashMap<&str, &Command> = subs.iter().map(|s| (s.get_name(), *s)).collect();
let mut result_groups: Vec<Group<Subcommand>> = Vec::new();
for group in groups {
let mut group_cmds = Vec::new();
for entry in &group.commands {
match entry {
None => {
group_cmds.push(Subcommand {
name: String::new(),
about: String::new(),
separator: true,
});
}
Some(cmd_name) => {
if let Some(sub) = sub_map.remove(cmd_name.as_str()) {
group_cmds.push(subcommand_row(sub));
}
}
}
}
if !group_cmds.is_empty() {
result_groups.push(Group {
title: Some(group.title.clone()),
help: group.help.clone(),
items: group_cmds,
});
}
}
if !sub_map.is_empty() {
let mut remaining: Vec<_> = sub_map.into_values().collect();
remaining.sort_by_key(|s| s.get_display_order());
result_groups.push(Group {
title: Some("Other".to_string()),
help: None,
items: remaining.iter().map(|sub| subcommand_row(sub)).collect(),
});
}
result_groups
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Arg;
fn extract_short(cmd: &Command) -> HelpData {
extract_help_data(cmd, None, HelpLength::Short)
}
#[test]
fn test_extract_basic() {
let cmd = Command::new("test").about("A test command");
let data = extract_short(&cmd);
assert_eq!(data.name, "test");
assert_eq!(data.about, "A test command");
}
#[test]
fn test_extract_subcommands() {
let cmd = Command::new("root")
.subcommand(Command::new("sub1").about("Sub 1"))
.subcommand(Command::new("sub2").about("Sub 2"));
let data = extract_short(&cmd);
assert_eq!(data.subcommands.len(), 1);
assert_eq!(data.subcommands[0].items.len(), 2);
}
#[test]
fn test_long_option_name_widens_column() {
let cmd = Command::new("root")
.arg(Arg::new("output").long("output").help("Output format"))
.arg(
Arg::new("output_file_path")
.long("output-file-path")
.help("Write output to file"),
);
let data = extract_short(&cmd);
assert_eq!(data.options_width, "--output-file-path".len());
}
#[test]
fn test_short_option_names_keep_floor_width() {
let cmd = Command::new("root")
.disable_help_flag(true)
.arg(Arg::new("out").long("out").help("Output"));
let data = extract_short(&cmd);
assert_eq!(data.options_width, NAME_COLUMN_MIN);
}
#[test]
fn test_name_column_measures_display_width_not_bytes() {
let cmd = Command::new("root")
.disable_help_flag(true)
.arg(Arg::new("wide").long("日本語オプション").help("Wide"));
let data = extract_short(&cmd);
assert_eq!(data.options_width, 18);
}
#[test]
fn test_grouped_subcommands_share_one_column() {
let cmd = Command::new("root")
.subcommand(Command::new("a-very-long-command-name").about("Long"))
.subcommand(Command::new("short").about("Short"));
let groups = vec![CommandGroup {
title: "Main".into(),
help: None,
commands: vec![Some("short".into())],
}];
let data = extract_help_data(&cmd, Some(&groups), HelpLength::Short);
assert_eq!(data.subcommands.len(), 2, "expected a Main and an Other");
assert_eq!(data.subcommands_width, "a-very-long-command-name".len());
}
#[test]
fn test_empty_subcommands() {
let cmd = Command::new("root");
let data = extract_short(&cmd);
assert!(data.subcommands.is_empty());
}
#[test]
fn test_short_length_uses_about_long_uses_long_about() {
let cmd = Command::new("root")
.about("Terse")
.long_about("The full story");
assert_eq!(extract_short(&cmd).about, "Terse");
assert_eq!(
extract_help_data(&cmd, None, HelpLength::Long).about,
"The full story"
);
}
#[test]
fn test_long_length_falls_back_to_about() {
let cmd = Command::new("root").about("Terse");
assert_eq!(
extract_help_data(&cmd, None, HelpLength::Long).about,
"Terse"
);
}
#[test]
fn test_option_carries_default_and_possible_values() {
let cmd = Command::new("root").disable_help_flag(true).arg(
Arg::new("output")
.long("output")
.default_value("auto")
.value_parser(["auto", "term", "text"])
.help("Output format"),
);
let data = extract_short(&cmd);
let opt = &data.options[0].items[0];
assert_eq!(opt.default.as_deref(), Some("auto"));
assert_eq!(opt.possible_values, vec!["auto", "term", "text"]);
}
#[test]
fn test_hidden_possible_values_left_out() {
let cmd = Command::new("root").disable_help_flag(true).arg(
Arg::new("mode").long("mode").value_parser([
clap::builder::PossibleValue::new("shown"),
clap::builder::PossibleValue::new("secret").hide(true),
]),
);
let data = extract_short(&cmd);
assert_eq!(data.options[0].items[0].possible_values, vec!["shown"]);
}
#[test]
fn test_positionals_land_in_arguments_not_options() {
let cmd = Command::new("root")
.disable_help_flag(true)
.arg(Arg::new("range").help("Git range to diff"))
.arg(Arg::new("staged").long("staged").help("Use the index"));
let data = extract_short(&cmd);
assert_eq!(data.arguments[0].items[0].name, "range");
assert_eq!(data.options[0].items[0].name, "--staged");
assert!(data
.options
.iter()
.all(|group| group.items.iter().all(|item| item.name != "range")));
}
#[test]
fn test_positional_uses_declared_value_name() {
let cmd = Command::new("root")
.disable_help_flag(true)
.arg(Arg::new("range").value_name("RANGE").help("A range"));
let data = extract_short(&cmd);
assert_eq!(data.arguments[0].items[0].name, "RANGE");
}
#[test]
fn test_arguments_and_options_columns_are_independent() {
let cmd = Command::new("root")
.disable_help_flag(true)
.arg(Arg::new("range").help("A range"))
.arg(
Arg::new("output_file_path")
.long("output-file-path")
.help("Write output to file"),
);
let data = extract_short(&cmd);
assert_eq!(data.arguments_width, NAME_COLUMN_MIN);
assert_eq!(data.options_width, "--output-file-path".len());
}
#[test]
fn test_help_only_commands_section_is_dropped() {
let cmd = Command::new("root")
.disable_help_subcommand(true)
.subcommand(Command::new("help").about("Print this message"));
let data = extract_short(&cmd);
assert!(
data.subcommands.is_empty(),
"a COMMANDS section listing only standout's own word is noise"
);
}
#[test]
fn test_help_only_commands_section_kept_when_topics_exist() {
use crate::topics::{Topic, TopicType};
let cmd = Command::new("root")
.disable_help_subcommand(true)
.subcommand(Command::new("help").about("Print this message"));
let mut registry = TopicRegistry::new();
registry.add_topic(Topic::new("Storage", "content", TopicType::Text, None));
let data = extract_help_data_with_topics(&cmd, ®istry, None, HelpLength::Short);
assert_eq!(
data.subcommands[0].items[0].name, "help",
"`help <topic>` is a real destination, so the word stays listed"
);
}
#[test]
fn test_help_alongside_real_commands_is_kept() {
let cmd = Command::new("root")
.disable_help_subcommand(true)
.subcommand(Command::new("help").about("Print this message"))
.subcommand(Command::new("build").about("Build it"));
let data = extract_short(&cmd);
assert_eq!(data.subcommands[0].items.len(), 2);
}
#[test]
fn test_topics_populate_learn_more() {
use crate::topics::{Topic, TopicType};
let cmd = Command::new("root");
let mut registry = TopicRegistry::new();
registry.add_topic(Topic::new(
"A Very Long Topic Name Here",
"content",
TopicType::Text,
None,
));
registry.add_topic(Topic::new("Short", "content", TopicType::Text, None));
let data = extract_help_data_with_topics(&cmd, ®istry, None, HelpLength::Short);
assert_eq!(data.learn_more.len(), 2);
assert_eq!(
data.learn_more_width,
data.learn_more
.iter()
.map(|topic| topic.name.len())
.max()
.unwrap()
);
}
}