standout 10.0.0

Styled CLI template rendering with automatic terminal detection
use clap::{Arg, Command};
use standout::cli::{render_help, HelpConfig, HelpLength, HelpResult};
use standout::OutputMode;
use standout_fixtures::{downstream, Fixture};

fn page(fixture: &Fixture, args: &[&str]) -> String {
    match fixture.app().get_matches_from(
        fixture.command(),
        args,
        &standout::InputSources::from_process(),
    ) {
        HelpResult::Help(text) | HelpResult::PagedHelp(text) => text,
        other => panic!("expected rendered help, got: {other:?}"),
    }
}

fn help_for(args: &[&str]) -> String {
    page(&downstream().build(), args)
}

fn flat_help_for(args: &[&str]) -> String {
    page(&downstream().flat().build(), args)
}

fn row<'a>(output: &'a str, name: &str) -> &'a str {
    output
        .lines()
        .find(|line| line.trim_start().starts_with(name))
        .unwrap_or_else(|| panic!("no row for {name} in:\n{output}"))
}

fn description_column(output: &str, name: &str, description: &str) -> usize {
    let line = row(output, name);
    line.find(description)
        .unwrap_or_else(|| panic!("{name} row has no description {description:?}: {line:?}"))
}

#[test]
fn long_option_name_keeps_its_separator() {
    let output = help_for(&["lookma", "--help"]);
    let line = row(&output, "--output-file-path");

    assert!(
        line.contains("--output-file-path <PATH>  Write output"),
        "the longest option must keep a gap before its description: {line:?}"
    );
    assert!(
        !line.contains("--output-file-pathWrite"),
        "the reported collision is back: {line:?}"
    );
}

#[test]
fn every_option_description_starts_at_one_column() {
    let output = help_for(&["lookma", "--help"]);

    let columns: Vec<usize> = [
        ("--staged", "Diff the staged changes"),
        ("--output-file-path", "Write output"),
    ]
    .iter()
    .map(|(name, description)| description_column(&output, name, description))
    .collect();

    assert!(
        columns.iter().all(|column| *column == columns[0]),
        "options must share one column, got {columns:?}\n{output}"
    );
}

#[test]
fn arguments_and_options_columns_are_independent() {
    let output = help_for(&["lookma", "--help"]);

    let arguments = description_column(&output, "RANGE", "Git range");
    let options = description_column(&output, "--output-file-path", "Write output");

    assert!(
        arguments < options,
        "ARGUMENTS should keep the narrow column; got {arguments} and {options}\n{output}"
    );
}

#[test]
fn short_names_keep_the_floor_width() {
    let cmd = Command::new("app")
        .disable_help_flag(true)
        .about("App")
        .arg(Arg::new("out").long("out").help("Output"));

    let config = HelpConfig {
        output_mode: Some(OutputMode::Text),
        ..Default::default()
    };
    let output = render_help(&cmd, Some(config)).unwrap();

    assert_eq!(
        description_column(&output, "--out", "Output"),
        16,
        "{output}"
    );
}

#[test]
fn short_help_renders_about_and_long_help_renders_long_about() {
    let short = help_for(&["lookma", "-h"]);
    let long = help_for(&["lookma", "--help"]);

    assert!(short.contains("Diff a git range"), "{short}");
    assert!(
        !short.contains("Names a change the way a human would"),
        "-h must stay terse:\n{short}"
    );
    assert!(
        long.contains("Names a change the way a human would"),
        "--help must render long_about:\n{long}"
    );
}

#[test]
fn help_word_renders_long_about() {
    let word = help_for(&["lookma", "help"]);
    assert!(
        word.contains("Names a change the way a human would"),
        "{word}"
    );
}

#[test]
fn long_help_falls_back_to_about_when_no_long_about() {
    let cmd = Command::new("app")
        .disable_help_flag(true)
        .about("Only terse");

    let config = HelpConfig {
        output_mode: Some(OutputMode::Text),
        length: HelpLength::Long,
        ..Default::default()
    };
    let output = render_help(&cmd, Some(config)).unwrap();
    assert!(output.contains("Only terse"), "{output}");
}

#[test]
fn option_rows_carry_defaults_and_possible_values() {
    let output = help_for(&["lookma", "--help"]);

    assert!(output.contains("default: auto"), "{output}");
    assert!(
        output.contains("possible values: auto, term, text, term-debug, json, yaml, csv"),
        "{output}"
    );
}

#[test]
fn an_apps_own_enumerated_option_carries_its_default_and_values() {
    let output = help_for(&["lookma", "--help"]);

    assert!(output.contains("default: brief"), "{output}");
    assert!(
        output.contains("possible values: brief, full, none"),
        "{output}"
    );
}

