standout 13.0.5

Styled CLI template rendering with automatic terminal detection
use std::cell::RefCell;
use std::rc::Rc;

use clap::{ArgMatches, Command};
use clapfig::{Clapfig, SearchPath};
use serde::{Deserialize, Serialize};
use serde_json::json;
use standout::cli::{
    App, CommandContext, CommandContextInput, FnHandler, HelpResult, Output, StreamSink,
    TermSettings,
};
use standout::{ColorPolicy, EmbeddedTemplates, InputSources, Representation, TemplateRef};
use standout_test::{serial, TestHarness, TestResult};

const STOPPED: &str = "apply stopped early";
const TEMPLATES: &[(&str, &str)] = &[("apply", "{{ note }}")];

#[derive(Debug, Clone, Serialize, Deserialize, clapfig::Schema)]
struct FixtureConfig {
    term: TermSettings,
}

type Seen = Rc<RefCell<Vec<(Representation, ColorPolicy)>>>;

fn apply_app(seen: Seen) -> App {
    App::builder()
        .templates(EmbeddedTemplates::new(TEMPLATES, ""))
        .config(
            Clapfig::typed::<FixtureConfig>()
                .app_name("hookapp")
                .search_paths(vec![SearchPath::Cwd]),
        )
        .term_settings(|config: &FixtureConfig| &config.term)
        .command_with(
            "apply",
            FnHandler::new(|_matches: &ArgMatches, _ctx: &CommandContext| {
                Ok(Output::Render(json!({ "note": STOPPED })))
            }),
            move |cfg| {
                cfg.template_name("apply").post_dispatch(
                    move |_matches: &ArgMatches, ctx: &CommandContext, data: serde_json::Value| {
                        seen.borrow_mut()
                            .push((ctx.representation(), ctx.color_policy()));
                        if ctx.representation().is_structured() {
                            ctx.warn(STOPPED);
                        }
                        Ok(data)
                    },
                )
            },
        )
        .unwrap()
        .build()
        .unwrap()
}

fn hookapp() -> Command {
    Command::new("hookapp").subcommand(Command::new("apply"))
}

fn run(
    harness: TestHarness,
    file: &str,
    args: &[&str],
) -> (Vec<(Representation, ColorPolicy)>, TestResult) {
    let seen = Seen::default();
    let app = apply_app(seen.clone());
    let result = harness
        .fixture("hookapp.toml", file.to_string())
        .run(&app, hookapp(), args);
    let observed = seen.borrow().clone();
    (observed, result)
}

#[test]
#[serial]
fn the_human_run_prints_the_sentence_once_and_pushes_no_warning() {
    let (seen, result) = run(TestHarness::new(), "", &["hookapp", "apply"]);

    result.assert_success();
    assert_eq!(seen, vec![(Representation::Human, ColorPolicy::Auto)]);
    assert_eq!(result.stdout_plain().matches(STOPPED).count(), 1);
    assert!(result.warnings().is_empty(), "{:?}", result.warnings());
}

#[test]
#[serial]
fn the_structured_run_pushes_the_sentence_as_a_warning() {
    let (seen, result) = run(
        TestHarness::new(),
        "",
        &["hookapp", "apply", "--output", "json"],
    );

    result.assert_success();
    assert_eq!(seen, vec![(Representation::Json, ColorPolicy::Auto)]);
    assert_eq!(result.warnings(), [STOPPED.to_string()]);
}

#[test]
#[serial]
fn every_representation_reaches_the_hook_as_the_run_resolved_it() {
    for (flag, expected) in [
        (None, Representation::Human),
        (Some("term-debug"), Representation::TermDebug),
        (Some("json"), Representation::Json),
        (Some("yaml"), Representation::Yaml),
        (Some("csv"), Representation::Csv),
        (Some("ndjson"), Representation::Ndjson),
    ] {
        let mut args = vec!["hookapp", "apply"];
        if let Some(flag) = flag {
            args.extend(["--output", flag]);
        }
        let (seen, result) = run(TestHarness::new(), "", &args);
        result.assert_success();
        assert_eq!(seen.first().map(|pair| pair.0), Some(expected), "{args:?}");
        assert_eq!(
            result.warnings().is_empty(),
            !expected.is_structured(),
            "{args:?}"
        );
    }
}

