yatera 0.2.0

Yet another Tera CLI
use std::io::Write;
use std::process::{Command, Output, Stdio};

fn render(template: &str, args: &[&str]) -> Output {
    render_with_env(template, args, &[])
}

fn render_with_env(template: &str, args: &[&str], env: &[(&str, &str)]) -> Output {
    let mut child = Command::new(env!("CARGO_BIN_EXE_tera"))
        .args(args)
        .env_remove("YATERA_TEST_CONTEXT")
        .envs(env.iter().copied())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    child
        .stdin
        .take()
        .unwrap()
        .write_all(template.as_bytes())
        .unwrap();
    child.wait_with_output().unwrap()
}

fn assert_render(template: &str, args: &[&str], expected: &str) {
    let output = render(template, args);
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
    assert!(output.stderr.is_empty());
}

#[test]
fn renders_strings_and_preserves_output() {
    assert_render(
        "{{ first }}|{{ second }}|{{ empty }}",
        &["first=你好 <&>", "second=a=b", "empty="],
        "你好 <&>|a=b|",
    );
    assert_render("{{ value }}", &["value=first", "value=last"], "last");
    assert_render("unchanged\n", &[], "unchanged\n");
    assert_render("", &[], "");
}

#[test]
fn renders_named_variable_flags() {
    assert_render(
        "{{ first }}|{{ second }}|{{ empty }}|{{ number is string }}",
        &[
            "--var",
            "first",
            "Hello World",
            "--arg",
            "second",
            "a=b",
            "--var",
            "empty",
            "",
            "--arg",
            "number",
            "42",
        ],
        "Hello World|a=b||true",
    );
    assert_render(
        "{{ name }}|{{ other }}",
        &[
            "--json",
            r#"{"name":"context","other":"kept"}"#,
            "--var",
            "name",
            "first",
            "name=positional",
            "--arg",
            "name",
            "last",
        ],
        "last|kept",
    );
    for args in [
        vec!["--var"],
        vec!["--arg", "name"],
        vec!["--var", "", "value"],
        vec!["--var", "name", "--arg", "other", "value"],
    ] {
        let output = render("", &args);
        assert!(!output.status.success(), "{args:?}");
        assert!(output.stdout.is_empty());
        assert!(!output.stderr.is_empty());
    }
}

const CONTEXTS: [(&str, &str); 3] = [
    (
        "json",
        r#"{"user":{"name":"Ada"},"enabled":true,"numbers":[1,2]}"#,
    ),
    (
        "toml",
        "enabled = true\nnumbers = [1, 2]\n[user]\nname = 'Ada'",
    ),
    ("yaml", "user:\n  name: Ada\nenabled: true\nnumbers: [1, 2]"),
];

#[test]
fn renders_heck_case_filters() {
    for (filter, expected) in [
        ("upper_camel_case", "XmlHttpRequest"),
        ("pascal_case", "XmlHttpRequest"),
        ("lower_camel_case", "xmlHttpRequest"),
        ("snake_case", "xml_http_request"),
        ("kebab_case", "xml-http-request"),
        ("shouty_snake_case", "XML_HTTP_REQUEST"),
        ("shouty_kebab_case", "XML-HTTP-REQUEST"),
        ("train_case", "Xml-Http-Request"),
    ] {
        assert_render(
            &format!("{{{{ name | {filter} }}}}"),
            &["name=XMLHttp request"],
            expected,
        );
        assert_render(&format!("{{{{ name | {filter} }}}}"), &["name="], "");
    }
    assert_render(
        "{{ name | snake_case | upper }}",
        &["name=Déjà Vu"],
        "DÉJÀ_VU",
    );
}

