standout 9.0.0

Styled CLI template rendering with automatic terminal detection
use clap::Subcommand;
use serde_json::json;
use standout::cli::{App, CommandContext, Dispatch, GroupBuilder, HandlerResult, Output};
use standout::{EmbeddedSource, OutputMode, TemplateResource};
use standout_test::TestHarness;

mod handlers {
    use super::*;
    use clap::ArgMatches;

    pub fn list(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
        Ok(Output::Silent)
    }

    pub fn add(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
        Ok(Output::Silent)
    }

    pub fn show_all(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
        Ok(Output::Silent)
    }

    pub fn export(
        _matches: &ArgMatches,
        _ctx: &CommandContext,
    ) -> HandlerResult<serde_json::Value> {
        Ok(Output::Render(json!({"name": "Ada"})))
    }

    pub fn download(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
        Ok(Output::Binary {
            data: vec![1, 2, 3],
            filename: "data.bin".to_string(),
        })
    }

    macro_rules! silent_handlers {
        ($($name:ident),* $(,)?) => {
            $(
                pub fn $name(_matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<()> {
                    Ok(Output::Silent)
                }
            )*
        };
    }

    silent_handlers!(
        x2fa,
        a1b2,
        sha256_sum,
        utf8_check,
        http_server,
        list_units,
        r#move
    );
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum BasicCommands {
    List,
    Add,
}

#[test]
fn test_basic_dispatch_compiles() {
    let config: fn(GroupBuilder) -> GroupBuilder =
        |builder| BasicCommands::dispatch_config()(builder);
    let _ = config;
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum MultiWordCommands {
    ShowAll,
}

#[test]
fn multi_word_variant_registers_the_kebab_case_name() {
    let builder = MultiWordCommands::dispatch_config()(GroupBuilder::new());
    assert!(builder.contains("show-all"));
    assert!(!builder.contains("show_all"));
}

/// Digit/acronym runs and raw identifiers are where a hand-rolled word
/// splitter and clap's `heck` conversion part ways, so this pins both derives
/// against each other rather than against a literal.
#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum ParityCommands {
    X2FA,
    A1B2,
    Sha256Sum,
    Utf8Check,
    HTTPServer,
    ListUnits,
    r#Move,
}

#[test]
fn derived_names_match_the_ones_clap_registers() {
    let clap_names: Vec<String> = ParityCommands::augment_subcommands(clap::Command::new("app"))
        .get_subcommands()
        .map(|sub| sub.get_name().to_string())
        .collect();
    assert_eq!(
        clap_names,
        [
            "x2fa",
            "a1b2",
            "sha256-sum",
            "utf8-check",
            "http-server",
            "list-units",
            "move"
        ]
    );

    let builder = ParityCommands::dispatch_config()(GroupBuilder::new());
    for name in &clap_names {
        assert!(builder.contains(name), "dispatch did not register `{name}`");
    }
}

/// Every `#[dispatch(...)]` on a variant speaks for that variant, so the
/// values of all of them apply rather than the first attribute winning.
#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum SplitAttrCommands {
    #[dispatch(name = "listing")]
    #[dispatch(default)]
    ListUnits,
}

#[test]
fn attributes_spread_over_several_dispatch_attrs_all_apply() {
    let builder = SplitAttrCommands::dispatch_config()(GroupBuilder::new());
    assert!(builder.contains("listing"));
    assert!(!builder.contains("list-units"));
    assert_eq!(builder.get_default_command(), Some("listing"));
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum RenamedCommands {
    #[dispatch(name = "ls", handler = handlers::list)]
    List,
    #[dispatch(name = "show", default)]
    ShowAll,
}

#[test]
fn variant_rename_replaces_the_derived_name() {
    let builder = RenamedCommands::dispatch_config()(GroupBuilder::new());
    assert!(builder.contains("ls"));
    assert!(builder.contains("show"));
    assert!(!builder.contains("list"));
    assert!(!builder.contains("show-all"));
    assert_eq!(builder.get_default_command(), Some("show"));
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum OverrideCommands {
    #[dispatch(handler = handlers::list)]
    Custom,
}

#[test]
fn test_handler_override_compiles() {
    let _ = OverrideCommands::dispatch_config();
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum TemplateCommands {
    #[dispatch(template_name = "custom.j2")]
    List,
}

#[test]
fn test_template_override_compiles() {
    let _ = TemplateCommands::dispatch_config();
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum TemplateAbsenceCommands {
    #[dispatch(template_name = "list")]
    Export,
    #[dispatch(silent)]
    Add,
    #[dispatch(binary, handler = handlers::download)]
    Download,
    #[dispatch(structured_only, handler = handlers::export)]
    ShowAll,
}

#[test]
fn test_template_absence_attributes_build_mixed_apps() {
    let app = App::builder()
        .templates(EmbeddedSource::<TemplateResource>::new(
            &[("list.jinja", "Hello {{ name }}")],
            "/path/that/does/not/exist",
        ))
        .commands(TemplateAbsenceCommands::dispatch_config())
        .unwrap()
        .build()
        .unwrap();
    let command = || {
        clap::Command::new("app")
            .subcommand(clap::Command::new("export"))
            .subcommand(clap::Command::new("add"))
            .subcommand(clap::Command::new("download"))
            .subcommand(clap::Command::new("show-all"))
    };

    let rendered = TestHarness::new()
        .text_output()
        .run(&app, command(), ["app", "export"]);
    rendered.assert_success();
    assert_eq!(rendered.stdout(), "Hello Ada");

    let silent = TestHarness::new().run(&app, command(), ["app", "add"]);
    silent.assert_success();
    assert_eq!(silent.stdout(), "");

    let binary = TestHarness::new().run(&app, command(), ["app", "download"]);
    binary.assert_success();
    assert_eq!(binary.binary(), Some((&[1, 2, 3][..], "data.bin")));

    let structured =
        TestHarness::new()
            .output_mode(OutputMode::Json)
            .run(&app, command(), ["app", "show-all"]);
    structured.assert_success();
    assert_eq!(structured.stdout(), "{\n  \"name\": \"Ada\"\n}");
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum SkipCommands {
    List,
    #[dispatch(skip)]
    Hidden,
}

#[test]
fn test_skip_attribute_compiles() {
    let _ = SkipCommands::dispatch_config();
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = handlers)]
enum DefaultCommands {
    #[dispatch(default)]
    List,
    Add,
}

#[test]
fn test_default_command_compiles() {
    let _ = DefaultCommands::dispatch_config();
}

#[test]
fn test_default_command_sets_default() {
    let builder = DefaultCommands::dispatch_config()(GroupBuilder::new());
    assert_eq!(builder.get_default_command(), Some("list"));
}

#[test]
fn test_default_command_registers_commands() {
    let builder = DefaultCommands::dispatch_config()(GroupBuilder::new());
    assert!(builder.contains("list"));
    assert!(builder.contains("add"));
}

mod input_handlers {
    use super::*;
    use clap::ArgMatches;
    use standout::cli::{CommandConfig, CommandContextInput};
    use standout::input::{ArgSource, InputChain, StdinSource};

    pub fn note_inputs<H>(config: CommandConfig<H>) -> CommandConfig<H> {
        config.input(
            "note",
            InputChain::<String>::new()
                .try_source(ArgSource::new("note"))
                .try_source(StdinSource::new())
                .validate(
                    |note: &String| !note.trim().is_empty(),
                    "note cannot be empty",
                ),
        )
    }

    pub fn write(_matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<serde_json::Value> {
        let note: &String = ctx.input("note").expect("note is resolved before dispatch");
        Ok(Output::Render(json!({ "note": note })))
    }
}

#[derive(Subcommand, Dispatch)]
#[dispatch(handlers = input_handlers)]
enum InputCommands {
    #[dispatch(inputs = input_handlers::note_inputs, structured_only)]
    Write { note: Option<String> },
}

fn input_command() -> clap::Command {
    clap::Command::new("app")
        .subcommand(clap::Command::new("write").arg(clap::Arg::new("note").long("note")))
}

#[test]
fn inputs_attribute_resolves_a_chain_for_a_derive_registered_command() {
    let app = App::builder()
        .commands(InputCommands::dispatch_config())
        .unwrap()
        .build()
        .unwrap();

    let from_arg = TestHarness::new().output_mode(OutputMode::Json).run(
        &app,
        input_command(),
        ["app", "write", "--note", "from the argument"],
    );
    from_arg.assert_success();
    assert!(from_arg.stdout().contains("from the argument"));

    let from_stdin = TestHarness::new()
        .output_mode(OutputMode::Json)
        .piped_stdin("from stdin\n")
        .run(&app, input_command(), ["app", "write"]);
    from_stdin.assert_success();
    assert!(from_stdin.stdout().contains("from stdin"));

    let rejected = TestHarness::new().output_mode(OutputMode::Json).run(
        &app,
        input_command(),
        ["app", "write", "--note", "   "],
    );
    rejected.assert_error_contains("note cannot be empty");
}