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"] {
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"));
}