#[test]
#[serial]
fn the_configured_term_output_reaches_the_hook_when_no_flag_overrides_it() {
    let (configured, result) = run(
        TestHarness::new(),
        "[term]\noutput = \"json\"\n",
        &["hookapp", "apply"],
    );
    result.assert_success();
    assert_eq!(
        configured.first().map(|pair| pair.0),
        Some(Representation::Json)
    );

    let (flagged, result) = run(
        TestHarness::new(),
        "[term]\noutput = \"json\"\n",
        &["hookapp", "apply", "--output", "yaml"],
    );
    result.assert_success();
    assert_eq!(
        flagged.first().map(|pair| pair.0),
        Some(Representation::Yaml)
    );
}

#[test]
#[serial]
fn the_color_policy_the_hook_reads_is_the_one_the_run_resolved() {
    for (file, args, programmatic, expected) in [
        (
            "",
            vec!["hookapp", "apply"],
            ColorPolicy::Auto,
            ColorPolicy::Auto,
        ),
        (
            "",
            vec!["hookapp", "apply"],
            ColorPolicy::Never,
            ColorPolicy::Never,
        ),
        (
            "[term]\ncolor = \"never\"\n",
            vec!["hookapp", "apply"],
            ColorPolicy::Auto,
            ColorPolicy::Never,
        ),
        (
            "[term]\ncolor = \"never\"\n",
            vec!["hookapp", "apply", "--color", "always"],
            ColorPolicy::Auto,
            ColorPolicy::Always,
        ),
    ] {
        let (seen, result) = run(TestHarness::new().color(programmatic), file, &args);
        result.assert_success();
        assert_eq!(
            seen.first().map(|pair| pair.1),
            Some(expected),
            "{args:?} with {programmatic:?} and {file:?}"
        );
    }
}

struct Cwd(std::path::PathBuf);

impl Cwd {
    fn enter(dir: &std::path::Path) -> Self {
        let previous = std::env::current_dir().unwrap();
        std::env::set_current_dir(dir).unwrap();
        Self(previous)
    }
}

impl Drop for Cwd {
    fn drop(&mut self) {
        let _ = std::env::set_current_dir(&self.0);
    }
}

#[test]
#[serial]
fn run_command_gives_the_hook_the_same_resolved_pair() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join("hookapp.toml"),
        "[term]\noutput = \"json\"\n",
    )
    .unwrap();
    let _cwd = Cwd::enter(dir.path());

    let seen = Seen::default();
    let app = apply_app(seen.clone());
    let matches = match app.get_matches_from(
        hookapp(),
        ["hookapp", "apply"],
        &InputSources::from_process(),
    ) {
        HelpResult::Matches(matches) => matches,
        other => panic!("{other:?}"),
    };
    let sub = matches.subcommand_matches("apply").unwrap();

    app.run_command(
        "apply",
        sub,
        FnHandler::new(|_matches: &ArgMatches, _ctx: &CommandContext| {
            Ok(Output::Render(json!({ "note": STOPPED })))
        }),
        TemplateRef::Inline("{{ note }}".to_string()),
        ColorPolicy::Never,
        StreamSink::new(Vec::new()),
    )
    .unwrap();

    assert_eq!(
        seen.borrow().clone(),
        vec![(Representation::Json, ColorPolicy::Never)]
    );
}

#[test]
fn a_context_built_without_a_run_reports_the_defaults() {
    let ctx = CommandContext::default();
    assert_eq!(ctx.representation(), Representation::Human);
    assert_eq!(ctx.color_policy(), ColorPolicy::Auto);
}