standout 9.0.0

Styled CLI template rendering with automatic terminal detection
use clap::Command;
use console::Style;
use proptest::prelude::*;
use serde_json::{json, Value};
use standout::cli::{App, DispatchResult, Output, RunErrorKind};
use standout::{OutputMode, Theme};
use standout_test::invariants::{
    assert_no_unresolved_tag_markers_in_page, assert_styling_preserves_layout_in_pages,
};

fn output_mode_strategy() -> impl Strategy<Value = OutputMode> {
    prop_oneof![
        Just(OutputMode::Auto),
        Just(OutputMode::Term),
        Just(OutputMode::Text),
        Just(OutputMode::TermDebug),
        Just(OutputMode::Json),
        Just(OutputMode::Yaml),
        Just(OutputMode::Xml),
        Just(OutputMode::Csv),
    ]
}

const TEMPLATES: &[(&str, &str)] = &[
    ("plain", "{{ data }}"),
    ("titled", "[title]{{ data }}[/title]"),
    (
        "highlighted",
        "[highlight]Output: [title]{{ data }}[/title][/highlight]",
    ),
];

#[derive(Debug, Clone)]
struct TemplateCase {
    name: &'static str,
}

fn template_strategy() -> impl Strategy<Value = TemplateCase> {
    prop_oneof![
        Just(TemplateCase { name: "plain" }),
        Just(TemplateCase { name: "titled" }),
        Just(TemplateCase {
            name: "highlighted",
        }),
    ]
}

#[derive(Debug, Clone)]
struct ThemeCase {
    theme: Option<Theme>,
}

fn theme_strategy() -> impl Strategy<Value = ThemeCase> {
    prop_oneof![
        Just(ThemeCase { theme: None }),
        Just(ThemeCase {
            theme: Some(Theme::new()),
        }),
        Just(ThemeCase {
            theme: Some(Theme::new().add("title", Style::new().bold())),
        }),
        Just(ThemeCase {
            theme: Some(Theme::new().add("highlight", Style::new().cyan())),
        }),
        Just(ThemeCase {
            theme: Some(
                Theme::new()
                    .add("title", Style::new().bold())
                    .add("highlight", Style::new().cyan())
                    .add("error", Style::new().red().bold())
            ),
        }),
    ]
}

fn json_data_strategy() -> impl Strategy<Value = Value> {
    let leaf = prop_oneof![
        Just(Value::Null),
        any::<bool>().prop_map(Value::Bool),
        any::<f64>().prop_map(|f| json!(f)),
        "[a-zA-Z0-9]*".prop_map(Value::String),
    ];
    leaf.prop_recursive(4, 64, 10, |inner| {
        prop_oneof![
            prop::collection::vec(inner.clone(), 0..10).prop_map(Value::Array),
            prop::collection::hash_map("[a-zA-Z0-9]*", inner, 0..10)
                .prop_map(|m| { Value::Object(m.into_iter().collect()) })
        ]
    })
}

fn dispatch(
    mode: OutputMode,
    theme: &ThemeCase,
    template: &TemplateCase,
    data: &Value,
) -> DispatchResult {
    let payload = if mode.is_structured() {
        data.clone()
    } else {
        json!({ "data": data })
    };
    let builder = App::builder()
        .templates(standout::EmbeddedTemplates::new(TEMPLATES, ""))
        .command_with(
            "test",
            standout::cli::FnHandler::new(move |_m, _ctx| Ok(Output::Render(payload.clone()))),
            |cfg| cfg.template_name(template.name),
        )
        .unwrap();

    let builder = match &theme.theme {
        Some(t) => builder.theme(t.clone()),
        None => builder,
    };

    let app = builder.build().expect("Failed to build app");
    let cmd = Command::new("app").subcommand(Command::new("test"));
    let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
    app.dispatch(matches, mode).into_outcome()
}

fn validate_structured_output(output: &str, mode: OutputMode) {
    match mode {
        OutputMode::Json => {
            let parsed: Result<Value, _> = serde_json::from_str(output);
            assert!(
                parsed.is_ok(),
                "JSON output should be parseable: {}",
                output
            );
        }
        OutputMode::Yaml => {
            let parsed: Result<Value, _> = serde_yaml::from_str(output);
            assert!(
                parsed.is_ok(),
                "YAML output should be parseable: {}",
                output
            );
        }
        OutputMode::Xml => {
            assert!(!output.is_empty(), "XML output should not be empty");
            let mut reader = quick_xml::Reader::from_str(output);
            loop {
                match reader.read_event() {
                    Ok(quick_xml::events::Event::Eof) => break,
                    Ok(_) => {}
                    Err(err) => panic!("XML output should parse: {err}\n{output}"),
                }
            }
        }
        OutputMode::Csv => {
            assert!(!output.is_empty(), "CSV output should not be empty");
            let mut reader = csv::ReaderBuilder::new()
                .has_headers(false)
                .flexible(false)
                .from_reader(output.as_bytes());
            for record in reader.records() {
                record.unwrap_or_else(|err| panic!("CSV output should parse: {err}\n{output}"));
            }
        }
        _ => {}
    }
}

fn assert_agrees_with_text_mode(
    styled_page: &str,
    theme: &ThemeCase,
    template: &TemplateCase,
    data: &Value,
) {
    let text = dispatch(OutputMode::Text, theme, template, data);
    let Some(text_page) = text.output() else {
        panic!("Text mode must render the same input: {text:?}");
    };
    assert_styling_preserves_layout_in_pages(&console::strip_ansi_codes(styled_page), text_page);
}

proptest! {
    #[test]
    fn rendering_upholds_the_invariants(
        mode in output_mode_strategy(),
        theme in theme_strategy(),
        template in template_strategy(),
        data in json_data_strategy()
    ) {
        let result = dispatch(mode, &theme, &template, &data);

        prop_assert!(
            result.is_handled() || result.is_error(),
            "expected Handled or Error, got {:?}",
            result
        );

        if mode.is_structured() {
            match mode {
                OutputMode::Json | OutputMode::Yaml => {
                    prop_assert!(
                        result.is_handled(),
                        "{:?} must serialize any generated value, got {:?}",
                        mode,
                        result
                    );
                }
                _ => {
                    prop_assert!(
                        result.is_handled()
                            || result.error_kind() == Some(RunErrorKind::Render),
                        "{:?} must serialize or refuse with a Render error, got {:?}",
                        mode,
                        result
                    );
                }
            }
            if let Some(output) = result.output() {
                validate_structured_output(output, mode);
            }
        } else {
            prop_assert!(
                result.is_handled(),
                "a valid template must render, got {:?}",
                result
            );

            let output = result.output().expect("a handled render has output");
            assert_no_unresolved_tag_markers_in_page(output);
            if matches!(mode, OutputMode::Term | OutputMode::Auto) {
                assert_agrees_with_text_mode(output, &theme, &template, &data);
            }
        }
    }

    #[test]
    fn no_theme_gap_corrupts_a_page_or_splits_the_modes(
        theme in theme_strategy(),
        template in template_strategy(),
        data in json_data_strategy()
    ) {
        let term = dispatch(OutputMode::Term, &theme, &template, &data);
        prop_assert!(term.is_handled(), "expected a render, got {:?}", term);
        let term_page = term.output().unwrap();
        assert_no_unresolved_tag_markers_in_page(term_page);
        assert_agrees_with_text_mode(term_page, &theme, &template, &data);
    }
}