standout-test 12.0.0

In-process test harness for applications built with the standout CLI framework
use clap::Command;
use serde::Serialize;
use serde_json::json;
use serial_test::serial;
use standout::cli::{
    App, AppFailure, CommandContextInput, Diagnostic, DiagnosticKind, ExitStatus, ExternalFailure,
    FnHandler, HandlerResult, HookError, HookPhase, Hooks, Output, RunErrorKind, Severity,
};
use standout::{EmbeddedTemplates, Representation};
use standout_test::TestHarness;
use std::collections::HashMap;

const TEMPLATES: &[(&str, &str)] = &[
    ("ok", "{{ message }}"),
    ("hook-fail", "{{ message }}"),
    ("render-fail", "{{ message }}"),
];

#[derive(Serialize)]
struct Unserializable {
    map: HashMap<(u8, u8), u8>,
}

fn command() -> Command {
    Command::new("app")
        .subcommand(Command::new("ok"))
        .subcommand(Command::new("fail"))
        .subcommand(Command::new("warn-fail"))
        .subcommand(Command::new("ranged"))
        .subcommand(Command::new("hook-fail"))
        .subcommand(Command::new("render-fail"))
        .subcommand(Command::new("app-fail"))
        .subcommand(Command::new("external"))
}

fn app() -> App {
    App::builder()
        .templates(EmbeddedTemplates::new(TEMPLATES, ""))
        .command_with(
            "ok",
            FnHandler::new(|_, _| Ok(Output::Render(json!({ "message": "ok" })))),
            |cfg| cfg,
        )
        .unwrap()
        .command_with(
            "fail",
            FnHandler::new(|_, _| -> HandlerResult<serde_json::Value> {
                Err(anyhow::anyhow!("the handler refused"))
            }),
            |cfg| cfg.structured_only(),
        )
        .unwrap()
        .command_with(
            "warn-fail",
            FnHandler::new(|_, ctx| -> HandlerResult<serde_json::Value> {
                ctx.warn("a soft warning");
                Err(anyhow::anyhow!("the handler refused"))
            }),
            |cfg| cfg.structured_only(),
        )
        .unwrap()
        .command_with(
            "ranged",
            FnHandler::new(|_, _| -> HandlerResult<serde_json::Value> {
                Err(Diagnostic::error("config line 2 does not parse")
                    .detail("expected `resource <name> <state>`")
                    .range("main.tfl", 2, 1)
                    .into())
            }),
            |cfg| cfg.structured_only(),
        )
        .unwrap()
        .command_with(
            "hook-fail",
            FnHandler::new(|_, _| Ok(Output::Render(json!({ "message": "unreachable" })))),
            |cfg| cfg,
        )
        .unwrap()
        .hooks(
            "hook-fail",
            Hooks::new().pre_dispatch(|_, _| {
                Err(HookError::pre_dispatch(
                    "Configuration validation failed: reviewers: at least one reviewer must be enabled",
                ))
            }),
        )
        .command_with(
            "render-fail",
            FnHandler::new(|_, _| {
                let mut map = HashMap::new();
                map.insert((1, 2), 3);
                Ok(Output::Render(Unserializable { map }))
            }),
            |cfg| cfg,
        )
        .unwrap()
        .command_with(
            "app-fail",
            FnHandler::new(|_, _| -> HandlerResult<serde_json::Value> {
                Err(AppFailure::new(3, "app: repository not found: demo/gamma\n")
                    .unwrap()
                    .into())
            }),
            |cfg| cfg.structured_only(),
        )
        .unwrap()
        .command_with(
            "external",
            FnHandler::new(|_, _| -> HandlerResult<serde_json::Value> {
                Err(ExternalFailure::new(128, "fatal: not a git repository\n")
                    .unwrap()
                    .into())
            }),
            |cfg| cfg.structured_only(),
        )
        .unwrap()
        .build()
        .unwrap()
}

fn run(args: &[&str]) -> standout_test::TestResult {
    TestHarness::new().run(&app(), command(), args)
}