#[test]
fn case_filters_reject_non_strings() {
    for value in ["42", "true", "null", "[]", "{}"] {
        let output = render(
            "before {{ name | snake_case }}",
            &["--json", &format!(r#"{{"name":{value}}}"#)],
        );
        assert!(!output.status.success(), "accepted {value}");
        assert!(output.stdout.is_empty());
        assert!(String::from_utf8_lossy(&output.stderr).contains("Failed to render template"));
    }
}

const TEMPLATE: &str = "{% if enabled %}{{ user.name | upper }}:{% for n in numbers %}{{ n + 1 }}{% endfor %}{% endif %}";

#[test]
fn renders_environment_contexts() {
    for (format, context) in CONTEXTS {
        let flag = format!("--{format}-env");
        let output = render_with_env(
            &format!("{TEMPLATE}|{{{{ extra }}}}"),
            &[&flag, "YATERA_TEST_CONTEXT", "extra=override"],
            &[("YATERA_TEST_CONTEXT", context)],
        );
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(output.stdout, b"ADA:23|override");
        assert!(output.stderr.is_empty());

        let output = render_with_env(
            "{{ enabled }}",
            &[&flag, "YATERA_TEST_CONTEXT", "enabled=override"],
            &[("YATERA_TEST_CONTEXT", context)],
        );
        assert!(output.status.success());
        assert_eq!(output.stdout, b"override");
    }
}

#[test]
fn reports_environment_context_errors() {
    for format in ["json", "toml", "yaml"] {
        let flag = format!("--{format}-env");
        for (env, message) in [
            (vec![], "Failed to read context environment variable"),
            (vec![("YATERA_TEST_CONTEXT", "[")], "Invalid"),
        ] {
            let output = render_with_env("", &[&flag, "YATERA_TEST_CONTEXT"], &env);
            assert!(!output.status.success());
            assert!(output.stdout.is_empty());
            let stderr = String::from_utf8_lossy(&output.stderr);
            assert!(stderr.contains(message), "{stderr}");
            assert!(stderr.contains("YATERA_TEST_CONTEXT"), "{stderr}");
        }
        for other in ["--json", "--toml-file", "--yaml-env"] {
            // Use a different environment flag to avoid repeating the same option.
            if other == flag {
                continue;
            }
            let output = render("", &[&flag, "YATERA_TEST_CONTEXT", other, "unused"]);
            assert!(!output.status.success());
            assert!(output.stdout.is_empty());
            assert!(
                String::from_utf8_lossy(&output.stderr).contains("Choose only one context source")
            );
        }
    }
    let output = render_with_env(
        "",
        &["--json-env", "YATERA_TEST_CONTEXT"],
        &[("YATERA_TEST_CONTEXT", "[]")],
    );
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("object or mapping"));
}

#[test]
fn renders_typed_inline_contexts() {
    for (format, context) in CONTEXTS {
        assert_render(TEMPLATE, &[&format!("--{format}"), context], "ADA:23");
        assert_render(
            "{{ enabled }}",
            &[&format!("--{format}"), context, "enabled=override"],
            "override",
        );
    }
}

#[test]
fn renders_context_files() {
    let directory = tempfile::tempdir().unwrap();
    for (format, context) in CONTEXTS {
        let path = directory.path().join(format!("vars.{format}"));
        std::fs::write(&path, context).unwrap();
        assert_render(
            TEMPLATE,
            &[&format!("--{format}-file"), path.to_str().unwrap()],
            "ADA:23",
        );
    }
}

#[test]
fn reports_invalid_inputs_without_stdout() {
    for (template, args, message) in [
        ("", vec!["invalid"], "expected name=value"),
        ("", vec!["=value"], "must not be empty"),
        ("", vec!["--json", "{"], "Invalid --json context"),
        ("", vec!["--toml", "value ="], "Invalid --toml context"),
        ("", vec!["--yaml", "value: ["], "Invalid --yaml context"),
        ("", vec!["--json", "[]"], "object or mapping"),
        ("", vec!["--yaml", "hello"], "object or mapping"),
        (
            "",
            vec!["--json", "{}", "--yaml", "{}"],
            "Choose only one context source",
        ),
        (
            "",
            vec!["--json", "{}", "--json-file", "unused"],
            "Choose only one context source",
        ),
        (
            "",
            vec!["--json-file", "nonexistent-directory/vars.json"],
            "Failed to read context file",
        ),
        ("before {{", vec![], "Failed to render template"),
        ("before {{ missing }}", vec![], "Failed to render template"),
    ] {
        let output = render(template, &args);
        assert!(!output.status.success(), "{args:?}");
        assert!(output.stdout.is_empty());
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains(message),
            "expected {message:?}, got {stderr:?}"
        );
    }
}

#[test]
fn help_exposes_root_options() {
    let output = render("", &["--help"]);
    assert!(output.status.success());
    let help = String::from_utf8(output.stdout).unwrap();
    for flag in [
        "--var",
        "--arg",
        "--json-env",
        "--toml-env",
        "--yaml-env",
        "--in-file",
        "--out-file",
        "--json",
        "--json-file",
        "--toml",
        "--toml-file",
        "--yaml",
        "--yaml-file",
    ] {
        assert!(help.contains(flag));
    }
}

#[test]
fn renders_from_and_to_files_independently() {
    let directory = tempfile::tempdir().unwrap();
    let source = directory.path().join("template.ts.tera");
    let target = directory.path().join("module.ts");
    let from = source.to_str().unwrap();
    let to = target.to_str().unwrap();
    std::fs::write(
        &source,
        "export const {{ name | snake_case }} = {{ value }};",
    )
    .unwrap();
    assert_render(
        "ignored stdin",
        &["--in-file", from, "name=HelloWorld", "value=42"],
        "export const hello_world = 42;",
    );
    assert_render(
        "ignored stdin",
        &[
            "--in-file",
            from,
            "--out-file",
            to,
            "--json",
            r#"{"name":"HelloWorld","value":42}"#,
        ],
        "",
    );
    assert_eq!(
        std::fs::read_to_string(&target).unwrap(),
        "export const hello_world = 42;"
    );
    assert_render("{{ value }}", &["--out-file", to, "value=short"], "");
    assert_eq!(std::fs::read_to_string(&target).unwrap(), "short");
    assert_render("", &["--in-file", to, "--out-file", to], "");
    assert_eq!(std::fs::read_to_string(&target).unwrap(), "short");
}

#[test]
fn file_errors_preserve_existing_output() {
    let directory = tempfile::tempdir().unwrap();
    let source = directory.path().join("broken.tera");
    let target = directory.path().join("output.txt");
    let missing = directory.path().join("missing.tera");
    std::fs::write(&source, "before {{").unwrap();
    std::fs::write(&target, "existing output").unwrap();
    for (path, message) in [
        (&source, "Failed to render template file"),
        (&missing, "Failed to read template file"),
    ] {
        let output = render(
            "",
            &[
                "--in-file",
                path.to_str().unwrap(),
                "--out-file",
                target.to_str().unwrap(),
            ],
        );
        assert!(!output.status.success());
        assert!(output.stdout.is_empty());
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains(message), "{stderr}");
        assert!(stderr.contains(path.to_str().unwrap()), "{stderr}");
        assert_eq!(std::fs::read_to_string(&target).unwrap(), "existing output");
    }
    let output = render(
        "rendered",
        &["--out-file", directory.path().to_str().unwrap()],
    );
    assert!(!output.status.success());
    assert!(output.stdout.is_empty());
    assert!(String::from_utf8_lossy(&output.stderr).contains("Failed to write rendered template"));
}