use crate::missing::normalize_program_name;
use crate::schema::{ArgLevelSchema, ArgSchema, Schema, Subcommand};
use facet_core::Facet;
use heck::ToKebabCase;
use owo_colors::OwoColorize;
use owo_colors::Stream::Stdout;
use std::string::String;
use std::vec::Vec;
pub fn generate_help<T: Facet<'static>>(config: &HelpConfig) -> String {
generate_help_for_shape(T::SHAPE, config)
}
pub fn generate_help_for_shape(shape: &'static facet_core::Shape, config: &HelpConfig) -> String {
let schema = match Schema::from_shape(shape) {
Ok(s) => s,
Err(_) => {
let program_name = config
.program_name
.clone()
.or_else(|| {
std::env::args()
.next()
.map(|path| normalize_program_name(&path))
})
.unwrap_or_else(|| "program".to_string());
return format!(
"{}\n\n(Schema could not be built for this type)\n",
program_name
);
}
};
generate_help_for_subcommand(&schema, &[], config)
}
#[derive(Debug, Clone)]
pub struct HelpConfig {
pub program_name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub width: usize,
}
impl Default for HelpConfig {
fn default() -> Self {
Self {
program_name: None,
version: None,
description: None,
width: 80,
}
}
}
pub fn generate_help_for_subcommand(
schema: &Schema,
subcommand_path: &[String],
config: &HelpConfig,
) -> String {
let program_name = config
.program_name
.clone()
.or_else(|| {
std::env::args()
.next()
.map(|path| normalize_program_name(&path))
})
.unwrap_or_else(|| "program".to_string());
if subcommand_path.is_empty() {
return generate_help_from_schema(schema, &program_name, config);
}
let mut current_args = schema.args();
let mut command_path = vec![program_name.clone()];
for name in subcommand_path {
let sub = current_args
.subcommands()
.values()
.find(|s| s.effective_name() == name);
if let Some(sub) = sub {
command_path.push(sub.cli_name().to_string());
current_args = sub.args();
} else {
return generate_help_from_schema(schema, &program_name, config);
}
}
let mut final_sub: Option<&Subcommand> = None;
let mut args = schema.args();
for name in subcommand_path {
let sub = args
.subcommands()
.values()
.find(|s| s.effective_name() == name);
if let Some(sub) = sub {
final_sub = Some(sub);
args = sub.args();
}
}
generate_help_for_subcommand_level(current_args, final_sub, &command_path.join(" "), config)
}
fn generate_help_from_schema(schema: &Schema, program_name: &str, config: &HelpConfig) -> String {
let mut out = String::new();
if let Some(version) = &config.version {
out.push_str(&format!("{program_name} {version}\n"));
} else {
out.push_str(&format!("{program_name}\n"));
}
if let Some(summary) = schema.docs().summary() {
out.push('\n');
out.push_str(summary.trim());
out.push('\n');
}
if let Some(details) = schema.docs().details() {
for line in details.lines() {
out.push_str(line.trim());
out.push('\n');
}
}
if let Some(desc) = &config.description {
out.push('\n');
out.push_str(desc);
out.push('\n');
}
out.push('\n');
generate_arg_level_help(&mut out, schema.args(), program_name, config);
out
}
fn generate_help_for_subcommand_level(
args: &ArgLevelSchema,
subcommand: Option<&Subcommand>,
full_command: &str,
config: &HelpConfig,
) -> String {
let mut out = String::new();
out.push_str(&format!("{full_command}\n"));
if let Some(sub) = subcommand {
if let Some(summary) = sub.docs().summary() {
out.push('\n');
out.push_str(summary.trim());
out.push('\n');
}
if let Some(details) = sub.docs().details() {
for line in details.lines() {
out.push_str(line.trim());
out.push('\n');
}
}
}
if let Some(desc) = &config.description {
out.push('\n');
out.push_str(desc);
out.push('\n');
}
out.push('\n');
generate_arg_level_help(&mut out, args, full_command, config);
out
}
fn wrap_text(text: &str, indent: &str, max_width: usize) -> String {
let available = if max_width == 0 || max_width <= indent.len() {
let mut s = indent.to_string();
s.push_str(text);
return s;
} else {
max_width - indent.len()
};
let mut result = String::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.len() + 1 + word.len() <= available {
line.push(' ');
line.push_str(word);
} else {
result.push_str(indent);
result.push_str(&line);
result.push('\n');
line.clear();
line.push_str(word);
}
}
if !line.is_empty() {
result.push_str(indent);
result.push_str(&line);
}
result
}
fn generate_arg_level_help(
out: &mut String,
args: &ArgLevelSchema,
program_name: &str,
config: &HelpConfig,
) {
let mut positionals: Vec<&ArgSchema> = Vec::new();
let mut flags: Vec<&ArgSchema> = Vec::new();
for (_name, arg) in args.args().iter() {
if arg.kind().is_positional() {
positionals.push(arg);
} else {
flags.push(arg);
}
}
out.push_str(&format!("{}:\n ", "USAGE".yellow().bold()));
out.push_str(program_name);
if !flags.is_empty() {
out.push_str(" [OPTIONS]");
}
for pos in &positionals {
let name = pos.name().to_uppercase();
if pos.required() {
out.push_str(&format!(" <{name}>"));
} else {
out.push_str(&format!(" [{name}]"));
}
}
if args.has_subcommands() {
if args.subcommand_optional() {
out.push_str(" [COMMAND]");
} else {
out.push_str(" <COMMAND>");
}
}
out.push_str("\n\n");
if !positionals.is_empty() {
out.push_str(&format!("{}:\n", "ARGUMENTS".yellow().bold()));
for arg in &positionals {
write_arg_help(out, arg, config);
}
out.push('\n');
}
if !flags.is_empty() {
out.push_str(&format!("{}:\n", "OPTIONS".yellow().bold()));
for arg in &flags {
write_arg_help(out, arg, config);
}
out.push('\n');
}
if args.has_subcommands() {
out.push_str(&format!("{}:\n", "COMMANDS".yellow().bold()));
for sub in args.subcommands().values() {
write_subcommand_help(out, sub, config);
}
out.push('\n');
}
}
fn write_arg_help(out: &mut String, arg: &ArgSchema, config: &HelpConfig) {
out.push_str(" ");
let is_positional = arg.kind().is_positional();
if let Some(c) = arg.kind().short() {
out.push_str(&format!(
"{}, ",
format!("-{c}").if_supports_color(Stdout, |text| text.green())
));
} else {
out.push_str(" ");
}
let name = arg.name();
let is_counted = arg.kind().is_counted();
if is_positional {
out.push_str(&format!(
"{}",
format!("<{}>", name.to_uppercase()).if_supports_color(Stdout, |text| text.green())
));
} else {
let is_bool = arg.value().inner_if_option().is_bool();
let flag_str = if is_bool {
format!("--[no-]{}", name.to_kebab_case())
} else {
format!("--{}", name.to_kebab_case())
};
out.push_str(&format!(
"{}",
flag_str.if_supports_color(Stdout, |text| text.green())
));
if !is_counted && !arg.value().is_bool() {
let placeholder = if let Some(desc) = arg.label() {
desc.to_uppercase()
} else if let Some(variants) = arg.value().inner_if_option().enum_variants() {
variants.join(",")
} else {
arg.value().type_identifier().to_uppercase()
};
out.push_str(&format!(" <{}>", placeholder));
}
}
const DOC_INDENT: &str = " ";
if let Some(summary) = arg.docs().summary() {
out.push('\n');
out.push_str(&wrap_text(summary.trim(), DOC_INDENT, config.width));
}
if is_counted {
out.push('\n');
out.push_str(&wrap_text("[can be repeated]", DOC_INDENT, config.width));
}
out.push('\n');
}
fn write_subcommand_help(out: &mut String, sub: &Subcommand, config: &HelpConfig) {
out.push_str(" ");
out.push_str(&format!(
"{}",
sub.cli_name()
.if_supports_color(Stdout, |text| text.green())
));
if let Some(summary) = sub.docs().summary() {
out.push('\n');
out.push_str(&wrap_text(summary.trim(), " ", config.width));
}
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
use facet::Facet;
use figue_attrs as args;
#[derive(Facet)]
struct CommonArgs {
#[facet(args::named, crate::short = 'v')]
verbose: bool,
#[facet(args::named, crate::short = 'q')]
quiet: bool,
}
#[derive(Facet)]
struct ArgsWithFlatten {
#[facet(args::positional)]
input: String,
#[facet(flatten)]
common: CommonArgs,
}
#[test]
fn test_flatten_args_appear_in_help() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
assert!(
help.contains("--[no-]verbose"),
"help should contain --[no-]verbose from flattened CommonArgs"
);
assert!(help.contains("-v"), "help should contain -v short flag");
assert!(
help.contains("--[no-]quiet"),
"help should contain --[no-]quiet from flattened CommonArgs"
);
assert!(help.contains("-q"), "help should contain -q short flag");
assert!(
!help.contains("--common"),
"help should not show --common as a flag"
);
}
#[test]
fn test_flatten_docs_preserved() {
let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
assert!(
help.contains("verbose output"),
"help should contain verbose field doc"
);
assert!(
help.contains("quiet mode"),
"help should contain quiet field doc"
);
}
#[derive(Facet)]
struct ServeArgs {
#[facet(args::named)]
port: u16,
#[facet(args::named)]
host: String,
}
#[derive(Facet)]
struct TupleVariantArgs {
#[facet(args::subcommand)]
command: Option<TupleVariantCommand>,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum TupleVariantCommand {
Serve(ServeArgs),
}
#[test]
fn test_label_overrides_placeholder() {
#[derive(Facet)]
struct TDArgs {
#[facet(args::named, args::label = "PATH")]
input: std::path::PathBuf,
}
let schema = Schema::from_shape(TDArgs::SHAPE).unwrap();
let help = generate_help_for_subcommand(&schema, &[], &HelpConfig::default());
assert!(
help.contains("<PATH>"),
"help should use custom label placeholder"
);
}
#[test]
fn test_tuple_variant_fields_not_shown_as_option() {
let schema = Schema::from_shape(TupleVariantArgs::SHAPE).unwrap();
let help =
generate_help_for_subcommand(&schema, &["Serve".to_string()], &HelpConfig::default());
assert!(
help.contains("--port"),
"help should contain --port from ServeArgs"
);
assert!(
help.contains("--host"),
"help should contain --host from ServeArgs"
);
assert!(
!help.contains("--0"),
"help should NOT show --0 for tuple variant wrapper field"
);
assert!(
!help.contains("SERVEARGS"),
"help should NOT show SERVEARGS as an option value"
);
}
#[test]
fn test_long_doc_comment_wraps() {
#[derive(Facet)]
struct LongDocArgs {
#[facet(args::named)]
output: String,
}
let schema = Schema::from_shape(LongDocArgs::SHAPE).unwrap();
let config = HelpConfig {
width: 80,
..Default::default()
};
let help = generate_help_for_subcommand(&schema, &[], &config);
eprintln!("{help}");
for line in help.lines() {
let plain: String = line
.chars()
.fold((String::new(), false), |(mut s, in_esc), c| {
if in_esc {
if c == 'm' { (s, false) } else { (s, true) }
} else if c == '\x1b' {
(s, true)
} else {
s.push(c);
(s, false)
}
})
.0;
assert!(
plain.len() <= 80,
"line exceeds 80 columns ({} chars): {:?}",
plain.len(),
plain
);
}
}
}