#[test]
#[serial]
fn each_failure_class_is_a_json_document_on_stdout_with_stderr_empty() {
    let cases: [(&[&str], RunErrorKind, ExitStatus, &str); 5] = [
        (
            &["app", "--bogus", "--output", "json"],
            RunErrorKind::ClapUsage,
            ExitStatus::USAGE_ERROR,
            "unexpected argument '--bogus' found",
        ),
        (
            &["app", "fail", "--output", "json"],
            RunErrorKind::Handler,
            ExitStatus::FAILURE,
            "the handler refused",
        ),
        (
            &["app", "hook-fail", "--output", "json"],
            RunErrorKind::Hook(HookPhase::PreDispatch),
            ExitStatus::FAILURE,
            "Configuration validation failed: reviewers: at least one reviewer must be enabled",
        ),
        (
            &["app", "render-fail", "--output", "json"],
            RunErrorKind::Render,
            ExitStatus::FAILURE,
            "key must be a string",
        ),
        (
            &["app", "ranged", "--output", "json"],
            RunErrorKind::Handler,
            ExitStatus::FAILURE,
            "config line 2 does not parse",
        ),
    ];
    for (args, kind, status, summary) in cases {
        let result = run(args);
        result.assert_error_kind(kind);
        result.assert_exit_status(status);
        result.assert_stderr_empty();
        let document: serde_json::Value = serde_json::from_str(result.stdout())
            .unwrap_or_else(|e| panic!("{args:?}: stdout is not JSON ({e}):\n{}", result.stdout()));
        assert_eq!(document["type"], "diagnostic", "{args:?}");
        assert_eq!(document["schema_version"], 1, "{args:?}");
        assert_eq!(document["severity"], "error", "{args:?}");
        assert_eq!(
            document["kind"],
            serde_json::to_value(DiagnosticKind::from(kind)).unwrap(),
            "{args:?}"
        );
        assert!(
            document["summary"].as_str().unwrap().contains(summary),
            "{args:?}: {document}"
        );
        let diagnostic = result.expect_diagnostic();
        assert_eq!(diagnostic.kind, DiagnosticKind::from(kind));
        assert_eq!(diagnostic.severity, Severity::Error);
    }
}

#[test]
#[serial]
fn the_summary_is_the_error_text_without_the_prose_framing() {
    let handler = run(&["app", "fail", "--output", "json"]).expect_diagnostic();
    assert_eq!(handler.summary, "the handler refused");
    assert_eq!(handler.detail, "");
    assert_eq!(handler.range, None);

    let hook = run(&["app", "hook-fail", "--output", "json"]).expect_diagnostic();
    assert_eq!(
        hook.summary,
        "Configuration validation failed: reviewers: at least one reviewer must be enabled"
    );

    let usage = run(&["app", "--bogus", "--output", "json"]).expect_diagnostic();
    assert_eq!(usage.summary, "unexpected argument '--bogus' found");
    assert!(usage.detail.contains("Usage:"), "{}", usage.detail);
}

#[test]
#[serial]
fn a_handler_returned_diagnostic_keeps_its_detail_and_range() {
    for mode in [
        Representation::Json,
        Representation::Yaml,
        Representation::Csv,
    ] {
        let result = TestHarness::new()
            .output_mode(mode)
            .run(&app(), command(), ["app", "ranged"]);
        result.assert_error_kind(RunErrorKind::Handler);
        result.assert_stderr_empty();
        let diagnostic = result.expect_diagnostic();
        assert_eq!(diagnostic.summary, "config line 2 does not parse");
        assert_eq!(diagnostic.detail, "expected `resource <name> <state>`");
        let range = diagnostic.range.expect("the range survives");
        assert_eq!(range.filename, "main.tfl");
        assert_eq!(range.start.line, 2);
        assert_eq!(range.start.column, 1);
    }
}

#[test]
#[serial]
fn yaml_and_csv_carry_the_handler_error_as_their_own_document() {
    let yaml = TestHarness::new().output_mode(Representation::Yaml).run(
        &app(),
        command(),
        ["app", "fail"],
    );
    yaml.assert_stderr_empty();
    assert!(
        yaml.stdout().starts_with("type: diagnostic\n"),
        "{}",
        yaml.stdout()
    );
    assert!(
        yaml.stdout().contains("kind: handler\n"),
        "{}",
        yaml.stdout()
    );
    assert_eq!(yaml.expect_diagnostic().summary, "the handler refused");

    let csv =
        TestHarness::new()
            .output_mode(Representation::Csv)
            .run(&app(), command(), ["app", "fail"]);
    csv.assert_stderr_empty();
    assert_eq!(
        csv.stdout(),
        "type,schema_version,severity,kind,summary,detail,range_filename,range_line,range_column\n\
         diagnostic,1,error,handler,the handler refused,,,,\n"
    );
    assert_eq!(csv.expect_diagnostic().kind, DiagnosticKind::Handler);

    let ranged = TestHarness::new().output_mode(Representation::Csv).run(
        &app(),
        command(),
        ["app", "ranged"],
    );
    assert!(
        ranged.stdout().ends_with(",main.tfl,2,1\n"),
        "{}",
        ranged.stdout()
    );
}

