use serde_json::Value;
use std::collections::HashMap;
use super::base_tera::translate_go_time_format;
use super::render::{extract_artifact_ext, render, render_with_env};
use super::vars::{TemplateVars, find_stale_typed_compare};
use crate::env_source::MapEnvSource;
fn test_vars() -> TemplateVars {
let mut vars = TemplateVars::new();
vars.set("ProjectName", "cfgd");
vars.set("Version", "1.2.3");
vars.set("Tag", "v1.2.3");
vars.set("Os", "linux");
vars.set("Arch", "amd64");
vars.set("ShortCommit", "abc1234");
vars.set("Major", "1");
vars.set("Minor", "2");
vars.set("Patch", "3");
vars.set_env("GITHUB_TOKEN", "tok123");
vars
}
#[test]
fn test_simple_substitution() {
let vars = test_vars();
let result = render("{{ .ProjectName }}-{{ .Version }}", &vars).unwrap();
assert_eq!(result, "cfgd-1.2.3");
}
#[test]
fn test_env_access() {
let vars = test_vars();
let result = render("{{ .Env.GITHUB_TOKEN }}", &vars).unwrap();
assert_eq!(result, "tok123");
}
#[test]
fn test_no_spaces() {
let vars = test_vars();
let result = render("{{.ProjectName}}-{{.Version}}", &vars).unwrap();
assert_eq!(result, "cfgd-1.2.3");
}
#[test]
fn test_missing_var() {
let vars = test_vars();
let result = render("{{ .Missing }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_archive_name_template() {
let vars = test_vars();
let result = render(
"{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}",
&vars,
)
.unwrap();
assert_eq!(result, "cfgd-1.2.3-linux-amd64");
}
#[test]
fn test_literal_text_preserved() {
let vars = test_vars();
let result = render("prefix-{{ .Tag }}-suffix.tar.gz", &vars).unwrap();
assert_eq!(result, "prefix-v1.2.3-suffix.tar.gz");
}
#[test]
fn test_tera_simple_substitution() {
let vars = test_vars();
let result = render("{{ ProjectName }}-{{ Version }}", &vars).unwrap();
assert_eq!(result, "cfgd-1.2.3");
}
#[test]
fn test_tera_env_access() {
let vars = test_vars();
let result = render("{{ Env.GITHUB_TOKEN }}", &vars).unwrap();
assert_eq!(result, "tok123");
}
#[test]
fn test_tera_archive_name() {
let vars = test_vars();
let result = render("{{ ProjectName }}-{{ Version }}-{{ Os }}-{{ Arch }}", &vars).unwrap();
assert_eq!(result, "cfgd-1.2.3-linux-amd64");
}
#[test]
fn test_tera_missing_var() {
let vars = test_vars();
let result = render("{{ Missing }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_conditional_true() {
let mut vars = test_vars();
vars.set("IsSnapshot", "true");
let result = render("{% if IsSnapshot %}SNAP{% endif %}", &vars).unwrap();
assert_eq!(result, "SNAP");
}
#[test]
fn test_conditional_false_else() {
let mut vars = test_vars();
vars.set("IsSnapshot", "false");
let result = render("{% if IsSnapshot %}SNAP{% else %}RELEASE{% endif %}", &vars).unwrap();
assert_eq!(result, "RELEASE");
}
#[test]
fn test_pipe_lower() {
let vars = test_vars();
let result = render("{{ Version | lower }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_pipe_upper() {
let vars = test_vars();
let result = render("{{ ProjectName | upper }}", &vars).unwrap();
assert_eq!(result, "CFGD");
}
#[test]
fn test_tolower_alias() {
let vars = test_vars();
let result = render("{{ ProjectName | tolower }}", &vars).unwrap();
assert_eq!(result, "cfgd");
}
#[test]
fn test_toupper_alias() {
let vars = test_vars();
let result = render("{{ ProjectName | toupper }}", &vars).unwrap();
assert_eq!(result, "CFGD");
}
#[test]
fn test_english_join_pipe_form_on_structured_var() {
let mut vars = test_vars();
vars.set_structured(
"Names",
Value::Array(vec![
Value::String("alice".into()),
Value::String("bob".into()),
Value::String("carol".into()),
]),
);
let result = render("{{ Names | englishJoin }}", &vars).unwrap();
assert_eq!(result, "alice, bob, and carol");
}
#[test]
fn test_trimprefix() {
let vars = test_vars();
let result = render("{{ Tag | trimprefix(prefix=\"v\") }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_trimprefix_no_match() {
let vars = test_vars();
let result = render("{{ Tag | trimprefix(prefix=\"x\") }}", &vars).unwrap();
assert_eq!(result, "v1.2.3");
}
#[test]
fn test_trimsuffix() {
let vars = test_vars();
let result = render("{{ ProjectName | trimsuffix(suffix=\"gd\") }}", &vars).unwrap();
assert_eq!(result, "cf");
}
#[test]
fn test_trimsuffix_no_match() {
let vars = test_vars();
let result = render("{{ ProjectName | trimsuffix(suffix=\"xyz\") }}", &vars).unwrap();
assert_eq!(result, "cfgd");
}
#[test]
fn test_default_value_for_undefined() {
let vars = test_vars();
let result = render("{{ Undefined | default(value=\"fallback\") }}", &vars).unwrap();
assert_eq!(result, "fallback");
}
#[test]
fn test_bad_syntax_error() {
let vars = test_vars();
let result = render("{{ unclosed", &vars);
assert!(result.is_err());
}
#[test]
fn test_nested_env_conditional() {
let vars = test_vars();
let result = render("{% if Env.GITHUB_TOKEN %}has token{% endif %}", &vars).unwrap();
assert_eq!(result, "has token");
}
#[test]
fn test_trimprefix_missing_arg_error() {
let vars = test_vars();
let result = render("{{ Tag | trimprefix }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_trimsuffix_missing_arg_error() {
let vars = test_vars();
let result = render("{{ Tag | trimsuffix }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_filter_chaining() {
let vars = test_vars();
let result = render("{{ Tag | trimprefix(prefix=\"v\") | upper }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_unknown_filter_error() {
let vars = test_vars();
let result = render("{{ ProjectName | nonexistent_filter }}", &vars);
assert!(result.is_err(), "unknown filter should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("nonexistent_filter"),
"error should mention the unknown filter name, got: {err}"
);
}
#[test]
fn test_unclosed_block_tag_error() {
let vars = test_vars();
let result = render("{% if ProjectName %} hello", &vars);
assert!(result.is_err(), "unclosed if block should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("template") || err.contains("if"),
"error should reference the template or block tag, got: {err}"
);
}
#[test]
fn test_trailing_pipe_with_no_filter_name_error() {
let vars = test_vars();
let result = render("{{ ProjectName | }}", &vars);
assert!(
result.is_err(),
"trailing pipe with no filter name should produce an error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("parse") || err.contains("unexpected") || err.contains("template"),
"error should mention a parsing issue, got: {err}"
);
}
#[test]
fn test_undefined_var_in_concat_coerces_empty() {
let vars = test_vars();
let result = render("{{ Undefined ~ ' suffix' }}", &vars).unwrap();
assert_eq!(result, " suffix");
}
#[test]
fn test_optional_chaining_suppresses_undefined_at_every_link() {
let mut vars = test_vars();
vars.set_structured("Some", serde_json::json!({ "present": "x" }));
assert_eq!(
render("{{ Some?.Missing or \"fallback\" }}", &vars).unwrap(),
"fallback"
);
assert_eq!(
render("{{ AbsentRoot?.b or \"fallback\" }}", &vars).unwrap(),
"fallback"
);
}
#[test]
fn test_undefined_dotted_field_in_concat_coerces_empty() {
let mut vars = test_vars();
vars.set_structured("Some", serde_json::json!({ "present": "x" }));
let result = render("{{ Some.Missing ~ '-x' }}", &vars).unwrap();
assert_eq!(result, "-x");
}
#[test]
fn test_undefined_dotted_root_in_concat_errors() {
let vars = test_vars();
let result = render("{{ AbsentRoot.Missing ~ '-x' }}", &vars);
assert!(
result.is_err(),
"concat with an undefined root variable should still error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("AbsentRoot"),
"error should name the undefined root variable, got: {err}"
);
}
#[test]
fn test_invalid_filter_argument_type_error() {
let vars = test_vars();
let result = render("{{ Tag | trimprefix(prefix=123) }}", &vars);
assert!(
result.is_err(),
"invalid filter argument type should produce an error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("trimprefix") || err.contains("prefix") || err.contains("argument"),
"error should mention the filter or argument, got: {err}"
);
}
#[test]
fn test_error_message_includes_original_template() {
let vars = test_vars();
let template = "{{ .Nonexistent }}";
let result = render(template, &vars);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Nonexistent") || err.contains(template),
"error should reference the template or variable name, got: {err}"
);
}
#[test]
fn test_mismatched_endfor_with_if_error() {
let vars = test_vars();
let result = render("{% if ProjectName %}hello{% endfor %}", &vars);
assert!(
result.is_err(),
"mismatched block tags should produce an error"
);
}
#[test]
fn test_undefined_variable_error_mentions_variable() {
let vars = test_vars();
let result = render("{{ UndefinedFoo }}", &vars);
assert!(
result.is_err(),
"undefined variable should produce an error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("UndefinedFoo") || err.contains("template"),
"error should mention the undefined variable name, got: {err}"
);
}
#[test]
fn test_unclosed_brace_syntax_error() {
let vars = test_vars();
let result = render("{{ ProjectName", &vars);
assert!(result.is_err(), "unclosed brace should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("parse") || err.contains("template") || err.contains("ProjectName"),
"error should indicate a parse failure, got: {err}"
);
}
#[test]
fn test_unclosed_tag_block_error() {
let vars = test_vars();
let result = render("{% for x in items %} content", &vars);
assert!(
result.is_err(),
"unclosed for block should produce an error"
);
}
#[test]
fn test_invalid_filter_name_error_mentions_filter() {
let vars = test_vars();
let result = render("{{ ProjectName | bogus_filter_name }}", &vars);
assert!(result.is_err(), "invalid filter should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("bogus_filter_name"),
"error should mention the invalid filter name, got: {err}"
);
}
#[test]
fn test_missing_env_var_returns_empty_string() {
let vars = test_vars();
let result = render("{{ Env.NONEXISTENT_VAR_12345 }}", &vars).unwrap();
assert_eq!(result, "", "missing env var should return empty string");
}
#[test]
fn test_go_style_syntax_error_reports_original_template() {
let vars = test_vars();
let template = "{{ .Missing | bad_filter }}";
let result = render(template, &vars);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("bad_filter") || err.contains(template),
"error should reference the original template or filter, got: {err}"
);
}
#[test]
fn test_empty_template_renders_empty() {
let vars = test_vars();
let result = render("", &vars);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_multiple_errors_in_template() {
let vars = test_vars();
let result = render("{% if %}", &vars);
assert!(
result.is_err(),
"empty if condition should produce an error"
);
}
#[test]
fn test_env_or_default_reads_from_template_env_map() {
let mut vars = test_vars();
vars.set_env("MY_CUSTOM_VAR", "from-template-env");
let result = render(
"{{ envOrDefault(name=\"MY_CUSTOM_VAR\", default=\"fallback\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "from-template-env");
}
#[test]
fn test_env_or_default_template_env_takes_priority_over_process_env() {
let mut vars = test_vars();
let host = MapEnvSource::new().with("ANODIZER_TEST_PRIORITY", "from-process");
vars.set_env("ANODIZER_TEST_PRIORITY", "from-template");
let result = render_with_env(
"{{ envOrDefault(name=\"ANODIZER_TEST_PRIORITY\", default=\"fallback\") }}",
&vars,
&host,
)
.unwrap();
assert_eq!(result, "from-template");
}
#[test]
fn test_env_or_default_falls_back_to_process_env() {
let vars = test_vars();
let host = MapEnvSource::new().with("ANODIZER_TEST_ENV_OR_DEFAULT", "from-process-env");
let result = render_with_env(
"{{ envOrDefault(name=\"ANODIZER_TEST_ENV_OR_DEFAULT\", default=\"fallback\") }}",
&vars,
&host,
)
.unwrap();
assert_eq!(result, "from-process-env");
}
#[test]
fn test_env_or_default_returns_default_when_unset() {
let vars = test_vars();
let result = render(
"{{ envOrDefault(name=\"ANODIZER_TEST_UNSET_VAR_XYZ\", default=\"fallback\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "fallback");
}
#[test]
fn test_env_or_default_returns_empty_when_no_default() {
let vars = test_vars();
let result = render(
"{{ envOrDefault(name=\"ANODIZER_TEST_UNSET_VAR_XYZ2\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_env_or_default_missing_name_error() {
let vars = test_vars();
let result = render("{{ envOrDefault(default=\"x\") }}", &vars);
assert!(result.is_err(), "envOrDefault without name should error");
}
#[test]
fn test_is_env_set_reads_from_template_env_map() {
let mut vars = test_vars();
vars.set_env("MY_CUSTOM_CHECK", "yes");
let result = render(
"{% if isEnvSet(name=\"MY_CUSTOM_CHECK\") %}SET{% else %}UNSET{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "SET");
}
#[test]
fn test_is_env_set_template_env_empty_returns_false() {
let mut vars = test_vars();
vars.set_env("MY_EMPTY_VAR", "");
let result = render(
"{% if isEnvSet(name=\"MY_EMPTY_VAR\") %}SET{% else %}UNSET{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "UNSET");
}
#[test]
fn test_is_env_set_falls_back_to_process_env() {
let vars = test_vars();
let host = MapEnvSource::new().with("ANODIZER_TEST_IS_SET", "yes");
let result = render_with_env(
"{% if isEnvSet(name=\"ANODIZER_TEST_IS_SET\") %}SET{% else %}UNSET{% endif %}",
&vars,
&host,
)
.unwrap();
assert_eq!(result, "SET");
}
#[test]
fn test_is_env_set_false_when_unset() {
let vars = test_vars();
let result = render(
"{% if isEnvSet(name=\"ANODIZER_TEST_NOT_SET_XYZ\") %}SET{% else %}UNSET{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "UNSET");
}
#[test]
fn test_is_env_set_missing_name_error() {
let vars = test_vars();
let result = render("{{ isEnvSet() }}", &vars);
assert!(result.is_err(), "isEnvSet without name should error");
}
fn hash_test_file() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hello.txt");
std::fs::write(&path, "hello").unwrap();
(dir, path.to_string_lossy().into_owned())
}
#[test]
fn test_hash_sha1() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha1(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(result, "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d");
}
#[test]
fn test_hash_sha256() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha256(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_hash_sha512() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha512(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
);
}
#[test]
fn test_hash_md5() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ md5(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(result, "5d41402abc4b2a76b9719d911017c592");
}
#[test]
fn test_hash_blake3() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ blake3(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f"
);
}
#[test]
fn test_hash_crc32() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ crc32(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(result, "3610a686");
}
#[test]
fn test_hash_missing_s_arg_error() {
let vars = test_vars();
let result = render("{{ sha256() }}", &vars);
assert!(
result.is_err(),
"hash function without `s` arg should error"
);
let err = format!("{:#}", result.unwrap_err());
assert!(
err.contains("requires `s` argument"),
"error should mention missing `s` argument, got: {err}"
);
}
#[test]
fn test_incpatch() {
let vars = test_vars();
let result = render("{{ incpatch(v=\"1.2.3\") }}", &vars).unwrap();
assert_eq!(result, "1.2.4");
}
#[test]
fn test_incminor() {
let vars = test_vars();
let result = render("{{ incminor(v=\"1.2.3\") }}", &vars).unwrap();
assert_eq!(result, "1.3.0");
}
#[test]
fn test_incmajor() {
let vars = test_vars();
let result = render("{{ incmajor(v=\"1.2.3\") }}", &vars).unwrap();
assert_eq!(result, "2.0.0");
}
#[test]
fn test_incpatch_preserves_v_prefix() {
let vars = test_vars();
let result = render("{{ incpatch(v=\"v1.2.3\") }}", &vars).unwrap();
assert_eq!(result, "v1.2.4");
}
#[test]
fn test_incpatch_handles_prerelease() {
let vars = test_vars();
let result = render("{{ incpatch(v=\"1.2.3-rc.1\") }}", &vars).unwrap();
assert_eq!(result, "1.2.4");
}
fn err_chain(err: &anyhow::Error) -> String {
let mut s = String::new();
s.push_str(&format!("{}", err));
let mut src: Option<&dyn std::error::Error> = err.source();
while let Some(e) = src {
s.push_str(" | ");
s.push_str(&format!("{}", e));
src = e.source();
}
s
}
#[test]
fn test_incpatch_rejects_non_semver_function_form() {
let vars = test_vars();
let err = render("{{ incpatch(v=\"garbage\") }}", &vars).unwrap_err();
let s = err_chain(&err);
assert!(
s.contains("garbage") && s.contains("not a valid semver"),
"expected error mentioning the offending input + 'not a valid semver', got: {}",
s
);
}
#[test]
fn test_incpatch_rejects_non_semver_filter_form() {
let vars = test_vars();
let err = render("{{ \"oops\" | incpatch }}", &vars).unwrap_err();
let s = err_chain(&err);
assert!(
s.contains("oops") && s.contains("not a valid semver"),
"expected error mentioning the offending input + 'not a valid semver', got: {}",
s
);
}
#[test]
fn test_incminor_rejects_two_component_version() {
let vars = test_vars();
let err = render("{{ incminor(v=\"1.2\") }}", &vars).unwrap_err();
let s = err_chain(&err);
assert!(
s.contains("not a valid semver"),
"two-component versions must error, got: {}",
s
);
}
#[test]
fn test_incmajor_rejects_alpha_component() {
let vars = test_vars();
let err = render("{{ incmajor(v=\"a.b.c\") }}", &vars).unwrap_err();
assert!(err_chain(&err).contains("not a valid semver"));
}
#[test]
fn test_read_file_existing() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "file content").unwrap();
let vars = test_vars();
let template = format!(
"{{{{ readFile(path=\"{}\") }}}}",
file_path.to_string_lossy().replace('\\', "\\\\")
);
let result = render(&template, &vars).unwrap();
assert_eq!(result, "file content");
}
#[test]
fn test_read_file_nonexistent_returns_empty() {
let vars = test_vars();
let result = render(
"{{ readFile(path=\"/tmp/anodizer_test_nonexistent_file_xyz\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_must_read_file_existing() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, "must content").unwrap();
let vars = test_vars();
let template = format!(
"{{{{ mustReadFile(path=\"{}\") }}}}",
file_path.to_string_lossy().replace('\\', "\\\\")
);
let result = render(&template, &vars).unwrap();
assert_eq!(result, "must content");
}
#[test]
fn test_must_read_file_nonexistent_errors() {
let vars = test_vars();
let result = render(
"{{ mustReadFile(path=\"/tmp/anodizer_test_nonexistent_file_xyz\") }}",
&vars,
);
assert!(
result.is_err(),
"mustReadFile with nonexistent file should error"
);
}
#[test]
fn test_dir_filter() {
let mut vars = test_vars();
vars.set("FilePath", "/foo/bar/baz.txt");
let result = render("{{ FilePath | dir }}", &vars).unwrap();
assert_eq!(result, "/foo/bar");
}
#[test]
fn test_base_filter() {
let mut vars = test_vars();
vars.set("FilePath", "/foo/bar/baz.txt");
let result = render("{{ FilePath | base }}", &vars).unwrap();
assert_eq!(result, "baz.txt");
}
#[test]
fn test_url_path_escape_spaces() {
let mut vars = test_vars();
vars.set("Input", "hello world");
let result = render("{{ Input | urlPathEscape }}", &vars).unwrap();
assert_eq!(result, "hello%20world");
}
#[test]
fn test_url_path_escape_encodes_slashes() {
let mut vars = test_vars();
vars.set("Input", "foo/bar");
let result = render("{{ Input | urlPathEscape }}", &vars).unwrap();
assert_eq!(result, "foo%2Fbar");
}
#[test]
fn test_mdv2escape() {
let mut vars = test_vars();
vars.set("Input", "hello_world");
let result = render("{{ Input | mdv2escape }}", &vars).unwrap();
assert_eq!(result, "hello\\_world");
}
#[test]
fn test_ruby_escape_quote_and_backslash() {
let mut vars = test_vars();
vars.set("Input", "the \"best\" \\tool");
let result = render("{{ Input | ruby_escape }}", &vars).unwrap();
assert_eq!(result, "the \\\"best\\\" \\\\tool");
}
#[test]
fn test_ruby_escape_plain_string_unchanged() {
let mut vars = test_vars();
vars.set("Input", "no special chars here");
let result = render("{{ Input | ruby_escape }}", &vars).unwrap();
assert_eq!(result, "no special chars here");
}
#[test]
fn test_contains_true() {
let vars = test_vars();
let result = render(
"{% if contains(s=\"hello world\", substr=\"world\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_contains_false() {
let vars = test_vars();
let result = render(
"{% if contains(s=\"hello\", substr=\"xyz\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_english_join_zero_items() {
let vars = test_vars();
let result = render("{{ englishJoin(items=[]) }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_english_join_one_item() {
let vars = test_vars();
let result = render("{{ englishJoin(items=[\"a\"]) }}", &vars).unwrap();
assert_eq!(result, "a");
}
#[test]
fn test_english_join_two_items() {
let vars = test_vars();
let result = render("{{ englishJoin(items=[\"a\", \"b\"]) }}", &vars).unwrap();
assert_eq!(result, "a and b");
}
#[test]
fn test_english_join_three_items_oxford() {
let vars = test_vars();
let result = render("{{ englishJoin(items=[\"a\", \"b\", \"c\"]) }}", &vars).unwrap();
assert_eq!(result, "a, b, and c");
}
#[test]
fn test_english_join_three_items_no_oxford() {
let vars = test_vars();
let result = render(
"{{ englishJoin(items=[\"a\", \"b\", \"c\"], oxford=false) }}",
&vars,
)
.unwrap();
assert_eq!(result, "a, b and c");
}
#[test]
fn test_filter_keeps_matches() {
let vars = test_vars();
let result = render(
"{{ filter(items=[\"apple\", \"banana\", \"avocado\"], regexp=\"^a\") }}",
&vars,
)
.unwrap();
assert!(result.contains("apple"));
assert!(result.contains("avocado"));
assert!(!result.contains("banana"));
}
#[test]
fn test_reverse_filter_removes_matches() {
let vars = test_vars();
let result = render(
"{{ reverseFilter(items=[\"apple\", \"banana\", \"avocado\"], regexp=\"^a\") }}",
&vars,
)
.unwrap();
assert!(result.contains("banana"));
assert!(!result.contains("apple"));
assert!(!result.contains("avocado"));
}
#[test]
fn test_filter_function_invalid_regex_returns_error() {
let vars = test_vars();
let result = render(
"{{ filter(items=[\"apple\"], regexp=\"[\") }}",
&vars,
);
assert!(result.is_err(), "invalid regex must produce an error");
let err = format!("{:?}", result.unwrap_err());
assert!(
err.contains("invalid regex") || err.contains("filter"),
"error chain should mention the invalid regex, got: {err}"
);
}
#[test]
fn test_reverse_filter_function_invalid_regex_returns_error() {
let vars = test_vars();
let result = render(
"{{ reverseFilter(items=[\"apple\"], regexp=\"[\") }}",
&vars,
);
assert!(result.is_err(), "invalid regex must produce an error");
let err = format!("{:?}", result.unwrap_err());
assert!(
err.to_lowercase().contains("invalid regex"),
"expected 'invalid regex' in error chain, got: {err}"
);
}
#[test]
fn test_filter_pipe_invalid_regex_returns_error() {
let vars = test_vars();
let result = render("{{ ProjectName | filter(regexp=\"[\") }}", &vars);
assert!(
result.is_err(),
"invalid regex in pipe form must produce an error"
);
let err = format!("{:?}", result.unwrap_err());
assert!(
err.contains("invalid regex"),
"error chain should mention 'invalid regex', got: {err}"
);
}
#[test]
fn test_reverse_filter_pipe_invalid_regex_returns_error() {
let vars = test_vars();
let result = render("{{ ProjectName | reverseFilter(regexp=\"[\") }}", &vars);
assert!(
result.is_err(),
"invalid regex in pipe form must produce an error"
);
let err = format!("{:?}", result.unwrap_err());
assert!(
err.to_lowercase().contains("invalid regex"),
"expected 'invalid regex' in error chain, got: {err}"
);
}
#[test]
fn test_index_or_default_key_exists() {
let args: HashMap<String, Value> = [
("map".to_string(), serde_json::json!({"foo": "bar"})),
("key".to_string(), Value::String("foo".to_string())),
("default".to_string(), Value::String("fallback".to_string())),
]
.into_iter()
.collect();
let map = args.get("map").unwrap().as_object().unwrap();
let key = args.get("key").unwrap().as_str().unwrap();
let default = args
.get("default")
.cloned()
.unwrap_or(Value::String(String::new()));
let result = map.get(key).cloned().unwrap_or(default);
assert_eq!(result, Value::String("bar".to_string()));
}
#[test]
fn test_index_or_default_key_missing() {
let args: HashMap<String, Value> = [
("map".to_string(), serde_json::json!({"foo": "bar"})),
("key".to_string(), Value::String("missing".to_string())),
("default".to_string(), Value::String("fallback".to_string())),
]
.into_iter()
.collect();
let map = args.get("map").unwrap().as_object().unwrap();
let key = args.get("key").unwrap().as_str().unwrap();
let default = args
.get("default")
.cloned()
.unwrap_or(Value::String(String::new()));
let result = map.get(key).cloned().unwrap_or(default);
assert_eq!(result, Value::String("fallback".to_string()));
}
#[test]
fn test_index_or_default_function_via_render_key_present() {
let mut vars = test_vars();
vars.set_structured(
"Labels",
serde_json::json!({ "env": "prod", "tier": "web" }),
);
let out = render(
"{{ indexOrDefault(map=Labels, key=\"env\", default=\"unknown\") }}",
&vars,
)
.unwrap();
assert_eq!(out, "prod");
}
#[test]
fn test_index_or_default_function_via_render_key_missing_uses_default() {
let mut vars = test_vars();
vars.set_structured("Labels", serde_json::json!({ "env": "prod" }));
let out = render(
"{{ indexOrDefault(map=Labels, key=\"absent\", default=\"unknown\") }}",
&vars,
)
.unwrap();
assert_eq!(out, "unknown");
}
#[test]
fn test_index_or_default_function_missing_map_arg_errors() {
let vars = test_vars();
let result = render("{{ indexOrDefault(key=\"x\", default=\"d\") }}", &vars);
assert!(
result.is_err(),
"indexOrDefault without a `map` argument must error"
);
}
#[test]
fn test_filter_pipe_keeps_matching_lines() {
let mut vars = test_vars();
vars.set("Notes", "feat: a\nfix: b\nfeat: c\nchore: d");
let out = render("{{ Notes | filter(regexp=\"^feat\") }}", &vars).unwrap();
assert_eq!(out, "feat: a\nfeat: c");
}
#[test]
fn test_reverse_filter_pipe_drops_matching_lines() {
let mut vars = test_vars();
vars.set("Notes", "feat: a\nfix: b\nfeat: c\nchore: d");
let out = render("{{ Notes | reverseFilter(regexp=\"^feat\") }}", &vars).unwrap();
assert_eq!(out, "fix: b\nchore: d");
}
#[test]
fn test_now_format_filter_missing_format_arg_errors() {
let vars = test_vars();
let result = render("{{ Now | now_format() }}", &vars);
assert!(
result.is_err(),
"now_format without a `format` argument must error"
);
}
#[test]
fn test_runtime_goos_renders() {
let mut vars = test_vars();
vars.set("RuntimeGoos", std::env::consts::OS);
let result = render("{{ Runtime.Goos }}", &vars).unwrap();
assert!(
!result.is_empty(),
"Runtime.Goos should render to a non-empty string"
);
}
#[test]
fn test_custom_var_tera_style() {
let mut vars = test_vars();
vars.set_custom_var("description", "my project description");
let result = render("{{ Var.description }}", &vars).unwrap();
assert_eq!(result, "my project description");
}
#[test]
fn test_custom_var_go_style() {
let mut vars = test_vars();
vars.set_custom_var("mykey", "myvalue");
let result = render("{{ .Var.mykey }}", &vars).unwrap();
assert_eq!(result, "myvalue");
}
#[test]
fn test_custom_var_multiple() {
let mut vars = test_vars();
vars.set_custom_var("name", "anodizer");
vars.set_custom_var("desc", "release tool");
let result = render("{{ .Var.name }} - {{ .Var.desc }}", &vars).unwrap();
assert_eq!(result, "anodizer - release tool");
}
#[test]
fn test_custom_var_empty_map_no_error() {
let vars = test_vars();
let result = render("{{ ProjectName }}", &vars).unwrap();
assert_eq!(result, "cfgd");
}
#[test]
fn test_custom_var_undefined_key_errors() {
let vars = test_vars();
let result = render("{{ Var.nonexistent }}", &vars);
assert!(
result.is_err(),
"accessing a missing key in Var should produce an error"
);
}
#[test]
fn test_custom_var_undefined_key_with_other_vars_set() {
let mut vars = test_vars();
vars.set_custom_var("exists", "yes");
let result = render("{{ Var.missing }}", &vars);
assert!(
result.is_err(),
"accessing a missing key in Var should produce an error"
);
}
#[test]
fn test_custom_var_empty_map_conditional() {
let vars = test_vars();
let result = render("{% if Var %}has vars{% else %}no vars{% endif %}", &vars).unwrap();
assert_eq!(result, "no vars");
let mut vars2 = test_vars();
vars2.set_custom_var("key", "val");
let result2 = render("{% if Var %}has vars{% else %}no vars{% endif %}", &vars2).unwrap();
assert_eq!(result2, "has vars");
}
#[test]
fn test_custom_var_with_template_in_value() {
let mut vars = test_vars();
vars.set_custom_var("version_string", "cfgd v1.2.3");
let result = render("{{ .Var.version_string }}", &vars).unwrap();
assert_eq!(result, "cfgd v1.2.3");
}
#[test]
fn test_positional_replace_standalone() {
let mut vars = test_vars();
vars.set("Version", "v1.2.3");
let result = render("{{ replace .Version \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_replace_standalone_no_dot() {
let mut vars = test_vars();
vars.set("Version", "v1.2.3");
let result = render("{{ replace Version \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_replace_piped() {
let mut vars = test_vars();
vars.set("Version", "v1.2.3");
let result = render("{{ .Version | replace \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_replace_piped_no_dot() {
let mut vars = test_vars();
vars.set("Version", "v1.2.3");
let result = render("{{ Version | replace \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_split_standalone() {
let vars = test_vars();
let result = render("{{ split .Version \".\" }}", &vars).unwrap();
assert!(result.contains("1"));
assert!(result.contains("2"));
assert!(result.contains("3"));
}
#[test]
fn test_positional_split_piped() {
let vars = test_vars();
let result = render("{{ .Version | split \".\" }}", &vars).unwrap();
assert!(result.contains("1"));
assert!(result.contains("2"));
assert!(result.contains("3"));
}
#[test]
fn test_positional_contains_standalone_true() {
let vars = test_vars();
let result = render(
"{% if contains .Version \"2\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_positional_contains_standalone_false() {
let vars = test_vars();
let result = render(
"{% if contains .Version \"rc\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_positional_contains_piped() {
let vars = test_vars();
let result = render(
"{% if Tag | contains \"v\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_positional_replace_with_env_var() {
let vars = test_vars();
let result = render("{{ replace .Env.GITHUB_TOKEN \"tok\" \"XXX\" }}", &vars).unwrap();
assert_eq!(result, "XXX123");
}
#[test]
fn test_positional_replace_empty_replacement() {
let vars = test_vars();
let result = render("{{ replace .Tag \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_named_arg_syntax_passthrough() {
let vars = test_vars();
let result = render("{{ replace(s=Tag, old=\"v\", new=\"\") }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_named_arg_filter_passthrough() {
let vars = test_vars();
let result = render("{{ Tag | replace(from=\"v\", to=\"\") }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_mixed_with_literal_text() {
let vars = test_vars();
let result = render("app-{{ replace .Tag \"v\" \"\" }}-{{ .Os }}", &vars).unwrap();
assert_eq!(result, "app-1.2.3-linux");
}
#[test]
fn test_positional_replace_both_quoted_args() {
let vars = test_vars();
let result = render("{{ replace \"v1.2.3\" \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_positional_split_literal_string() {
let vars = test_vars();
let result = render("{{ split \"a.b.c\" \".\" }}", &vars).unwrap();
assert!(result.contains("a"));
assert!(result.contains("b"));
assert!(result.contains("c"));
}
#[test]
fn test_positional_contains_literal_string() {
let vars = test_vars();
let result = render(
"{% if contains \"hello world\" \"world\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_split_filter_end_to_end() {
let vars = test_vars();
let result = render("{{ Version | split(sep=\".\") }}", &vars).unwrap();
assert!(result.contains("1"));
assert!(result.contains("2"));
assert!(result.contains("3"));
}
#[test]
fn test_contains_filter_end_to_end() {
let vars = test_vars();
let result = render(
"{% if Tag | contains(substr=\"v\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_chained_named_filter_then_positional_rewrite() {
let mut vars = test_vars();
vars.set("Version", "v1.2.3");
let input = "{{ Version | trimprefix(prefix=\"v\") | replace \".\" \"-\" }}";
let result = render(input, &vars).unwrap();
assert_eq!(result, "1-2-3");
}
#[test]
fn test_in_list_contains_value() {
let vars = test_vars();
let result = render(
"{% if in(items=[\"a\", \"b\", \"c\"], value=\"b\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_in_list_not_contains_value() {
let vars = test_vars();
let result = render(
"{% if in(items=[\"a\", \"b\", \"c\"], value=\"d\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_in_empty_list() {
let vars = test_vars();
let result = render(
"{% if in(items=[], value=\"a\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_in_go_style_positional_with_list_subexpr() {
let vars = test_vars();
let result = render(
"{% if in (list \"linux\" \"darwin\") \"linux\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_in_go_style_positional_with_list_subexpr_not_found() {
let vars = test_vars();
let result = render(
"{% if in (list \"linux\" \"darwin\") \"windows\" %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_in_positional_with_variable() {
let vars = test_vars();
let result = render(
"{% if in (list \"a\" \"b\" \"c\") \"c\" %}found{% else %}nope{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "found");
}
#[test]
fn test_in_renders_bool_string() {
let vars = test_vars();
let result = render("{{ in(items=[\"a\", \"b\"], value=\"a\") }}", &vars).unwrap();
assert_eq!(result, "true");
}
#[test]
fn test_in_renders_bool_string_false() {
let vars = test_vars();
let result = render("{{ in(items=[\"a\", \"b\"], value=\"z\") }}", &vars).unwrap();
assert_eq!(result, "false");
}
#[test]
fn test_in_filter_form_piped_via_set() {
let vars = test_vars();
let result = render(
"{% set items = [\"a\", \"b\", \"c\"] %}{% if items | in(value=\"b\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_in_filter_form_piped_not_found() {
let vars = test_vars();
let result = render(
"{% set items = [\"a\", \"b\", \"c\"] %}{% if items | in(value=\"z\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_in_missing_items_arg_error() {
let vars = test_vars();
let result = render("{{ in(value=\"a\") }}", &vars);
assert!(result.is_err(), "in without items should error");
}
#[test]
fn test_in_missing_value_arg_error() {
let vars = test_vars();
let result = render("{{ in(items=[\"a\"]) }}", &vars);
assert!(result.is_err(), "in without value should error");
}
#[test]
fn test_re_replace_all_basic() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\"world\", input=\"hello world\", replacement=\"rust\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "hello rust");
}
#[test]
fn test_re_replace_all_with_capture_groups() {
let vars = test_vars();
let result = render(
r#"{{ reReplaceAll(pattern="(\w+) (\w+)", input="hello world", replacement="$2 $1") }}"#,
&vars,
)
.unwrap();
assert_eq!(result, "world hello");
}
#[test]
fn test_re_replace_all_capture_group_goreleaser_style() {
let mut vars = test_vars();
vars.set("Message", "fix bug (#123)");
let result = render(
r#"{{ reReplaceAll(pattern="(.*) \(#(.*)\)", input=Message, replacement="$1 [#$2](https://tracker/$2)") }}"#,
&vars,
)
.unwrap();
assert_eq!(result, "fix bug [#123](https://tracker/123)");
}
#[test]
fn test_re_replace_all_no_match() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\"xyz\", input=\"hello\", replacement=\"replaced\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_re_replace_all_invalid_regex_error() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\"[invalid\", input=\"hello\", replacement=\"x\") }}",
&vars,
);
assert!(result.is_err(), "invalid regex should produce an error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("invalid regex") || err.contains("reReplaceAll"),
"error should mention reReplaceAll or invalid regex, got: {err}"
);
}
#[test]
fn test_re_replace_all_replaces_all_occurrences() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\"o\", input=\"foo bar boo\", replacement=\"0\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "f00 bar b00");
}
#[test]
fn test_re_replace_all_go_style_positional() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll \"world\" \"hello world\" \"rust\" }}",
&vars,
)
.unwrap();
assert_eq!(result, "hello rust");
}
#[test]
fn test_re_replace_all_go_style_with_variable() {
let vars = test_vars();
let result = render("{{ reReplaceAll \"v\" Tag \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_re_replace_all_filter_form() {
let vars = test_vars();
let result = render(
"{{ Tag | reReplaceAll(pattern=\"v\", replacement=\"\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_re_replace_all_filter_form_with_capture() {
let vars = test_vars();
let result = render(
"{{ Tag | reReplaceAll(pattern=\"v(.*)\", replacement=\"ver-$1\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "ver-1.2.3");
}
#[test]
fn test_re_replace_all_piped_positional() {
let vars = test_vars();
let result = render("{{ Tag | reReplaceAll \"v\" \"\" }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_re_replace_all_missing_pattern_error() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(input=\"hello\", replacement=\"x\") }}",
&vars,
);
assert!(result.is_err(), "reReplaceAll without pattern should error");
}
#[test]
fn test_re_replace_all_missing_input_error() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\"x\", replacement=\"y\") }}",
&vars,
);
assert!(result.is_err(), "reReplaceAll without input should error");
}
#[test]
fn test_re_replace_all_missing_replacement_error() {
let vars = test_vars();
let result = render("{{ reReplaceAll(pattern=\"x\", input=\"hello\") }}", &vars);
assert!(
result.is_err(),
"reReplaceAll without replacement should error"
);
}
#[test]
fn test_re_replace_all_filter_invalid_regex_error() {
let vars = test_vars();
let result = render(
"{{ Tag | reReplaceAll(pattern=\"[bad\", replacement=\"x\") }}",
&vars,
);
assert!(
result.is_err(),
"invalid regex in filter form should produce an error"
);
}
#[test]
fn test_in_numeric_value_as_string() {
let vars = test_vars();
let result = render(
"{% if in(items=[1, 2, 3], value=\"2\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_in_numeric_value_as_number() {
let vars = test_vars();
let result = render(
"{% if in(items=[1, 2, 3], value=2) %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_in_numeric_value_not_found() {
let vars = test_vars();
let result = render(
"{% if in(items=[1, 2, 3], value=4) %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_re_replace_all_empty_input() {
let vars = test_vars();
let result = render(
"{{ reReplaceAll(pattern=\".*\", input=\"\", replacement=\"x\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "x");
}
#[test]
fn test_in_set_context_keyword_conflict() {
let vars = test_vars();
let result = render(
"{% set result = in(items=[\"a\"], value=\"a\") %}{{ result }}",
&vars,
);
match result {
Ok(val) => assert_eq!(val, "true"),
Err(e) => {
panic!(
"Tera rejects `in` as function name in set context: {}. \
Consider renaming to `listContains`.",
e
);
}
}
}
#[test]
fn test_extract_artifact_ext_tar_gz() {
assert_eq!(
extract_artifact_ext("myapp-1.0.0-linux-amd64.tar.gz"),
".tar.gz"
);
}
#[test]
fn test_extract_artifact_ext_tar_xz() {
assert_eq!(extract_artifact_ext("myapp.tar.xz"), ".tar.xz");
}
#[test]
fn test_extract_artifact_ext_tar_zst() {
assert_eq!(extract_artifact_ext("myapp.tar.zst"), ".tar.zst");
}
#[test]
fn test_extract_artifact_ext_tar_bz2() {
assert_eq!(extract_artifact_ext("myapp.tar.bz2"), ".tar.bz2");
}
#[test]
fn test_extract_artifact_ext_tar_lz4() {
assert_eq!(extract_artifact_ext("archive.tar.lz4"), ".tar.lz4");
}
#[test]
fn test_extract_artifact_ext_tar_sz() {
assert_eq!(extract_artifact_ext("archive.tar.sz"), ".tar.sz");
}
#[test]
fn test_extract_artifact_ext_exe() {
assert_eq!(extract_artifact_ext("myapp.exe"), ".exe");
}
#[test]
fn test_extract_artifact_ext_dmg() {
assert_eq!(extract_artifact_ext("myapp-1.0.0.dmg"), ".dmg");
}
#[test]
fn test_extract_artifact_ext_zip() {
assert_eq!(extract_artifact_ext("myapp-1.0.0.zip"), ".zip");
}
#[test]
fn test_extract_artifact_ext_no_extension() {
assert_eq!(extract_artifact_ext("myapp"), "");
}
#[test]
fn test_extract_artifact_ext_hidden_file_no_ext() {
assert_eq!(extract_artifact_ext(".gitignore"), "");
}
#[test]
fn test_extract_artifact_ext_deb() {
assert_eq!(extract_artifact_ext("myapp_1.0.0_amd64.deb"), ".deb");
}
#[test]
fn test_extract_artifact_ext_rpm() {
assert_eq!(extract_artifact_ext("myapp-1.0.0.x86_64.rpm"), ".rpm");
}
#[test]
fn test_extract_artifact_ext_empty_string() {
assert_eq!(extract_artifact_ext(""), "");
}
#[test]
fn test_outputs_set_and_render() {
let mut vars = test_vars();
vars.set_output("build_id", "abc123");
let result = render("{{ .Outputs.build_id }}", &vars).unwrap();
assert_eq!(result, "abc123");
}
#[test]
fn test_outputs_multiple_keys() {
let mut vars = test_vars();
vars.set_output("key1", "val1");
vars.set_output("key2", "val2");
let result = render("{{ .Outputs.key1 }}-{{ .Outputs.key2 }}", &vars).unwrap();
assert_eq!(result, "val1-val2");
}
#[test]
fn test_outputs_empty_map_renders_empty_string() {
let vars = test_vars();
let result = render("{{ Outputs.missing | default(value=\"\") }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_outputs_get_output() {
let mut vars = TemplateVars::new();
vars.set_output("x", "42");
assert_eq!(vars.get_output("x"), Some(&"42".to_string()));
assert_eq!(vars.get_output("y"), None);
}
#[test]
fn test_artifact_ext_template_rendering() {
let mut vars = test_vars();
vars.set("ArtifactName", "myapp-1.0.0-linux-amd64.tar.gz");
vars.set("ArtifactExt", ".tar.gz");
let result = render("{{ .ArtifactName }}{{ .ArtifactExt }}", &vars).unwrap();
assert_eq!(result, "myapp-1.0.0-linux-amd64.tar.gz.tar.gz");
}
#[test]
fn test_target_template_rendering() {
let mut vars = test_vars();
vars.set("Target", "x86_64-unknown-linux-gnu");
let result = render("{{ .ProjectName }}-{{ .Version }}-{{ .Target }}", &vars).unwrap();
assert_eq!(result, "cfgd-1.2.3-x86_64-unknown-linux-gnu");
}
#[test]
fn test_checksums_template_rendering() {
let mut vars = test_vars();
let checksum_content = "abc123 myapp-1.0.0.tar.gz\ndef456 myapp-1.0.0.zip\n";
vars.set("Checksums", checksum_content);
let result = render("{{ .Checksums }}", &vars).unwrap();
assert_eq!(result, checksum_content);
}
#[test]
fn test_translate_go_time_format_basic_date() {
let result = translate_go_time_format("2006-01-02");
assert_eq!(result, "%Y-%m-%d");
}
#[test]
fn test_translate_go_time_format_full_datetime() {
let result = translate_go_time_format("2006-01-02 15:04:05");
assert_eq!(result, "%Y-%m-%d %H:%M:%S");
}
#[test]
fn test_translate_go_time_format_chrono_passthrough() {
let result = translate_go_time_format("%Y-%m-%d");
assert_eq!(result, "%Y-%m-%d");
}
#[test]
fn test_translate_go_time_format_no_go_patterns() {
let result = translate_go_time_format("hello world");
assert_eq!(result, "hello world");
}
#[test]
fn test_translate_go_time_format_month_name() {
let result = translate_go_time_format("January 02, 2006");
assert_eq!(result, "%B %d, %Y");
}
#[test]
fn test_translate_go_time_format_weekday() {
let result = translate_go_time_format("Monday, January 02, 2006");
assert_eq!(result, "%A, %B %d, %Y");
}
#[test]
fn test_time_go_format_end_to_end() {
let vars = test_vars();
let result = render("{{ time(format=\"2006-01-02\") }}", &vars).unwrap();
assert!(
result.len() == 10 && result.chars().nth(4) == Some('-'),
"expected date in YYYY-MM-DD format, got: {result}"
);
}
#[test]
fn test_time_chrono_format_still_works() {
let vars = test_vars();
let result = render("{{ time(format=\"%Y-%m-%d\") }}", &vars).unwrap();
assert!(
result.len() == 10 && result.chars().nth(4) == Some('-'),
"expected date in YYYY-MM-DD format, got: {result}"
);
}
#[test]
fn test_time_function_honors_source_date_epoch() {
let vars = test_vars();
let host = MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
let result = render_with_env("{{ time(format=\"%Y-%m-%d\") }}", &vars, &host).unwrap();
assert_eq!(
result, "2024-05-06",
"time() must honor SOURCE_DATE_EPOCH; got: {result}"
);
}
#[test]
fn test_now_format_filter_honors_source_date_epoch() {
let mut vars = test_vars();
vars.set("Now", "ignored");
let host = MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
let result =
render_with_env("{{ Now | now_format(format=\"%Y-%m-%d\") }}", &vars, &host).unwrap();
assert_eq!(
result, "2024-05-06",
"now_format must honor SOURCE_DATE_EPOCH; got: {result}"
);
}
#[test]
fn test_now_format_filter_go_format() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:00:00Z"); let result = render("{{ Now | now_format(format=\"2006-01-02\") }}", &vars).unwrap();
assert!(
result.len() == 10 && result.chars().nth(4) == Some('-'),
"expected date in YYYY-MM-DD format, got: {result}"
);
}
#[test]
fn test_now_format_filter_chrono_format() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:00:00Z");
let result = render("{{ Now | now_format(format=\"%Y-%m-%d\") }}", &vars).unwrap();
assert!(
result.len() == 10 && result.chars().nth(4) == Some('-'),
"expected date in YYYY-MM-DD format, got: {result}"
);
}
#[test]
fn test_now_format_preprocessed_from_go_style() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:00:00Z");
let result = render("{{ .Now.Format \"2006-01-02\" }}", &vars).unwrap();
assert!(
result.len() == 10 && result.chars().nth(4) == Some('-'),
"expected date in YYYY-MM-DD format, got: {result}"
);
}
#[test]
fn test_eq_comparison_end_to_end() {
let vars = test_vars();
let result = render("{{ if eq .Os \"linux\" }}yes{{ end }}", &vars).unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_ne_comparison_end_to_end() {
let vars = test_vars();
let result = render("{{ if ne .Os \"windows\" }}not-win{{ end }}", &vars).unwrap();
assert_eq!(result, "not-win");
}
#[test]
fn test_gt_comparison_end_to_end() {
let vars = test_vars();
let result = render("{{ if gt .Major 0 }}positive{{ else }}zero{{ end }}", &vars).unwrap();
assert_eq!(result, "positive");
}
#[test]
fn test_lt_comparison_end_to_end() {
let vars = test_vars();
let result = render("{{ if lt .Patch 5 }}small{{ else }}big{{ end }}", &vars).unwrap();
assert_eq!(result, "small");
}
#[test]
fn test_eq_with_not_parenthesized() {
let mut vars = test_vars();
vars.set("Amd64", "v2");
let result = render("{{ if not (eq .Amd64 \"v1\") }}not-v1{{ end }}", &vars).unwrap();
assert_eq!(result, "not-v1");
}
#[test]
fn test_or_and_comparison_end_to_end() {
let vars = test_vars();
let result = render(
"{{ if or (eq .Os \"linux\") (eq .Os \"darwin\") }}unix{{ else }}other{{ end }}",
&vars,
)
.unwrap();
assert_eq!(result, "unix");
}
#[test]
fn test_and_comparison_end_to_end() {
let vars = test_vars();
let result = render(
"{{ if and (eq .Os \"linux\") (eq .Arch \"amd64\") }}match{{ else }}no{{ end }}",
&vars,
)
.unwrap();
assert_eq!(result, "match");
}
#[test]
fn test_index_map_access() {
let mut vars = test_vars();
let map = serde_json::json!({"key1": "val1", "key2": "val2"});
vars.set_structured("mymap", map);
let result = render("{{ index(collection=mymap, key=\"key1\") }}", &vars).unwrap();
assert_eq!(result, "val1");
}
#[test]
fn test_index_map_missing_key() {
let mut vars = test_vars();
let map = serde_json::json!({"key1": "val1"});
vars.set_structured("mymap", map);
let result = render("{{ index(collection=mymap, key=\"missing\") }}", &vars).unwrap();
assert_eq!(result, "", "missing key should return empty string");
}
#[test]
fn test_index_array_access() {
let mut vars = test_vars();
let arr = serde_json::json!(["first", "second", "third"]);
vars.set_structured("myarr", arr);
let result = render("{{ index(collection=myarr, key=1) }}", &vars).unwrap();
assert_eq!(result, "second");
}
#[test]
fn test_missing_env_var_in_conditional() {
let vars = test_vars();
let result = render(
"{{ if .Env.TOTALLY_MISSING_VAR_XYZ }}set{{ else }}unset{{ end }}",
&vars,
)
.unwrap();
assert_eq!(result, "unset");
}
#[test]
fn test_missing_env_var_renders_empty() {
let vars = test_vars();
let result = render("prefix-{{ .Env.NONEXISTENT_ABC_123 }}-suffix", &vars).unwrap();
assert_eq!(result, "prefix--suffix");
}
#[test]
fn test_existing_env_var_still_works() {
let vars = test_vars();
let result = render("{{ .Env.GITHUB_TOKEN }}", &vars).unwrap();
assert_eq!(result, "tok123");
}
#[test]
fn test_map_and_index_go_style() {
let vars = test_vars();
let result = render(
"{{ $m := map \"a\" \"1\" \"b\" \"2\" }}{{ index $m \"a\" }}",
&vars,
)
.unwrap();
assert_eq!(result, "1");
}
#[test]
fn test_map_and_index_missing_key_returns_empty() {
let vars = test_vars();
let result = render(
"{{ $m := map \"a\" \"1\" }}{{ index $m \"missing\" }}",
&vars,
)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_per_target_vars_includes_ppc64_and_riscv64() {
use super::vars::PER_TARGET_VARS;
assert!(
PER_TARGET_VARS.contains(&"Ppc64"),
"PER_TARGET_VARS missing Ppc64 key (GR parity tmpl.go:43,208)"
);
assert!(
PER_TARGET_VARS.contains(&"Riscv64"),
"PER_TARGET_VARS missing Riscv64 key (GR parity tmpl.go:44,209)"
);
}
#[test]
fn test_clear_per_target_vars_clears_ppc64_and_riscv64() {
use super::vars::{TemplateVars, clear_per_target_vars};
let mut tv = TemplateVars::new();
tv.set("Ppc64", "power9");
tv.set("Riscv64", "rva20u64");
clear_per_target_vars(&mut tv);
assert_eq!(tv.get("Ppc64").map(String::as_str), Some(""));
assert_eq!(tv.get("Riscv64").map(String::as_str), Some(""));
}
#[test]
fn test_render_ppc64_and_riscv64_empty_after_clear() {
use super::vars::clear_per_target_vars;
let mut vars = test_vars();
clear_per_target_vars(&mut vars);
let out = render("[{{ .Ppc64 }}|{{ .Riscv64 }}]", &vars).unwrap();
assert_eq!(out, "[|]");
}
#[test]
fn test_clear_per_artifact_vars_clears_both_target_and_artifact_keys() {
use super::vars::{PER_ARTIFACT_VARS, PER_TARGET_VARS, TemplateVars, clear_per_artifact_vars};
let mut tv = TemplateVars::new();
tv.set("Os", "linux");
tv.set("Arch", "amd64");
tv.set("ArtifactName", "myapp-1.0.0.tar.gz");
tv.set("ArtifactExt", ".tar.gz");
tv.set("ArtifactID", "myapp-archive");
clear_per_artifact_vars(&mut tv);
for key in PER_TARGET_VARS.iter().chain(PER_ARTIFACT_VARS.iter()) {
assert_eq!(
tv.get(key).map(String::as_str),
Some(""),
"per-artifact clear must blank `{key}`"
);
}
}
#[test]
fn test_slice_string_end_exclusive() {
let vars = test_vars();
let result = render("{{ slice \"abcdefghij\" 0 7 }}", &vars).unwrap();
assert_eq!(result, "abcdefg");
}
#[test]
fn test_slice_start_only() {
let vars = test_vars();
let result = render("{{ slice \"abcdefghij\" 3 }}", &vars).unwrap();
assert_eq!(result, "defghij");
}
#[test]
fn test_slice_short_commit() {
let vars = test_vars();
let result = render("{{ slice .ShortCommit 0 4 }}", &vars).unwrap();
assert_eq!(result, "abc1");
}
#[test]
fn test_slice_out_of_range_clamps() {
let vars = test_vars();
let result = render("{{ slice \"hi\" 0 100 }}", &vars).unwrap();
assert_eq!(result, "hi");
}
#[test]
fn test_slice_char_boundary_safe() {
let mut vars = test_vars();
let word = format!("h{}llo", '\u{00E9}');
vars.set_structured("Word", Value::String(word));
let result = render("{{ slice Word 0 2 }}", &vars).unwrap();
assert_eq!(result, format!("h{}", '\u{00E9}'));
}
#[test]
fn test_slice_piped_form() {
let vars = test_vars();
let result = render("{{ \"abcdef\" | slice(start=1, end=4) }}", &vars).unwrap();
assert_eq!(result, "bcd");
}
#[test]
fn test_printf_zero_pad() {
let vars = test_vars();
let result = render("{{ printf \"%04d\" 7 }}", &vars).unwrap();
assert_eq!(result, "0007");
}
#[test]
fn test_printf_left_align_string() {
let vars = test_vars();
let result = render("{{ printf \"%-5s|\" \"hi\" }}", &vars).unwrap();
assert_eq!(result, "hi |");
}
#[test]
fn test_printf_float_precision() {
let vars = test_vars();
let result = render("{{ printf \"%.2f\" 3.14159 }}", &vars).unwrap();
assert_eq!(result, "3.14");
}
#[test]
fn test_printf_plus_sign() {
let vars = test_vars();
let result = render("{{ printf \"%+d\" 7 }}", &vars).unwrap();
assert_eq!(result, "+7");
}
#[test]
fn test_printf_negative_int() {
let mut vars = test_vars();
vars.set_structured("Neg", Value::Number((-7i64).into()));
let result = render("{{ printf \"%05d\" Neg }}", &vars).unwrap();
assert_eq!(result, "-0007");
}
#[test]
fn test_printf_hex() {
let vars = test_vars();
assert_eq!(render("{{ printf \"%x\" 255 }}", &vars).unwrap(), "ff");
assert_eq!(render("{{ printf \"%X\" 255 }}", &vars).unwrap(), "FF");
assert_eq!(render("{{ printf \"%#x\" 255 }}", &vars).unwrap(), "0xff");
}
#[test]
fn test_printf_octal_binary() {
let vars = test_vars();
assert_eq!(render("{{ printf \"%o\" 8 }}", &vars).unwrap(), "10");
assert_eq!(render("{{ printf \"%b\" 5 }}", &vars).unwrap(), "101");
}
#[test]
fn test_printf_string_and_v() {
let vars = test_vars();
assert_eq!(render("{{ printf \"%s\" \"hi\" }}", &vars).unwrap(), "hi");
assert_eq!(render("{{ printf \"%v\" 42 }}", &vars).unwrap(), "42");
}
#[test]
fn test_printf_quote_verb() {
let vars = test_vars();
let result = render("{{ printf \"%q\" \"hi\" }}", &vars).unwrap();
assert_eq!(result, "\"hi\"");
}
#[test]
fn test_printf_bool_verb() {
let vars = test_vars();
let result = render("{{ printf \"%t\" true }}", &vars).unwrap();
assert_eq!(result, "true");
}
#[test]
fn test_printf_char_verb() {
let vars = test_vars();
let result = render("{{ printf \"%c\" 65 }}", &vars).unwrap();
assert_eq!(result, "A");
}
#[test]
fn test_printf_exp_verb_go_style() {
let vars = test_vars();
let result = render("{{ printf \"%.2e\" 12345.678 }}", &vars).unwrap();
assert_eq!(result, "1.23e+04");
}
#[test]
fn test_printf_exp_verb_negative_exponent() {
let vars = test_vars();
let result = render("{{ printf \"%.2e\" 0.0000123 }}", &vars).unwrap();
assert_eq!(result, "1.23e-05");
}
#[test]
fn test_printf_exp_verb_three_digit_exponent() {
let mut vars = test_vars();
vars.set_structured(
"Huge",
Value::Number(serde_json::Number::from_f64(1e100).unwrap()),
);
let result = render("{{ printf \"%.2e\" Huge }}", &vars).unwrap();
assert_eq!(result, "1.00e+100");
}
#[test]
fn test_printf_exp_uppercase() {
let vars = test_vars();
let result = render("{{ printf \"%.2E\" 12345.678 }}", &vars).unwrap();
assert_eq!(result, "1.23E+04");
}
#[test]
fn test_printf_g_verb_plain_decimal() {
let vars = test_vars();
let result = render("{{ printf \"%g\" 3.14 }}", &vars).unwrap();
assert_eq!(result, "3.14");
}
#[test]
fn test_printf_percent_literal() {
let vars = test_vars();
let result = render("{{ printf \"100%%\" }}", &vars).unwrap();
assert_eq!(result, "100%");
}
#[test]
fn test_printf_multiple_args() {
let vars = test_vars();
let result = render("{{ printf \"%s-%04d\" \"v\" 7 }}", &vars).unwrap();
assert_eq!(result, "v-0007");
}
#[test]
fn test_printf_unsupported_verb_errors() {
let vars = test_vars();
let result = render("{{ printf \"%y\" 1 }}", &vars);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("unsupported verb"), "got: {}", msg);
}
#[test]
fn test_print_concatenates() {
let vars = test_vars();
let result = render("{{ print \"a\" \"b\" }}", &vars).unwrap();
assert_eq!(result, "ab");
}
#[test]
fn test_println_joins_with_space_and_newline() {
let vars = test_vars();
let result = render("{{ println \"x\" \"y\" }}", &vars).unwrap();
assert_eq!(result, "x y\n");
}
#[test]
fn test_println_single_arg() {
let vars = test_vars();
let result = render("{{ println \"x\" }}", &vars).unwrap();
assert_eq!(result, "x\n");
}
#[test]
fn test_time_positional_renders_date() {
let vars = test_vars();
let result = render("{{ time \"2006-01-02\" }}", &vars).unwrap();
assert_eq!(result.len(), 10, "got: {}", result);
assert_eq!(result.matches('-').count(), 2, "got: {}", result);
}
#[test]
fn test_printf_g_huge_uses_exponent() {
let mut vars = test_vars();
vars.set_structured(
"Huge",
Value::Number(serde_json::Number::from_f64(1e300).unwrap()),
);
let result = render("{{ printf \"%g\" Huge }}", &vars).unwrap();
assert_eq!(result, "1e+300");
}
#[test]
fn test_printf_g_tiny_uses_exponent() {
let mut vars = test_vars();
vars.set_structured(
"Tiny",
Value::Number(serde_json::Number::from_f64(1e-300).unwrap()),
);
let result = render("{{ printf \"%g\" Tiny }}", &vars).unwrap();
assert_eq!(result, "1e-300");
}
#[test]
fn test_printf_g_million_uses_exponent() {
let vars = test_vars();
let result = render("{{ printf \"%g\" 1000000.0 }}", &vars).unwrap();
assert_eq!(result, "1e+06");
}
#[test]
fn test_printf_g_small_int_decimal() {
let vars = test_vars();
let result = render("{{ printf \"%g\" 3.14 }}", &vars).unwrap();
assert_eq!(result, "3.14");
}
#[test]
fn test_printf_g_eight_digits_uses_exponent() {
let vars = test_vars();
let result = render("{{ printf \"%g\" 99999999.0 }}", &vars).unwrap();
assert_eq!(result, "9.9999999e+07");
}
#[test]
fn test_printf_g_uppercase() {
let mut vars = test_vars();
vars.set_structured(
"Huge",
Value::Number(serde_json::Number::from_f64(1e300).unwrap()),
);
let result = render("{{ printf \"%G\" Huge }}", &vars).unwrap();
assert_eq!(result, "1E+300");
}
#[test]
fn test_printf_int_precision() {
let vars = test_vars();
let result = render("{{ printf \"%.5d\" 7 }}", &vars).unwrap();
assert_eq!(result, "00007");
}
#[test]
fn test_printf_hex_precision() {
let vars = test_vars();
let result = render("{{ printf \"%.3x\" 255 }}", &vars).unwrap();
assert_eq!(result, "0ff");
}
#[test]
fn test_printf_int_precision_and_width() {
let vars = test_vars();
let result = render("{{ printf \"%8.5d\" 7 }}", &vars).unwrap();
assert_eq!(result, " 00007");
}
#[test]
fn test_printf_int_precision_disables_zero_flag() {
let vars = test_vars();
let result = render("{{ printf \"%08.5d\" 7 }}", &vars).unwrap();
assert_eq!(result, " 00007");
}
#[test]
fn test_printf_int_precision_disables_zero_flag_negative() {
let mut vars = test_vars();
vars.set_structured("Neg", Value::Number((-7i64).into()));
let result = render("{{ printf \"%08.3d\" Neg }}", &vars).unwrap();
assert_eq!(result, " -007");
}
#[test]
fn test_printf_int_precision_zero_of_zero_is_empty() {
let mut vars = test_vars();
vars.set_structured("Z", Value::Number(0i64.into()));
let result = render("{{ printf \"%.0d\" Z }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_printf_int_precision_zero_of_zero_width_padded() {
let mut vars = test_vars();
vars.set_structured("Z", Value::Number(0i64.into()));
let result = render("{{ printf \"%5.0d\" Z }}", &vars).unwrap();
assert_eq!(result, " ");
}
#[test]
fn test_printf_float_zero_flag_with_precision_unaffected() {
let vars = test_vars();
let result = render("{{ printf \"%08.2f\" 3.14 }}", &vars).unwrap();
assert_eq!(result, "00003.14");
}
#[test]
fn test_printf_width_cap_errors() {
let vars = test_vars();
let result = render("{{ printf \"%99999999d\" 1 }}", &vars);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("printf width") && msg.contains("exceeds maximum"),
"got: {}",
msg
);
}
#[test]
fn test_printf_precision_cap_errors() {
let vars = test_vars();
let result = render("{{ printf \"%.99999999f\" 1.0 }}", &vars);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("printf precision") && msg.contains("exceeds maximum"),
"got: {}",
msg
);
}
#[test]
fn test_slice_default_start() {
let vars = test_vars();
let result = render("{{ \"abcdef\" | slice(end=3) }}", &vars).unwrap();
assert_eq!(result, "abc");
}
#[test]
fn test_slice_negative_start_string() {
let vars = test_vars();
let result = render("{{ \"abcdef\" | slice(start=-2) }}", &vars).unwrap();
assert_eq!(result, "ef");
}
#[test]
fn test_slice_negative_start_array() {
let mut vars = test_vars();
vars.set_structured("Items", serde_json::json!(["a", "b", "c", "d"]));
let result = render("{{ Items | slice(start=-2) | join(sep=\",\") }}", &vars).unwrap();
assert_eq!(result, "c,d");
}
#[test]
fn test_slice_array_default_start() {
let mut vars = test_vars();
vars.set_structured("Items", serde_json::json!(["a", "b", "c", "d"]));
let result = render("{{ Items | slice(end=2) | join(sep=\",\") }}", &vars).unwrap();
assert_eq!(result, "a,b");
}
#[test]
fn test_print_two_numbers_spaced() {
let mut vars = test_vars();
vars.set_structured("A", Value::Number(1i64.into()));
vars.set_structured("B", Value::Number(2i64.into()));
let result = render("{{ print A B }}", &vars).unwrap();
assert_eq!(result, "1 2");
}
#[test]
fn test_print_two_strings_no_space() {
let vars = test_vars();
let result = render("{{ print \"a\" \"b\" }}", &vars).unwrap();
assert_eq!(result, "ab");
}
#[test]
fn test_print_string_then_number_no_space() {
let mut vars = test_vars();
vars.set_structured("N", Value::Number(1i64.into()));
let result = render("{{ print \"a\" N }}", &vars).unwrap();
assert_eq!(result, "a1");
}
#[test]
fn test_printf_g_explicit_precision_exponent_trims_zeros() {
let mut vars = test_vars();
vars.set_structured(
"V",
Value::Number(serde_json::Number::from_f64(1200000.0).unwrap()),
);
let result = render("{{ printf \"%.3g\" V }}", &vars).unwrap();
assert_eq!(result, "1.2e+06");
}
#[test]
fn test_render_preserves_emoji_in_literal_footer() {
let vars = test_vars();
let result = render("Released with [anodizer](https://x) 🦀", &vars).unwrap();
assert_eq!(result, "Released with [anodizer](https://x) 🦀");
assert!(!result.contains('\u{f0}'), "no mojibake `ð`: {result:?}");
}
#[test]
fn test_value_to_string_bool_arm_via_in_function() {
let vars = test_vars();
let result = render(
"{% set items = [true, false] %}{% if items | in(value=\"true\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_value_to_string_bool_false_arm_via_in_function() {
let vars = test_vars();
let result = render(
"{% set items = [true, false] %}{% if items | in(value=\"false\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_value_to_string_array_arm_via_printf_v() {
let mut vars = test_vars();
vars.set_structured("Arr", serde_json::json!(["a", "b"]));
let result = render("{{ printf \"%v\" Arr }}", &vars).unwrap();
assert!(
!result.is_empty(),
"array should render via JSON fallback: {result}"
);
assert!(
result.contains("a") && result.contains("b"),
"got: {result}"
);
}
#[test]
fn test_printf_string_already_wider_than_width() {
let vars = test_vars();
let result = render("{{ printf \"%2s\" \"hello\" }}", &vars).unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_printf_zero_pad_string_verb() {
let vars = test_vars();
let result = render("{{ printf \"%05s\" \"hi\" }}", &vars).unwrap();
assert_eq!(result, "000hi");
}
#[test]
fn test_printf_space_flag_positive_int() {
let vars = test_vars();
let result = render("{{ printf \"% d\" 7 }}", &vars).unwrap();
assert_eq!(result, " 7");
}
#[test]
fn test_printf_space_flag_negative_int() {
let mut vars = test_vars();
vars.set_structured("Neg", Value::Number((-3i64).into()));
let result = render("{{ printf \"% d\" Neg }}", &vars).unwrap();
assert_eq!(result, "-3");
}
#[test]
fn test_printf_g_integer_value_no_dot() {
let mut vars = test_vars();
vars.set_structured(
"V",
Value::Number(serde_json::Number::from_f64(42.0).unwrap()),
);
let result = render("{{ printf \"%g\" V }}", &vars).unwrap();
assert_eq!(result, "42");
}
#[test]
fn test_printf_g_explicit_precision_decimal_branch() {
let vars = test_vars();
let result = render("{{ printf \"%.4g\" 3.14159 }}", &vars).unwrap();
assert_eq!(result, "3.142");
}
#[test]
fn test_printf_g_explicit_precision_decimal_branch_rounds() {
let vars = test_vars();
let result = render("{{ printf \"%.2g\" 3.14 }}", &vars).unwrap();
assert_eq!(result, "3.1");
}
#[test]
fn test_printf_c_verb_invalid_code_point() {
let mut vars = test_vars();
vars.set_structured("Big", Value::Number((0x110000u64).into()));
let result = render("{{ printf \"%c\" Big }}", &vars);
assert!(result.is_err(), "%c with invalid code point must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("valid code point") || msg.contains("%c"),
"got: {msg}"
);
}
#[test]
fn test_printf_d_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%d\" \"hello\" }}", &vars);
assert!(result.is_err(), "%d with string must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("expects an integer") || msg.contains("%d"),
"got: {msg}"
);
}
#[test]
fn test_printf_x_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%x\" \"nope\" }}", &vars);
assert!(result.is_err(), "%x with non-integer must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("expects an integer") || msg.contains("%x"),
"got: {msg}"
);
}
#[test]
fn test_printf_o_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%o\" \"nope\" }}", &vars);
assert!(result.is_err(), "%o with non-integer must error");
}
#[test]
fn test_printf_b_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%b\" \"nope\" }}", &vars);
assert!(result.is_err(), "%b with non-integer must error");
}
#[test]
fn test_printf_hash_binary_prefix() {
let vars = test_vars();
let result = render("{{ printf \"%#b\" 5 }}", &vars).unwrap();
assert_eq!(result, "0b101");
}
#[test]
fn test_printf_hash_octal_prefix() {
let vars = test_vars();
let result = render("{{ printf \"%#o\" 8 }}", &vars).unwrap();
assert_eq!(result, "010");
}
#[test]
fn test_printf_hash_hex_upper_prefix() {
let vars = test_vars();
let result = render("{{ printf \"%#X\" 255 }}", &vars).unwrap();
assert_eq!(result, "0XFF");
}
#[test]
fn test_printf_f_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%f\" \"nope\" }}", &vars);
assert!(result.is_err(), "%f with non-numeric must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("expects a numeric") || msg.contains("%f"),
"got: {msg}"
);
}
#[test]
fn test_printf_e_verb_string_type_error() {
let vars = test_vars();
let result = render("{{ printf \"%e\" \"nope\" }}", &vars);
assert!(result.is_err(), "%e with non-numeric must error");
}
#[test]
fn test_printf_empty_precision_means_zero() {
let mut vars = test_vars();
vars.set_structured("Z", Value::Number(0i64.into()));
let result = render("{{ printf \"%.d\" Z }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_printf_empty_precision_nonzero_value() {
let vars = test_vars();
let result = render("{{ printf \"%.d\" 42 }}", &vars).unwrap();
assert_eq!(result, "42");
}
#[test]
fn test_base_tera_env_or_default_placeholder_unset_returns_default() {
use super::base_tera::BASE_TERA;
let mut tera = BASE_TERA.clone();
tera.add_raw_template(
"t",
"{{ envOrDefault(name=\"ANODIZER_T7_PLACEHOLDER_UNSET_DEFAULT\", default=\"sentinel\") }}",
)
.unwrap();
let result = tera.render("t", &tera::Context::new()).unwrap();
assert_eq!(result, "sentinel");
}
#[test]
#[serial_test::serial(env_placeholder)]
fn test_base_tera_env_or_default_placeholder_reads_process_env() {
use super::base_tera::BASE_TERA;
let key = "ANODIZER_T7_PLACEHOLDER_ENVSET";
unsafe { std::env::set_var(key, "fromenv") };
let mut tera = BASE_TERA.clone();
tera.add_raw_template(
"t",
"{{ envOrDefault(name=\"ANODIZER_T7_PLACEHOLDER_ENVSET\", default=\"missed\") }}",
)
.unwrap();
let result = tera.render("t", &tera::Context::new()).unwrap();
unsafe { std::env::remove_var(key) };
assert_eq!(result, "fromenv");
}
#[test]
fn test_base_tera_is_env_set_placeholder_unset() {
use super::base_tera::BASE_TERA;
let mut tera = BASE_TERA.clone();
tera.add_raw_template("t", "{% if isEnvSet(name=\"ANODIZER_T7_PLACEHOLDER_ISSET_UNSET\") %}set{% else %}unset{% endif %}")
.unwrap();
let result = tera.render("t", &tera::Context::new()).unwrap();
assert_eq!(result, "unset");
}
#[test]
#[serial_test::serial(env_placeholder)]
fn test_base_tera_is_env_set_placeholder_set() {
use super::base_tera::BASE_TERA;
let key = "ANODIZER_T7_PLACEHOLDER_ISSET_SET";
unsafe { std::env::set_var(key, "yes") };
let mut tera = BASE_TERA.clone();
tera.add_raw_template("t", "{% if isEnvSet(name=\"ANODIZER_T7_PLACEHOLDER_ISSET_SET\") %}set{% else %}unset{% endif %}")
.unwrap();
let result = tera.render("t", &tera::Context::new()).unwrap();
unsafe { std::env::remove_var(key) };
assert_eq!(result, "set");
}
#[test]
fn test_hash_sha256_missing_file_errors() {
let vars = test_vars();
let result = render(
"{{ sha256(s=\"/nonexistent/path/anodizer_test_missing_abc\") }}",
&vars,
);
assert!(result.is_err(), "hash of missing file must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("failed to read file") || msg.contains("sha256"),
"got: {msg}"
);
}
#[test]
fn test_hash_sha224() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha224(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"ea09ae9cc6768c50fcee903ed054556e5bfc8347907f12598aa24193"
);
}
#[test]
fn test_hash_sha384() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha384(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"59e1748777448c69de6b800d7a33bbfb9ff1b463e44354c3553bcdb9c666fa90125a3c79f90397bdf5f6a13de828684f"
);
}
#[test]
fn test_hash_sha3_224() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha3_224(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"b87f88c72702fff1748e58b87e9141a42c0dbedc29a78cb0d4a5cd81"
);
}
#[test]
fn test_hash_sha3_256() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha3_256(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"3338be694f50c5f338814986cdf0686453a888b84f424d792af4b9202398f392"
);
}
#[test]
fn test_hash_sha3_384() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha3_384(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"720aea11019ef06440fbf05d87aa24680a2153df3907b23631e7177ce620fa1330ff07c0fddee54699a4c3ee0ee9d887"
);
}
#[test]
fn test_hash_sha3_512() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ sha3_512(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"75d527c368f2efe848ecf6b073a36767800805e9eef2b1857d5f984f036eb6df891d75f72d9b154518c1cd58835286d1da9a38deba3de98b5a53e5ed78a84976"
);
}
#[test]
fn test_hash_blake2b() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ blake2b(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"e4cfa39a3d37be31c59609e807970799caa68a19bfaa15135f165085e01d41a65ba1e1b146aeb6bd0092b49eac214c103ccfa3a365954bbbe52f74a2b3620c94"
);
}
#[test]
fn test_hash_blake2s() {
let vars = test_vars();
let (_dir, path) = hash_test_file();
let tmpl = format!("{{{{ blake2s(s=\"{path}\") }}}}");
let result = render(&tmpl, &vars).unwrap();
assert_eq!(
result,
"19213bacc58dee6dbde3ceb9a47cbb330b3d86f8cca8997eb00be456f140ca25"
);
}
#[test]
fn test_base_tera_time_placeholder_produces_date() {
use super::base_tera::BASE_TERA;
let mut tera = BASE_TERA.clone();
tera.add_raw_template("t", "{{ time(format=\"%Y-%m-%d\") }}")
.unwrap();
let ctx = tera::Context::new();
let result = tera.render("t", &ctx).unwrap();
assert_eq!(result.len(), 10, "expected YYYY-MM-DD, got: {result}");
assert_eq!(result.matches('-').count(), 2);
}
#[test]
#[serial_test::serial(cwd)]
fn test_abs_filter_relative_path() {
let mut vars = test_vars();
vars.set("RelPath", "some/relative/path");
let result = render("{{ RelPath | abs }}", &vars).unwrap();
assert!(
std::path::Path::new(&result).is_absolute(),
"abs filter on relative path must return absolute path: {result}"
);
assert!(result.ends_with("some/relative/path"), "got: {result}");
}
#[test]
fn test_abs_filter_absolute_path_passthrough() {
let mut vars = test_vars();
let already_abs = if cfg!(windows) {
"C:/already/absolute"
} else {
"/already/absolute"
};
vars.set("AbsPath", already_abs);
let result = render("{{ AbsPath | abs }}", &vars).unwrap();
assert_eq!(result, already_abs);
}
#[test]
fn test_list_function_creates_array() {
let vars = test_vars();
let result = render(
"{{ list(items=[\"x\", \"y\", \"z\"]) | join(sep=\",\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "x,y,z");
}
#[test]
fn test_list_function_missing_items_error() {
let vars = test_vars();
let result = render("{{ list() }}", &vars);
assert!(result.is_err(), "list without items should error");
}
#[test]
fn test_map_function_odd_args_error() {
let vars = test_vars();
let result = render("{{ $m := map \"a\" \"1\" \"b\" }}{{ $m }}", &vars);
assert!(result.is_err(), "map with odd arg count must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("even") || msg.contains("key-value"),
"got: {msg}"
);
}
#[test]
fn test_re_replace_all_filter_missing_pattern_error() {
let vars = test_vars();
let result = render("{{ Tag | reReplaceAll(replacement=\"x\") }}", &vars);
assert!(
result.is_err(),
"reReplaceAll filter without pattern should error"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("pattern") || msg.contains("reReplaceAll"),
"got: {msg}"
);
}
#[test]
fn test_re_replace_all_filter_missing_replacement_error() {
let vars = test_vars();
let result = render("{{ Tag | reReplaceAll(pattern=\"v\") }}", &vars);
assert!(
result.is_err(),
"reReplaceAll filter without replacement should error"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("replacement") || msg.contains("reReplaceAll"),
"got: {msg}"
);
}
#[test]
fn test_english_join_filter_zero_items() {
let mut vars = test_vars();
vars.set_structured("Names", Value::Array(vec![]));
let result = render("{{ Names | englishJoin }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_english_join_filter_one_item() {
let mut vars = test_vars();
vars.set_structured("Names", Value::Array(vec![Value::String("alice".into())]));
let result = render("{{ Names | englishJoin }}", &vars).unwrap();
assert_eq!(result, "alice");
}
#[test]
fn test_english_join_filter_two_items() {
let mut vars = test_vars();
vars.set_structured(
"Names",
Value::Array(vec![
Value::String("alice".into()),
Value::String("bob".into()),
]),
);
let result = render("{{ Names | englishJoin }}", &vars).unwrap();
assert_eq!(result, "alice and bob");
}
#[test]
fn test_english_join_filter_three_items_no_oxford() {
let mut vars = test_vars();
vars.set_structured(
"Names",
Value::Array(vec![
Value::String("a".into()),
Value::String("b".into()),
Value::String("c".into()),
]),
);
let result = render("{{ Names | englishJoin(oxford=false) }}", &vars).unwrap();
assert_eq!(result, "a, b and c");
}
#[test]
fn test_english_join_filter_non_array_errors() {
let vars = test_vars();
let result = render("{{ ProjectName | englishJoin }}", &vars);
assert!(
result.is_err(),
"englishJoin filter on non-array must error"
);
}
#[test]
fn test_reverse_filter_pipe_missing_arg_error() {
let vars = test_vars();
let result = render("{{ ProjectName | reverseFilter }}", &vars);
assert!(result.is_err(), "reverseFilter without regexp must error");
}
#[test]
fn test_filter_function_string_input_keeps_matching_lines() {
let mut vars = test_vars();
vars.set_structured("Lines", Value::String("line1\nline2\nline3".to_string()));
let result = render("{{ filter(items=Lines, regexp=\"2\") }}", &vars).unwrap();
assert_eq!(result, "line2");
}
#[test]
fn test_filter_function_string_input_no_match() {
let mut vars = test_vars();
vars.set_structured("Lines", Value::String("aaa\nbbb\nccc".to_string()));
let result = render("{{ filter(items=Lines, regexp=\"xyz\") }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_filter_function_number_items_error() {
let mut vars = test_vars();
vars.set_structured("N", Value::Number(42i64.into()));
let result = render("{{ filter(items=N, regexp=\"x\") }}", &vars);
assert!(
result.is_err(),
"filter with non-string/non-array items must error"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("string or array") || msg.contains("filter"),
"got: {msg}"
);
}
#[test]
fn test_reverse_filter_function_string_input() {
let mut vars = test_vars();
vars.set_structured("Lines", Value::String("line1\nline2\nline3".to_string()));
let result = render("{{ reverseFilter(items=Lines, regexp=\"2\") }}", &vars).unwrap();
assert!(result.contains("line1"), "got: {result}");
assert!(!result.contains("line2"), "got: {result}");
assert!(result.contains("line3"), "got: {result}");
}
#[test]
fn test_reverse_filter_function_array_input() {
let vars = test_vars();
let result = render(
"{{ reverseFilter(items=[\"apple\", \"banana\", \"avocado\"], regexp=\"^a\") }}",
&vars,
)
.unwrap();
assert!(result.contains("banana"), "got: {result}");
assert!(!result.contains("apple"), "got: {result}");
assert!(!result.contains("avocado"), "got: {result}");
}
#[test]
fn test_reverse_filter_function_invalid_type_error() {
let mut vars = test_vars();
vars.set_structured("N", Value::Number(42i64.into()));
let result = render("{{ reverseFilter(items=N, regexp=\"x\") }}", &vars);
assert!(
result.is_err(),
"reverseFilter with non-string/non-array must error"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("string or array") || msg.contains("reverseFilter"),
"got: {msg}"
);
}
#[test]
fn test_reverse_filter_function_missing_items_error() {
let vars = test_vars();
let result = render("{{ reverseFilter(regexp=\"x\") }}", &vars);
assert!(result.is_err(), "reverseFilter without items must error");
}
#[test]
fn test_reverse_filter_function_missing_regexp_error() {
let vars = test_vars();
let result = render("{{ reverseFilter(items=[\"a\", \"b\"]) }}", &vars);
assert!(result.is_err(), "reverseFilter without regexp must error");
}
#[test]
fn test_trim_function_form() {
let vars = test_vars();
let result = render("{{ trim(s=\" hello \") }}", &vars).unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_trim_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ trim() }}", &vars);
assert!(result.is_err(), "trim() without s must error");
}
#[test]
fn test_title_function_form() {
let vars = test_vars();
let result = render("{{ title(s=\"hello world\") }}", &vars).unwrap();
assert_eq!(result, "Hello World");
}
#[test]
fn test_title_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ title() }}", &vars);
assert!(result.is_err(), "title() without s must error");
}
#[test]
fn test_tolower_function_form() {
let vars = test_vars();
let result = render("{{ tolower(s=\"HELLO\") }}", &vars).unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_tolower_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ tolower() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_toupper_function_form() {
let vars = test_vars();
let result = render("{{ toupper(s=\"hello\") }}", &vars).unwrap();
assert_eq!(result, "HELLO");
}
#[test]
fn test_toupper_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ toupper() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_trimprefix_function_form() {
let vars = test_vars();
let result = render("{{ trimprefix(s=\"v1.2.3\", prefix=\"v\") }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_trimprefix_function_form_no_match() {
let vars = test_vars();
let result = render("{{ trimprefix(s=\"1.2.3\", prefix=\"v\") }}", &vars).unwrap();
assert_eq!(result, "1.2.3");
}
#[test]
fn test_trimprefix_function_form_missing_args_error() {
let vars = test_vars();
let result = render("{{ trimprefix(s=\"v1.2.3\") }}", &vars);
assert!(result.is_err(), "trimprefix fn without prefix should error");
}
#[test]
fn test_trimsuffix_function_form() {
let vars = test_vars();
let result = render(
"{{ trimsuffix(s=\"hello.tar.gz\", suffix=\".tar.gz\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_trimsuffix_function_form_no_match() {
let vars = test_vars();
let result = render(
"{{ trimsuffix(s=\"hello.zip\", suffix=\".tar.gz\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "hello.zip");
}
#[test]
fn test_trimsuffix_function_form_missing_args_error() {
let vars = test_vars();
let result = render("{{ trimsuffix(s=\"hello\") }}", &vars);
assert!(result.is_err(), "trimsuffix fn without suffix should error");
}
#[test]
fn test_dir_function_form() {
let vars = test_vars();
let result = render("{{ dir(s=\"/foo/bar/baz.txt\") }}", &vars).unwrap();
assert_eq!(result, "/foo/bar");
}
#[test]
fn test_dir_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ dir() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_base_function_form() {
let vars = test_vars();
let result = render("{{ base(s=\"/foo/bar/baz.txt\") }}", &vars).unwrap();
assert_eq!(result, "baz.txt");
}
#[test]
fn test_base_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ base() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_abs_function_form_absolute() {
let vars = test_vars();
let already_abs = if cfg!(windows) {
"C:/already/absolute"
} else {
"/already/absolute"
};
let result = render(&format!("{{{{ abs(s=\"{already_abs}\") }}}}"), &vars).unwrap();
assert_eq!(result, already_abs);
}
#[test]
#[serial_test::serial(cwd)]
fn test_abs_function_form_relative() {
let vars = test_vars();
let result = render("{{ abs(s=\"relative/path\") }}", &vars).unwrap();
assert!(
std::path::Path::new(&result).is_absolute(),
"abs(s=relative) must be absolute: {result}"
);
assert!(result.ends_with("relative/path"), "got: {result}");
}
#[test]
fn test_abs_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ abs() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_url_path_escape_function_form() {
let vars = test_vars();
let result = render("{{ urlPathEscape(s=\"hello world\") }}", &vars).unwrap();
assert_eq!(result, "hello%20world");
}
#[test]
fn test_url_path_escape_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ urlPathEscape() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_mdv2escape_function_form() {
let vars = test_vars();
let result = render("{{ mdv2escape(s=\"hello_world\") }}", &vars).unwrap();
assert_eq!(result, "hello\\_world");
}
#[test]
fn test_mdv2escape_function_form_missing_s_error() {
let vars = test_vars();
let result = render("{{ mdv2escape() }}", &vars);
assert!(result.is_err());
}
#[test]
fn test_incpatch_filter_form_increments() {
let vars = test_vars();
let result = render("{{ \"2.4.6\" | incpatch }}", &vars).unwrap();
assert_eq!(result, "2.4.7");
}
#[test]
fn test_incminor_filter_form_increments() {
let vars = test_vars();
let result = render("{{ \"2.4.6\" | incminor }}", &vars).unwrap();
assert_eq!(result, "2.5.0");
}
#[test]
fn test_incmajor_filter_form_increments() {
let vars = test_vars();
let result = render("{{ \"2.4.6\" | incmajor }}", &vars).unwrap();
assert_eq!(result, "3.0.0");
}
#[test]
fn test_index_array_string_key_errors() {
let mut vars = test_vars();
vars.set_structured("Arr", serde_json::json!(["a", "b", "c"]));
let result = render("{{ index(collection=Arr, key=\"notanumber\") }}", &vars);
assert!(result.is_err(), "index on array with string key must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("array index must be a number") || msg.contains("index"),
"got: {msg}"
);
}
#[test]
fn test_index_non_collection_returns_empty() {
let vars = test_vars();
let result = render("{{ index(collection=\"scalar\", key=\"k\") }}", &vars).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_slice_filter_number_errors() {
let mut vars = test_vars();
vars.set_structured("N", Value::Number(42i64.into()));
let result = render("{{ N | slice(start=0, end=1) }}", &vars);
assert!(result.is_err(), "slice of a number must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("string or array") || msg.contains("slice"),
"got: {msg}"
);
}
#[test]
fn test_printf_e_lowercase_default_precision() {
let mut vars = test_vars();
vars.set_structured(
"F",
Value::Number(serde_json::Number::from_f64(1234.5).unwrap()),
);
let result = render("{{ printf \"%e\" F }}", &vars).unwrap();
assert_eq!(result, "1.234500e+03");
}
#[test]
fn test_printf_e_uppercase_uses_capital_exponent_letter() {
let mut vars = test_vars();
vars.set_structured(
"F",
Value::Number(serde_json::Number::from_f64(1234.5).unwrap()),
);
let result = render("{{ printf \"%E\" F }}", &vars).unwrap();
assert_eq!(result, "1.234500E+03");
}
#[test]
fn test_printf_e_explicit_precision_two() {
let mut vars = test_vars();
vars.set_structured(
"F",
Value::Number(serde_json::Number::from_f64(0.0001234).unwrap()),
);
let result = render("{{ printf \"%.2e\" F }}", &vars).unwrap();
assert_eq!(result, "1.23e-04");
}
#[test]
fn test_printf_e_negative_value_keeps_sign_before_mantissa() {
let mut vars = test_vars();
vars.set_structured(
"F",
Value::Number(serde_json::Number::from_f64(-1234.5).unwrap()),
);
let result = render("{{ printf \"%e\" F }}", &vars).unwrap();
assert_eq!(result, "-1.234500e+03");
}
#[test]
fn test_split_function_form_returns_array() {
let vars = test_vars();
let result = render(
"{{ split(s=\"a,b,c\", sep=\",\") | join(sep=\"|\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "a|b|c");
}
#[test]
fn test_split_function_form_single_field_no_separator_match() {
let vars = test_vars();
let result = render(
"{{ split(s=\"nodelimiter\", sep=\",\") | join(sep=\"|\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "nodelimiter");
}
#[test]
fn test_split_function_form_missing_sep_errors() {
let vars = test_vars();
let result = render("{{ split(s=\"a,b\") }}", &vars);
assert!(result.is_err(), "split without sep must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("sep") || msg.contains("split"), "got: {msg}");
}
#[test]
fn test_map_pairs_named_form_builds_object() {
let vars = test_vars();
let result = render(
"{% set m = map(pairs=[\"k1\", \"v1\", \"k2\", \"v2\"]) %}{{ index(collection=m, key=\"k2\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "v2");
}
#[test]
fn test_map_pairs_odd_count_errors() {
let vars = test_vars();
let result = render("{{ map(pairs=[\"k1\", \"v1\", \"k2\"]) }}", &vars);
assert!(result.is_err(), "odd pair count must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("even number") || msg.contains("key-value"),
"got: {msg}"
);
}
#[test]
fn test_map_pairs_missing_pairs_errors() {
let vars = test_vars();
let result = render("{{ map() }}", &vars);
assert!(result.is_err(), "map without pairs must error");
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("pairs") || msg.contains("map"), "got: {msg}");
}
#[test]
fn test_contains_any_function_form_found() {
let vars = test_vars();
let result = render(
"{% if contains_any(items=[\"a\", \"b\", \"c\"], value=\"b\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_contains_any_function_form_not_found() {
let vars = test_vars();
let result = render(
"{% if contains_any(items=[\"a\", \"b\"], value=\"z\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "no");
}
#[test]
fn test_contains_any_filter_form_found() {
let vars = test_vars();
let result = render(
"{% set items = [\"x\", \"y\"] %}{% if items | contains_any(value=\"y\") %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_contains_any_filter_form_numeric_value_stringified() {
let vars = test_vars();
let result = render(
"{% set items = [1, 2, 3] %}{% if items | contains_any(value=2) %}yes{% else %}no{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_bool_var_interpolates_as_true_false_strings() {
let mut vars = TemplateVars::new();
vars.set_bool("IsSnapshot", true);
assert_eq!(render("{{ IsSnapshot }}", &vars).unwrap(), "true");
vars.set_bool("IsSnapshot", false);
assert_eq!(render("{{ IsSnapshot }}", &vars).unwrap(), "false");
assert_eq!(render("{{ .IsSnapshot }}", &vars).unwrap(), "false");
}
#[test]
fn test_bool_var_not_and_bare_if_evaluate_correctly() {
let mut vars = TemplateVars::new();
vars.set_bool("IsSnapshot", true);
assert_eq!(render("{{ not IsSnapshot }}", &vars).unwrap(), "false");
assert_eq!(
render("{% if IsSnapshot %}SNAP{% else %}REL{% endif %}", &vars).unwrap(),
"SNAP"
);
vars.set_bool("IsSnapshot", false);
assert_eq!(render("{{ not IsSnapshot }}", &vars).unwrap(), "true");
assert_eq!(
render("{% if IsSnapshot %}SNAP{% else %}REL{% endif %}", &vars).unwrap(),
"REL"
);
assert_eq!(
render("{{ if not .IsSnapshot }}REL{{ else }}SNAP{{ end }}", &vars).unwrap(),
"REL"
);
}
#[test]
fn test_bool_vars_combine_with_and_or() {
let mut vars = TemplateVars::new();
vars.set_bool("IsSnapshot", false);
vars.set_bool("IsHarness", true);
assert_eq!(
render("{{ not IsSnapshot or IsHarness }}", &vars).unwrap(),
"true"
);
vars.set_bool("IsHarness", false);
assert_eq!(
render("{{ not IsSnapshot or IsHarness }}", &vars).unwrap(),
"true"
);
vars.set_bool("IsSnapshot", true);
assert_eq!(
render("{{ not IsSnapshot or IsHarness }}", &vars).unwrap(),
"false"
);
}
#[test]
fn test_nightly_build_number_interpolates_and_compares() {
let mut vars = TemplateVars::new();
vars.set_structured("NightlyBuild", Value::from(42u64));
assert_eq!(render("{{ NightlyBuild }}", &vars).unwrap(), "42");
assert_eq!(
render(
"{% if NightlyBuild == 42 %}yes{% else %}no{% endif %}",
&vars
)
.unwrap(),
"yes"
);
assert_eq!(
render("{% if NightlyBuild > 0 %}yes{% else %}no{% endif %}", &vars).unwrap(),
"yes"
);
}
#[test]
fn test_set_and_set_structured_are_mutually_exclusive() {
let mut vars = TemplateVars::new();
vars.set_bool("IsSnapshot", false);
vars.set("IsSnapshot", "true");
assert!(vars.get_structured("IsSnapshot").is_none());
assert_eq!(render("{{ not IsSnapshot }}", &vars).unwrap(), "false");
vars.set_bool("IsSnapshot", false);
assert!(vars.get("IsSnapshot").is_none());
assert_eq!(vars.get_structured("IsSnapshot"), Some(&Value::Bool(false)));
}
#[test]
fn test_find_stale_typed_compare_detects_string_compares() {
for tpl in [
r#"{% if IsSnapshot == "false" %}true{% endif %}"#,
r#"{% if IsSnapshot == "false" or IsHarness == "true" %}true{% endif %}"#,
r#"{{ IsHarness != 'true' }}"#,
r#"{{ "true" == .IsNightly }}"#,
r#"{{ if eq .IsSnapshot "false" }}true{{ end }}"#,
r#"{{ if ne .IsDraft "true" }}x{{ end }}"#,
r#"{% if NightlyBuild == "0" %}first{% endif %}"#,
] {
assert!(
find_stale_typed_compare(tpl).is_some(),
"must flag stale typed compare: {tpl}"
);
}
}
#[test]
fn test_find_stale_typed_compare_allows_natural_and_unrelated_forms() {
for tpl in [
"{{ not IsSnapshot }}",
"{{ not IsSnapshot or IsHarness }}",
"{% if IsSnapshot %}snap{% endif %}",
"{{ if not .IsSnapshot }}rel{{ end }}",
r#"{% if SomeUserVar == "false" %}x{% endif %}"#,
r#"{% if GitTreeState == "dirty" %}x{% endif %}"#,
"{% if NightlyBuild == 0 %}first{% endif %}",
"{% if IsSnapshot == false %}rel{% endif %}",
] {
assert!(
find_stale_typed_compare(tpl).is_none(),
"must not flag: {tpl}"
);
}
}
#[test]
fn test_find_stale_typed_compare_ignores_namespaced_user_vars() {
for tpl in [
r#"{% if Var.IsSnapshot == "false" %}x{% endif %}"#,
r#"{{ eq .Var.IsSnapshot "false" }}"#,
] {
assert!(
find_stale_typed_compare(tpl).is_none(),
"must not flag namespaced user var: {tpl}"
);
}
assert_eq!(
find_stale_typed_compare(r#"{% if IsSnapshot == "false" %}x{% endif %}"#),
Some(r#"IsSnapshot == "false""#)
);
}
#[test]
fn test_unset_clears_key_from_both_maps() {
let mut vars = TemplateVars::new();
vars.set_bool("IsSnapshot", true);
assert!(vars.unset("IsSnapshot"));
assert!(vars.get("IsSnapshot").is_none());
assert!(vars.get_structured("IsSnapshot").is_none());
vars.set("Tag", "v1.0.0");
assert!(vars.unset_structured("Tag"));
assert!(vars.get("Tag").is_none());
assert!(vars.get_structured("Tag").is_none());
assert!(!vars.unset("Missing"));
assert!(!vars.unset_structured("Missing"));
}
#[test]
fn test_shell_param_length_renders_literal() {
let vars = test_vars();
let result = render("bash -c 'n=${#f[@]}'", &vars).unwrap();
assert_eq!(result, "bash -c 'n=${#f[@]}'");
}
#[test]
fn test_shell_param_length_gate_pattern_round_trips() {
let vars = test_vars();
let result = render("(( ${#f[@]} )) || exit 1", &vars).unwrap();
assert_eq!(result, "(( ${#f[@]} )) || exit 1");
}
#[test]
fn test_shell_param_length_mixed_with_tera_var() {
let vars = test_vars();
let result = render("echo {{ Tag }}; n=${#arr[@]}", &vars).unwrap();
assert_eq!(result, "echo v1.2.3; n=${#arr[@]}");
}
#[test]
fn test_shell_param_length_multiple_occurrences() {
let vars = test_vars();
let result = render("a=${#x[@]}; b=${#y[*]}; c=${#z}", &vars).unwrap();
assert_eq!(result, "a=${#x[@]}; b=${#y[*]}; c=${#z}");
}
#[test]
fn test_standalone_tera_comment_still_stripped() {
let vars = test_vars();
let result = render("pre {# c #} post", &vars).unwrap();
assert_eq!(result, "pre post");
}
#[test]
fn docs_undefined_variable_examples_stay_live_accurate() {
use super::render;
use super::vars::TemplateVars;
let vars = TemplateVars::new();
let err = render("{{ Typo }}", &vars).unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("Variable `Typo` is not defined"),
"got: {chain}"
);
assert!(
chain.contains("Available variables:"),
"tera must still append the live context keys: {chain}"
);
assert_eq!(render("{{ Typo ~ '-rc1' }}", &vars).unwrap(), "-rc1");
assert_eq!(render("{{ .Env.MISSING }}", &vars).unwrap(), "");
assert_eq!(
render("{{ Typo or \"default\" }}", &vars).unwrap(),
"default"
);
assert_eq!(render("{{ Some?.Missing or \"\" }}", &vars).unwrap(), "");
}
#[test]
fn test_date_filter_rfc3339_string() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56+00:00");
assert_eq!(
render("{{ Now | date(format='%Y%m%d') }}", &vars).unwrap(),
"20260405"
);
}
#[test]
fn test_date_filter_default_format() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
assert_eq!(render("{{ Now | date }}", &vars).unwrap(), "2026-04-05");
}
#[test]
fn test_date_filter_timestamp_input() {
let mut vars = test_vars();
vars.set_structured("Epoch", serde_json::json!(1715000000));
assert_eq!(
render("{{ Epoch | date(format=\"%Y-%m-%d\") }}", &vars).unwrap(),
"2024-05-06"
);
}
#[test]
fn test_date_filter_plain_date_string() {
let mut vars = test_vars();
vars.set("Day", "2026-04-05");
assert_eq!(
render("{{ Day | date(format=\"%d/%m/%Y\") }}", &vars).unwrap(),
"05/04/2026"
);
}
#[test]
fn test_date_filter_invalid_format_errors() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
let err = render("{{ Now | date(format=\"%¾\") }}", &vars).unwrap_err();
assert!(
format!("{err:#}").contains(
"invalid format `%¾` — expected chrono strftime specifiers (e.g. `%Y-%m-%d`)"
),
"got: {err:#}"
);
}
#[test]
fn test_date_filter_timestamp_timezone_converts() {
let mut vars = test_vars();
vars.set_structured("Epoch", serde_json::json!(1719878400));
assert_eq!(
render(
"{{ Epoch | date(format=\"%Y-%m-%d %H:%M\", timezone=\"America/New_York\") }}",
&vars,
)
.unwrap(),
"2024-07-01 20:00"
);
assert_eq!(
render(
"{{ Epoch | date(format=\"%Y-%m-%d %H:%M\", timezone=\"UTC\") }}",
&vars,
)
.unwrap(),
"2024-07-02 00:00"
);
}
#[test]
fn test_date_filter_rfc3339_timezone_converts() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
assert_eq!(
render(
"{{ Now | date(format=\"%H:%M\", timezone=\"Europe/Paris\") }}",
&vars,
)
.unwrap(),
"14:34"
);
}
#[test]
fn test_date_filter_naive_datetime_timezone_ignored() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56");
assert_eq!(
render(
"{{ Now | date(format=\"%H:%M\", timezone=\"Europe/Paris\") }}",
&vars,
)
.unwrap(),
"12:34"
);
}
#[test]
fn test_date_filter_invalid_timezone_errors() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
let err = render(
"{{ Now | date(format=\"%Y\", timezone=\"Mars/Olympus_Mons\") }}",
&vars,
)
.unwrap_err();
assert!(
format!("{err:#}").contains(
"error parsing `Mars/Olympus_Mons` as a timezone \
(expected an IANA name, e.g. `America/New_York`)"
),
"got: {err:#}"
);
}
#[test]
fn test_date_filter_locale_arg_errors_explicitly() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
let err = render("{{ Now | date(format=\"%Y\", locale=\"fr_FR\") }}", &vars).unwrap_err();
assert!(
format!("{err:#}").contains(
"the `locale` argument is not supported; remove it — \
output is always formatted in the POSIX locale"
),
"got: {err:#}"
);
}
#[test]
fn test_backtick_string_numeric_content_not_index_rewritten() {
let vars = test_vars();
assert_eq!(render("{{ `v1.0` }}", &vars).unwrap(), "v1.0");
}
#[test]
fn test_backtick_string_as_filter_arg_survives_verbatim() {
let vars = test_vars();
assert_eq!(
render("{{ 'v1.0-x' | replace(from=`v1.0`, to=\"Z\") }}", &vars).unwrap(),
"Z-x"
);
}
#[test]
fn test_string_closing_at_first_quote_after_backslash_matches_engine() {
let vars = test_vars();
assert_eq!(render(r"{{ 'Q\' ~ 'v1.0' }}", &vars).unwrap(), r"Q\v1.0");
}
#[test]
fn test_backtick_string_dollar_content_not_stripped() {
let vars = test_vars();
assert_eq!(render("{{ `$foo` }}", &vars).unwrap(), "$foo");
}
#[test]
fn test_backtick_string_leading_dot_content_not_stripped() {
let vars = test_vars();
assert_eq!(render("{{ `.Field` }}", &vars).unwrap(), ".Field");
}
#[test]
fn test_escaped_quote_inside_string_is_a_loud_error_not_a_silent_change() {
let vars = test_vars();
assert!(render(r#"{{ "a\"b" }}"#, &vars).is_err());
}
fn list_vars() -> TemplateVars {
let mut vars = test_vars();
vars.set_structured("list", serde_json::json!(["banana", "cherry"]));
vars
}
#[test]
fn test_positional_standalone_arg_with_numeric_index() {
assert_eq!(
render("{{ replace list.0 \"a\" \"o\" }}", &list_vars()).unwrap(),
"bonono"
);
}
#[test]
fn test_positional_piped_input_with_numeric_index() {
assert_eq!(
render("{{ list.0 | replace \"a\" \"o\" }}", &list_vars()).unwrap(),
"bonono"
);
}
#[test]
fn test_positional_slice_arg_with_numeric_index() {
assert_eq!(
render("{{ slice list.1 0 3 }}", &list_vars()).unwrap(),
"che"
);
}
#[test]
fn test_optional_numeric_index_renders_end_to_end() {
let mut vars = test_vars();
vars.set_structured("a", serde_json::json!(["first", "second"]));
assert_eq!(render("{{ a?.0 }}", &vars).unwrap(), "first");
}
#[test]
fn test_multiline_block_dot_strip() {
let vars = test_vars();
assert_eq!(render("{{\n .Version }}", &vars).unwrap(), "1.2.3");
}
#[test]
fn test_multiline_block_dollar_var_strip() {
let vars = test_vars();
assert_eq!(
render("{% set foo = \"x\" %}{{\n $foo }}", &vars).unwrap(),
"x"
);
}
#[test]
fn test_multiline_block_go_builtin_rewrite() {
let vars = test_vars();
assert_eq!(
render("{% if eq Os \"linux\"\n%}yes{% endif %}", &vars).unwrap(),
"yes"
);
}
#[test]
fn test_multiline_block_method_call_rewrite() {
let mut vars = test_vars();
vars.set("Now", "2026-04-05T12:34:56Z");
let env = MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1719878400");
assert_eq!(
render_with_env("{{\n .Now.Format \"2006-01-02\" }}", &vars, &env).unwrap(),
"2024-07-02"
);
}
#[test]
fn test_multiline_block_numeric_index_rewrite() {
assert_eq!(render("{{\n list.0 }}", &list_vars()).unwrap(), "banana");
}
#[test]
fn test_multiline_block_backslash_shim() {
let vars = test_vars();
assert_eq!(
render(
"{{\n reReplaceAll(pattern=\"(\\w+)\", input=\"abc\", replacement=\"[$1]\") }}",
&vars,
)
.unwrap(),
"[abc]"
);
}
#[test]
fn test_raw_block_contents_render_verbatim() {
let vars = test_vars();
for literal in [
"{{ .Version }}",
"{{ trimprefix .Tag \"v\" }}",
"{{ trimprefix (base .Path) \"v\" }}",
"{{ .Version | replace \"v\" \"\" }}",
"{{ if .X }}y{{ end }}",
"{{ list.0 }}",
"{{ $v := .Tag }}",
r"grep '\d\+' file",
r#"{{ reReplaceAll(pattern="(\w+)", replacement="$1") }}"#,
"${#VAR}",
"echo ${#ARR[@]}",
] {
let template = format!("{{% raw %}}{literal}{{% endraw %}}");
assert_eq!(render(&template, &vars).unwrap(), literal, "raw: {literal}");
}
}
#[test]
fn test_positional_calls_render_in_every_pipeline_slot() {
let vars = test_vars();
for (template, expected) in [
("{{ trimprefix .Tag \"v\" | upper }}", "1.2.3"),
("{{ trimprefix \"vabc\" \"v\" | upper }}", "ABC"),
("{{ .Version | replace \".\" \"-\" | upper }}", "1-2-3"),
("{{ list \"a\" \"b\" | join(sep=\"-\") }}", "a-b"),
(
"{{ .Tag | trimprefix \"v\" | replace \".\" \"-\" }}",
"1-2-3",
),
] {
assert_eq!(render(template, &vars).unwrap(), expected, "{template}");
}
}
#[test]
fn test_over_nested_expression_is_rejected_by_name() {
let vars = test_vars();
let template = format!(
"{{{{ {}\"A\"{} }}}}",
"tolower (".repeat(200),
")".repeat(200)
);
let err = render(&template, &vars)
.expect_err("a 200-deep nest must be rejected")
.to_string();
assert!(err.contains("over-nested expression in template"), "{err}");
assert!(err.contains("parentheses nest 200 deep"), "{err}");
}