standout-test 9.0.0

In-process test harness for applications built with the standout CLI framework
use crate::page::{find_row, rows, takes_values, visible_args, ClapJoint};
use crate::TestHarness;
use clap::builder::{PossibleValue, PossibleValuesParser};
use clap::{Arg, ArgAction, Command};
use proptest::prelude::*;
use serde_json::json;
use standout::cli::FnHandler;
use standout::cli::{App, Output};
use standout::EmbeddedTemplates;

const TEMPLATES: &[(&str, &str)] = &[("stat", "stat")];
const ADVERSARIAL: [&str; 7] = [
    "key:value",
    "key: value",
    "a, b",
    "plain text",
    "[a b]",
    "foo\"bar",
    "a,b",
];
const CONTROL: [&str; 4] = [
    "line\nbreak",
    "tab\tstop",
    "back\\slash here",
    "esc \u{1b}del\u{7f}",
];
const STANDOUT_REPRESENTABLE: [&str; 4] = ["key:value", "plain text", "[a b]", "foo\"bar"];
fn sorted(mut values: Vec<String>) -> Vec<String> {
    values.sort();
    values
}
fn known_possible_values(arg: &Arg) -> Vec<String> {
    arg.get_possible_values()
        .iter()
        .filter(|value| !value.is_hide_set())
        .map(|value| value.get_name().to_string())
        .collect()
}
fn known_defaults(arg: &Arg) -> Vec<String> {
    arg.get_default_values()
        .iter()
        .map(|value| value.to_string_lossy().into_owned())
        .collect()
}
#[track_caller]
fn assert_page_roundtrips(page: &str, cmd: &Command) {
    let mut built = cmd.clone();
    built.build();
    let rows = rows(page);
    for arg in visible_args(&built).filter(|arg| takes_values(arg)) {
        let row = find_row(&rows, arg)
            .unwrap_or_else(|| panic!("no row for `{}` on the page:\n{page}", arg.get_id()));
        assert_eq!(
            sorted(row.possible_value_names(arg)),
            sorted(known_possible_values(arg)),
            "`{}`: the decoded possible values must equal clap's own set \
             exactly\n--- page ---\n{page}\n------------",
            arg.get_id()
        );
        assert_eq!(
            sorted(row.labelled_values("default:", ClapJoint::Spaces)),
            sorted(known_defaults(arg)),
            "`{}`: the decoded defaults must equal clap's own set \
             exactly\n--- page ---\n{page}\n------------",
            arg.get_id()
        );
    }
}
fn inline_clauses() -> Command {
    Command::new("notes")
        .about("Keep short notes")
        .term_width(0)
        .arg(
            Arg::new("format")
                .long("format")
                .value_name("FORMAT")
                .action(ArgAction::Set)
                .default_value("plain text")
                .value_parser(ADVERSARIAL)
                .help("How to print the notes"),
        )
        .arg(
            Arg::new("tag")
                .long("tag")
                .value_name("TAG")
                .action(ArgAction::Set)
                .default_value("key:value")
                .help("A colon is the value's own"),
        )
        .arg(
            Arg::new("file")
                .long("file")
                .value_name("FILE")
                .action(ArgAction::Set)
                .default_value("[a b]")
                .help("The bracket belongs to the quoted value"),
        )
        .arg(
            Arg::new("quote")
                .long("quote")
                .value_name("Q")
                .action(ArgAction::Set)
                .default_value("foo\"bar")
                .help("A mid-word quote is a literal"),
        )
        .arg(
            Arg::new("multi")
                .long("multi")
                .value_name("V")
                .action(ArgAction::Append)
                .default_values(["plain text", "a, b"])
                .help("Clap space-joins a defaults list"),
        )
        .arg(
            Arg::new("escape")
                .long("escape")
                .value_name("E")
                .action(ArgAction::Append)
                .default_values(CONTROL)
                .value_parser(CONTROL)
                .help("Debug escapes decode back to their characters"),
        )
}
fn bulleted_values() -> Command {
    Command::new("notes").about("Keep short notes").arg(
        Arg::new("format")
            .long("format")
            .value_name("FORMAT")
            .action(ArgAction::Set)
            .value_parser(
                ADVERSARIAL
                    .iter()
                    .map(|value| PossibleValue::new(*value))
                    .chain([PossibleValue::new("json").help("One note per line")])
                    .collect::<Vec<_>>(),
            )
            .help("How to print the notes"),
    )
}
#[test]
fn claps_pages_decode_back_to_claps_own_values() {
    for cmd in [inline_clauses(), bulleted_values()] {
        let mut built = cmd.clone();
        built.build();
        for page in [
            built.render_help().to_string(),
            built.render_long_help().to_string(),
        ] {
            assert_page_roundtrips(&page, &cmd);
        }
    }
}
fn stat_app() -> App {
    App::builder()
        .templates(EmbeddedTemplates::new(TEMPLATES, ""))
        .help_handling(true)
        .command_with(
            "stat",
            FnHandler::new(|_m, _ctx| Ok(Output::Render(json!({})))),
            |cfg| cfg,
        )
        .unwrap()
        .build()
        .unwrap()
}
fn standout_pair() -> (App, Command) {
    let app = stat_app();
    let cmd = Command::new("notes")
        .about("Keep short notes")
        .arg(
            Arg::new("format")
                .long("format")
                .value_name("FORMAT")
                .action(ArgAction::Set)
                .default_value("plain text")
                .value_parser(STANDOUT_REPRESENTABLE)
                .help("How to print the notes"),
        )
        .arg(
            Arg::new("tag")
                .long("tag")
                .value_name("TAG")
                .action(ArgAction::Set)
                .default_value("key:value")
                .help("A colon is the value's own"),
        )
        .arg(
            Arg::new("file")
                .long("file")
                .value_name("FILE")
                .action(ArgAction::Set)
                .default_value("[a b]")
                .help("Standout never brackets its clause, so the value keeps its own"),
        )
        .arg(
            Arg::new("quote")
                .long("quote")
                .value_name("Q")
                .action(ArgAction::Set)
                .default_value("foo\"bar")
                .help("A mid-word quote is a literal"),
        )
        .subcommand(Command::new("stat").about("Summarize the notes"));
    (app, cmd)
}
#[test]
#[serial_test::serial]
fn standouts_page_decodes_back_to_claps_own_values() {
    let (app, cmd) = standout_pair();
    let result = TestHarness::new()
        .text_output()
        .run(&app, cmd.clone(), ["notes", "--help"]);
    result.assert_success();
    let page = result.stdout_plain();
    assert!(
        page.contains("possible values: key:value, plain text, [a b], foo\"bar"),
        "the page only tests standout's grammar if it spells the list raw and \
         unquoted:\n{page}"
    );
    assert_page_roundtrips(&page, &cmd);
}
fn hazard_char() -> impl Strategy<Value = char> {
    prop_oneof![
        proptest::char::any(),
        proptest::sample::select(vec![
            ' ', ',', '"', '\\', '\n', '\t', '[', ']', ':', '=', '.', 'a',
        ]),
    ]
}
fn clap_statable(value: &str) -> bool {
    let raw = !value.is_empty() && !value.contains(char::is_whitespace);
    let lower = value.to_ascii_lowercase();
    !(raw && (value.contains(']') || value.starts_with('"') || value.chars().any(char::is_control)))
        && !lower.contains("default:")
        && !lower.contains("possible values:")
}
fn clap_value() -> impl Strategy<Value = String> {
    proptest::collection::vec(hazard_char(), 0..8)
        .prop_map(String::from_iter)
        .prop_filter("the page cannot state the value unambiguously", |value| {
            clap_statable(value)
        })
}
fn standout_value() -> impl Strategy<Value = String> {
    proptest::collection::vec(
        prop_oneof![
            proptest::char::range('a', 'z'),
            proptest::sample::select(vec![' ', ':', '"', '\\', '.', '-', '=', 'é']),
        ],
        1..8,
    )
    .prop_map(String::from_iter)
    .prop_filter("the grammar cannot state the value", |value| {
        value.trim() == value
            && !value.ends_with('\\')
            && !value.contains("default:")
            && !value.contains("possible values:")
    })
}
proptest! {
    #[test]
    fn any_statable_value_round_trips_through_claps_page(
        defaults in proptest::collection::vec(clap_value(), 1..3),
        choices in proptest::collection::hash_set(clap_value(), 1..3),
    ) {
        let cmd = Command::new("notes")
            .term_width(0)
            .arg(
                Arg::new("field")
                    .long("field")
                    .value_name("F")
                    .action(ArgAction::Append)
                    .default_values(defaults),
            )
            .arg(
                Arg::new("choice")
                    .long("choice")
                    .value_name("C")
                    .action(ArgAction::Set)
                    .value_parser(PossibleValuesParser::new(
                        choices.into_iter().collect::<Vec<_>>(),
                    )),
            );
        let mut built = cmd.clone();
        built.build();
        for page in [
            built.render_help().to_string(),
            built.render_long_help().to_string(),
        ] {
            assert_page_roundtrips(&page, &cmd);
        }
    }
}
proptest! {
    #![proptest_config(ProptestConfig::with_cases(48))]
    #[test]
    #[serial_test::serial]
    fn any_representable_value_round_trips_through_standouts_page(
        default in standout_value(),
        choices in proptest::collection::hash_set(standout_value(), 1..3),
    ) {
        let app = stat_app();
        let cmd = Command::new("notes")
            .about("Keep short notes")
            .arg(
                Arg::new("field")
                    .long("field")
                    .value_name("F")
                    .action(ArgAction::Set)
                    .default_value(default),
            )
            .arg(
                Arg::new("choice")
                    .long("choice")
                    .value_name("C")
                    .action(ArgAction::Set)
                    .value_parser(PossibleValuesParser::new(
                        choices.into_iter().collect::<Vec<_>>(),
                    )),
            )
            .subcommand(Command::new("stat").about("Summarize the notes"));
        let result = TestHarness::new()
            .text_output()
            .run(&app, cmd.clone(), ["notes", "--help"]);
        result.assert_success();
        assert_page_roundtrips(&result.stdout_plain(), &cmd);
    }
}