cli-forge 0.4.0

Unified CLI framework: runtime command registration with styled output through one API.
Documentation
//! Help rendering.
//!
//! Auto-generated help for the application and for any command, rendered through
//! the same output layer as everything else — section headers are styled, and on
//! a pipe or under `NO_COLOR` the whole thing degrades to plain text. The
//! injectable [`App::help_header`](crate::App::help_header) /
//! [`App::help_footer`](crate::App::help_footer) wrap every page.
//!
//! Commands marked [`hidden`](crate::Command::hidden) or
//! [`requires_auth`](crate::Command::requires_auth) are omitted from the listings
//! (auth-gated commands surface once the auth seam lands in v0.5.0).

use crate::arg::{Arg, ArgKind};
use crate::command::Command;
use crate::parser::Cli;
use crate::style::style;

/// Render the top-level application help.
pub(crate) fn render_app(cli: &Cli) -> String {
    let mut out = String::new();
    push_header(&mut out, cli);

    let commands = visible(cli.commands);
    let width = app_column_width(&commands, cli.version.is_some());

    out.push_str(&heading("USAGE:"));
    out.push_str(&format!(" {} <command> [options]\n", cli.app_name));

    if !commands.is_empty() {
        out.push('\n');
        out.push_str(&heading("COMMANDS:"));
        out.push('\n');
        for command in &commands {
            push_row(&mut out, &invocation(command), about(command), width);
        }
    }

    out.push('\n');
    out.push_str(&heading("OPTIONS:"));
    out.push('\n');
    push_row(&mut out, "-h, --help", "show this help", width);
    if cli.version.is_some() {
        push_row(&mut out, "-V, --version", "show the version", width);
    }

    push_footer(&mut out, cli);
    out
}

/// Render help for one command, reached via `path` (the command names from the
/// app root down to and including this command).
pub(crate) fn render_command(cli: &Cli, path: &[&str], command: &Command) -> String {
    let mut out = String::new();
    push_header(&mut out, cli);

    if let Some(text) = command.about.as_deref() {
        out.push_str(text);
        out.push_str("\n\n");
    }

    let positionals: Vec<&Arg> = command.positionals().collect();
    let options: Vec<&Arg> = command
        .args
        .iter()
        .filter(|a| a.kind != ArgKind::Positional)
        .collect();
    let subcommands = visible(&command.subcommands);
    let width = command_column_width(&positionals, &options, &subcommands);

    // USAGE line.
    out.push_str(&heading("USAGE:"));
    out.push_str(&format!(" {} {}", cli.app_name, path.join(" ")));
    if !options.is_empty() {
        out.push_str(" [options]");
    }
    for arg in &positionals {
        out.push_str(&format!(" {}", positional_slot(arg)));
    }
    if !subcommands.is_empty() {
        out.push_str(" <command>");
    }
    out.push('\n');

    if !positionals.is_empty() {
        out.push('\n');
        out.push_str(&heading("ARGUMENTS:"));
        out.push('\n');
        for arg in &positionals {
            push_row(&mut out, &positional_slot(arg), help_text(arg), width);
        }
    }

    out.push('\n');
    out.push_str(&heading("OPTIONS:"));
    out.push('\n');
    for arg in &options {
        push_row(&mut out, &option_signature(arg), help_text(arg), width);
    }
    push_row(&mut out, "-h, --help", "show this help", width);

    if !subcommands.is_empty() {
        out.push('\n');
        out.push_str(&heading("COMMANDS:"));
        out.push('\n');
        for sub in &subcommands {
            push_row(&mut out, &invocation(sub), about(sub), width);
        }
    }

    push_footer(&mut out, cli);
    out
}

/// The commands to show: neither hidden nor (until the auth seam) auth-gated.
fn visible(commands: &[Command]) -> Vec<&Command> {
    commands
        .iter()
        .filter(|c| !c.hidden && !c.requires_auth)
        .collect()
}

fn push_header(out: &mut String, cli: &Cli) {
    if let Some(text) = cli.header {
        out.push_str(text);
        out.push_str("\n\n");
    }
}

fn push_footer(out: &mut String, cli: &Cli) {
    if let Some(text) = cli.footer {
        out.push('\n');
        out.push_str(text);
        out.push('\n');
    }
}

/// A bold section header (plain when color is off).
fn heading(label: &str) -> String {
    style(label).bold().render()
}

/// A two-column row, left-padded to `width`, with no trailing whitespace.
fn push_row(out: &mut String, left: &str, right: &str, width: usize) {
    if right.is_empty() {
        out.push_str(&format!("  {left}\n"));
    } else {
        out.push_str(&format!("  {left:<width$}  {right}\n"));
    }
}

/// A command's invocation column: its name plus any aliases.
fn invocation(command: &Command) -> String {
    if command.aliases.is_empty() {
        command.name.clone()
    } else {
        format!("{}, {}", command.name, command.aliases.join(", "))
    }
}

fn about(command: &Command) -> &str {
    command.about.as_deref().unwrap_or("")
}

fn help_text(arg: &Arg) -> &str {
    arg.help.as_deref().unwrap_or("")
}

/// A positional's usage slot: `<name>` when required, `[name]` otherwise.
fn positional_slot(arg: &Arg) -> String {
    if arg.required && arg.default.is_none() {
        format!("<{}>", arg.name)
    } else {
        format!("[{}]", arg.name)
    }
}

/// A flag/option's left column, e.g. `-o, --output <OUTPUT>` or `    --long`.
fn option_signature(arg: &Arg) -> String {
    let mut left = match arg.short {
        Some(c) => format!("-{c}, "),
        None => "    ".to_string(),
    };
    if let Some(long) = arg.long_name() {
        left.push_str(&format!("--{long}"));
    }
    if arg.kind == ArgKind::Option {
        left.push_str(&format!(" <{}>", arg.name.to_uppercase()));
    }
    left
}

fn app_column_width(commands: &[&Command], has_version: bool) -> usize {
    let mut width = commands
        .iter()
        .map(|c| invocation(c).len())
        .max()
        .unwrap_or(0);
    width = width.max("-h, --help".len());
    if has_version {
        width = width.max("-V, --version".len());
    }
    width
}

fn command_column_width(positionals: &[&Arg], options: &[&Arg], subcommands: &[&Command]) -> usize {
    let positional = positionals.iter().map(|a| positional_slot(a).len());
    let option = options.iter().map(|a| option_signature(a).len());
    let sub = subcommands.iter().map(|c| invocation(c).len());
    positional
        .chain(option)
        .chain(sub)
        .chain(std::iter::once("-h, --help".len()))
        .max()
        .unwrap_or(0)
}