yatera 0.1.0

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

fn render(template: &str, args: &[&str]) -> Output {
    let mut child = Command::new(env!("CARGO_BIN_EXE_tera"))
        .args(args)
        .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("", &[], "");
}

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 [
        ("to_upper_camel_case", "XmlHttpRequest"),
        ("to_pascal_case", "XmlHttpRequest"),
        ("to_lower_camel_case", "xmlHttpRequest"),
        ("to_snake_case", "xml_http_request"),
        ("to_kebab_case", "xml-http-request"),
        ("to_shouty_snake_case", "XML_HTTP_REQUEST"),
        ("to_shouty_kebab_case", "XML-HTTP-REQUEST"),
        ("to_title_case", "Xml Http Request"),
        ("to_train_case", "Xml-Http-Request"),
    ] {
        assert_render(
            &format!("{{{{ name | {filter} }}}}"),
            &["name=XMLHttp request"],
            expected,
        );
        assert_render(&format!("{{{{ name | {filter} }}}}"), &["name="], "");
    }
    assert_render(
        "{{ name | to_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 | to_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_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 [
        "--from",
        "--to",
        "--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 | to_snake_case }} = {{ value }};",
    )
    .unwrap();
    assert_render(
        "ignored stdin",
        &["--from", from, "name=HelloWorld", "value=42"],
        "export const hello_world = 42;",
    );
    assert_render(
        "ignored stdin",
        &[
            "--from",
            from,
            "--to",
            to,
            "--json",
            r#"{"name":"HelloWorld","value":42}"#,
        ],
        "",
    );
    assert_eq!(
        std::fs::read_to_string(&target).unwrap(),
        "export const hello_world = 42;"
    );
    assert_render("{{ value }}", &["--to", to, "value=short"], "");
    assert_eq!(std::fs::read_to_string(&target).unwrap(), "short");
    assert_render("", &["--from", to, "--to", 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(
            "",
            &[
                "--from",
                path.to_str().unwrap(),
                "--to",
                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", &["--to", 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"));
}