#[test]
#[serial]
fn the_argv_scan_finds_the_mode_on_either_side_of_a_usage_error() {
    for args in [
        &["app", "--output", "json", "--bogus"][..],
        &["app", "--bogus", "--output", "json"][..],
        &["app", "--bogus", "--output=json"][..],
        &["app", "--output", "text", "--bogus", "--output", "json"][..],
        &["app", "fail", "--bogus", "--output", "json"][..],
    ] {
        let result = run(args);
        result.assert_error_kind(RunErrorKind::ClapUsage);
        result.assert_exit_status(ExitStatus::USAGE_ERROR);
        assert!(result.stderr().is_empty(), "{args:?}: {}", result.stderr());
        assert_eq!(
            result.expect_diagnostic().kind,
            DiagnosticKind::ClapUsage,
            "{args:?}"
        );
    }
}

#[test]
#[serial]
fn a_malformed_output_value_stays_a_prose_usage_error() {
    for args in [
        &["app", "--output", "jsn", "--bogus"][..],
        &["app", "--bogus", "--output", "jsn"][..],
        &["app", "fail", "--output", "jsn"][..],
    ] {
        let result = run(args);
        result.assert_error_kind(RunErrorKind::ClapUsage);
        result.assert_exit_status(ExitStatus::USAGE_ERROR);
        assert_eq!(result.stdout(), "", "{args:?}");
        assert!(
            result.stderr().starts_with("error:"),
            "{args:?}: {}",
            result.stderr()
        );
        assert_eq!(result.diagnostic(), None, "{args:?}");
    }
}

#[test]
#[serial]
fn human_modes_keep_prose_on_stderr_and_stdout_empty() {
    for mode in [
        Representation::Human,
        Representation::Human,
        Representation::Human,
        Representation::TermDebug,
    ] {
        let result =
            TestHarness::new()
                .output_mode(mode)
                .run(&app(), command(), ["app", "hook-fail"]);
        result.assert_exit_status(ExitStatus::FAILURE);
        assert_eq!(result.stdout(), "", "{mode:?}");
        assert_eq!(
            result.stderr_plain(),
            "Error: hook error (pre-dispatch): Configuration validation failed: reviewers: at \
             least one reviewer must be enabled\n",
            "{mode:?}"
        );
        assert_eq!(result.diagnostic(), None, "{mode:?}");
    }
    let usage = run(&["app", "--bogus"]);
    assert_eq!(usage.stdout(), "");
    assert!(
        usage.stderr().starts_with("error: unexpected argument"),
        "{}",
        usage.stderr()
    );
}

#[test]
#[serial]
fn warnings_stay_prose_on_stderr_beside_the_stdout_document() {
    let result = run(&["app", "warn-fail", "--output", "json"]);
    result.assert_exit_status(ExitStatus::FAILURE);
    assert_eq!(result.expect_diagnostic().summary, "the handler refused");
    result.assert_stderr_contains("a soft warning");
    assert!(
        !result.stderr().contains("Error:"),
        "the failure itself must not reach stderr:\n{}",
        result.stderr()
    );
}

#[test]
#[serial]
fn owner_declared_failures_keep_their_bytes_and_add_the_document() {
    let app_failure = run(&["app", "app-fail", "--output", "json"]);
    assert_eq!(
        app_failure.exit_status(),
        Some(AppFailure::new(3, "").unwrap().exit_status())
    );
    assert_eq!(
        app_failure.stderr(),
        "app: repository not found: demo/gamma\n"
    );
    let diagnostic = app_failure.expect_diagnostic();
    assert_eq!(diagnostic.kind, DiagnosticKind::App);
    assert_eq!(diagnostic.summary, "app: repository not found: demo/gamma");
    assert_eq!(diagnostic.detail, "app: repository not found: demo/gamma\n");

    let external = run(&["app", "external", "--output", "yaml"]);
    assert_eq!(external.stderr(), "fatal: not a git repository\n");
    let diagnostic = external.expect_diagnostic();
    assert_eq!(diagnostic.kind, DiagnosticKind::External);
    assert_eq!(diagnostic.detail, "fatal: not a git repository\n");

    let human = run(&["app", "app-fail"]);
    assert_eq!(human.stdout(), "");
    assert_eq!(human.stderr(), "app: repository not found: demo/gamma\n");
}

#[test]
#[serial]
fn a_success_is_never_a_diagnostic() {
    let result = run(&["app", "ok", "--output", "json"]);
    result.assert_success();
    assert_eq!(result.diagnostic(), None);
    assert_eq!(
        serde_json::from_str::<serde_json::Value>(result.stdout()).unwrap()["message"],
        "ok"
    );
}