#[test]
fn value_taking_options_render_their_metavars() {
    let output = help_for(&["lookma", "--help"]);

    assert!(output.contains("--threshold <RATIO>"), "{output}");
    assert!(output.contains("-c, --color <BOOL>"), "{output}");
    assert!(
        output.contains("--pattern <pattern>"),
        "options without an explicit value_name should keep clap's fallback metavar:\n{output}"
    );
}

#[test]
fn presence_flags_do_not_render_bool_values() {
    let output = help_for(&["lookma", "--help"]);
    let staged = row(&output, "--staged");

    assert!(
        !staged.contains('<'),
        "presence flags take no value:\n{output}"
    );
    assert!(
        !staged.contains("true") && !staged.contains("false"),
        "presence flags should not advertise parser-derived bool values:\n{output}"
    );
}

#[test]
fn default_and_values_lines_align_with_descriptions() {
    let output = help_for(&["lookma", "--help"]);

    let description = description_column(&output, "--output", "Output format");
    let default = row(&output, "default:").find("default:").unwrap();
    let values = row(&output, "possible values:")
        .find("possible values:")
        .unwrap();

    assert_eq!(default, description, "default line must hang:\n{output}");
    assert_eq!(values, description, "values line must hang:\n{output}");
}

fn section_line(output: &str, header: &str) -> usize {
    output
        .lines()
        .position(|line| line.trim() == header)
        .unwrap_or_else(|| panic!("no {header} section in:\n{output}"))
}

#[test]
fn positionals_get_their_own_section_before_options() {
    let output = help_for(&["lookma", "--help"]);

    let arguments = section_line(&output, "ARGUMENTS");
    let options = section_line(&output, "OPTIONS");
    assert!(
        arguments < options,
        "ARGUMENTS must precede OPTIONS:\n{output}"
    );

    let range = output
        .lines()
        .position(|line| line.trim_start().starts_with("RANGE"))
        .expect(&output);
    assert!(
        range > arguments && range < options,
        "the positional belongs in ARGUMENTS:\n{output}"
    );
}

#[test]
fn flat_cli_suppresses_a_help_only_commands_section() {
    let output = page(
        &downstream().flat().without_topics().build(),
        &["lookma", "--help"],
    );

    assert!(
        !output.lines().any(|line| line.trim() == "COMMANDS"),
        "a flat CLI has no commands to list:\n{output}"
    );
}

#[test]
fn registered_topics_keep_the_help_word_listed() {
    let output = flat_help_for(&["lookma", "--help"]);

    section_line(&output, "COMMANDS");
    assert!(
        row(&output, "help").contains("Print this message"),
        "the word must be listed so `help <topic>` is discoverable:\n{output}"
    );
}

#[test]
fn flat_cli_help_word_does_not_mention_subcommands() {
    let fixture = downstream().flat().build();
    let augmented = fixture.app().augment_command_with_help(fixture.command());
    let word = augmented
        .get_subcommands()
        .find(|sub| sub.get_name() == "help")
        .expect("the word is installed on this shape");

    assert_eq!(
        word.get_about().map(|about| about.to_string()),
        Some("Print this message".to_string())
    );
}

#[test]
fn nested_cli_help_word_still_mentions_subcommands() {
    let fixture = downstream().build();
    let augmented = fixture.app().augment_command_with_help(fixture.command());
    let word = augmented
        .get_subcommands()
        .find(|sub| sub.get_name() == "help")
        .expect("a root with subcommands always gets the word");

    assert_eq!(
        word.get_about().map(|about| about.to_string()),
        Some("Print this message or the help of the given subcommand(s)".to_string())
    );
}

#[test]
fn nested_cli_keeps_its_commands_section() {
    let output = help_for(&["lookma", "--help"]);

    section_line(&output, "COMMANDS");
    assert!(
        row(&output, "review").contains("Review a range hunk by hunk"),
        "{output}"
    );
}

#[test]
fn the_help_flag_clap_generates_gets_a_row() {
    let output = help_for(&["lookma", "--help"]);
    let line = row(&output, "-h, --help");

    assert!(
        line.contains("Print help"),
        "the row must carry clap's own help text: {line:?}"
    );
}

#[test]
fn the_version_flag_clap_generates_gets_a_row() {
    let cmd = Command::new("notes")
        .about("Keep short notes")
        .version("1.2.3")
        .disable_help_subcommand(true);

    let output = render_help(
        &cmd,
        Some(HelpConfig {
            output_mode: Some(OutputMode::Text),
            ..Default::default()
        }),
    )
    .expect("the themed page renders");

    assert!(
        row(&output, "-V, --version").contains("Print version"),
        "clap accepts `-V`/`--version`, so the page states them:\n{output}"
    );
}

#[test]
fn a_valueless_flag_states_no_default() {
    let output = help_for(&["lookma", "--help"]);
    let line = row(&output, "--staged");

    assert!(
        !output.contains("default: false"),
        "clap builds a `false` default onto a SetTrue flag and hides it from its \
         own page; the themed page hides it too: {line:?}\n{output}"
    );
}