use anyhow::{Context as _, Result};
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::LazyLock;
use crate::template_preprocess::preprocess;
use tera::Value;
pub fn parse_static(name: &str, raw: &str) -> Result<tera::Tera> {
let mut tera = tera::Tera::default();
tera.autoescape_on(vec![]);
tera.add_raw_template(name, raw)
.with_context(|| format!("parse static template '{}'", name))?;
Ok(tera)
}
pub fn render_static(
tera: &tera::Tera,
name: &str,
ctx: &tera::Context,
stage: &str,
) -> Result<String> {
tera.render(name, ctx)
.with_context(|| format!("{}: render '{}'", stage, name))
}
use sha1::Digest as Sha1Digest;
use sha2::Digest as Sha2Digest;
use sha3::Digest as Sha3Digest;
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{}/{}", home, rest);
}
path.to_string()
}
fn value_to_string(v: &Value) -> Cow<'_, str> {
match v {
Value::String(s) => Cow::Borrowed(s.as_str()),
Value::Number(n) => Cow::Owned(n.to_string()),
Value::Bool(b) => Cow::Owned(b.to_string()),
Value::Null => Cow::Borrowed(""),
other => Cow::Owned(other.to_string()),
}
}
fn translate_go_time_format(fmt: &str) -> Cow<'_, str> {
if fmt.contains('%') {
return Cow::Borrowed(fmt);
}
const GO_MARKERS: &[&str] = &[
"2006", "06", "January", "Jan", "01", "Monday", "Mon", "02", "15", "03", "04", "05", "PM",
"pm", "-0700", "Z0700", "MST",
];
let has_go_patterns = GO_MARKERS.iter().any(|p| fmt.contains(p));
if !has_go_patterns {
return Cow::Borrowed(fmt);
}
let mut result = fmt.to_string();
let replacements: &[(&str, &str)] = &[
("January", "%B"), ("Monday", "%A"), ("-0700", "%z"), ("Z0700", "%z"), ("2006", "%Y"), ("Jan", "%b"), ("Mon", "%a"), ("MST", "%Z"), ("PM", "%p"), ("pm", "%P"), ("15", "%H"), ("06", "%y"), ("05", "%S"), ("04", "%M"), ("03", "%I"), ("02", "%d"), ("01", "%m"), ];
for (go_pat, chrono_pat) in replacements {
result = result.replace(go_pat, chrono_pat);
}
Cow::Owned(result)
}
enum VersionPart {
Major,
Minor,
Patch,
}
fn increment_version(v: &str, part: VersionPart) -> String {
let stripped = v.strip_prefix('v').unwrap_or(v);
let parts: Vec<&str> = stripped.splitn(3, '.').collect();
let major: u64 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor: u64 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let patch: u64 = parts
.get(2)
.and_then(|s| {
s.split('-').next().and_then(|n| n.parse().ok())
})
.unwrap_or(0);
let prefix = if v.starts_with('v') { "v" } else { "" };
match part {
VersionPart::Major => format!("{}{}.0.0", prefix, major + 1),
VersionPart::Minor => format!("{}{}.{}.0", prefix, major, minor + 1),
VersionPart::Patch => format!("{}{}.{}.{}", prefix, major, minor, patch + 1),
}
}
static BASE_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
let mut tera = tera::Tera::default();
tera.register_filter("tolower", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("tolower", "value", String, value);
Ok(Value::String(s.to_lowercase()))
});
tera.register_filter("toupper", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("toupper", "value", String, value);
Ok(Value::String(s.to_uppercase()))
});
tera.register_filter(
"trimprefix",
|value: &Value, args: &HashMap<String, Value>| {
let s = tera::try_get_value!("trimprefix", "value", String, value);
let prefix = args
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimprefix requires a `prefix` argument"))?;
let result = s.strip_prefix(prefix).unwrap_or(&s);
Ok(Value::String(result.to_string()))
},
);
tera.register_filter(
"trimsuffix",
|value: &Value, args: &HashMap<String, Value>| {
let s = tera::try_get_value!("trimsuffix", "value", String, value);
let suffix = args
.get("suffix")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimsuffix requires a `suffix` argument"))?;
let result = s.strip_suffix(suffix).unwrap_or(&s);
Ok(Value::String(result.to_string()))
},
);
tera.register_function(
"envOrDefault",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("envOrDefault requires `name` argument"))?;
let default = args.get("default").and_then(|v| v.as_str()).unwrap_or("");
let value = std::env::var(name).unwrap_or_else(|_| default.to_string());
Ok(Value::String(value))
},
);
tera.register_function(
"isEnvSet",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("isEnvSet requires `name` argument"))?;
let is_set = std::env::var(name).map(|v| !v.is_empty()).unwrap_or(false);
Ok(Value::Bool(is_set))
},
);
tera.register_function(
"incpatch",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let v = args
.get("v")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("incpatch requires `v` argument"))?;
Ok(Value::String(increment_version(v, VersionPart::Patch)))
},
);
tera.register_function(
"incminor",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let v = args
.get("v")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("incminor requires `v` argument"))?;
Ok(Value::String(increment_version(v, VersionPart::Minor)))
},
);
tera.register_function(
"incmajor",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let v = args
.get("v")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("incmajor requires `v` argument"))?;
Ok(Value::String(increment_version(v, VersionPart::Major)))
},
);
macro_rules! register_hash_fn {
($tera:expr, $name:expr, $hash_fn:expr) => {
$tera.register_function(
$name,
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args.get("s").and_then(|v| v.as_str()).ok_or_else(|| {
tera::Error::msg(format!("{} requires `s` argument", $name))
})?;
let bytes = std::fs::read(s).map_err(|e| {
tera::Error::msg(format!("{}: failed to read file '{}': {}", $name, s, e))
})?;
Ok(Value::String($hash_fn(&bytes)))
},
);
};
}
register_hash_fn!(tera, "sha1", |b: &[u8]| {
let mut h = sha1::Sha1::new();
Sha1Digest::update(&mut h, b);
hex_encode(&Sha1Digest::finalize(h))
});
register_hash_fn!(tera, "sha224", |b: &[u8]| {
let mut h = sha2::Sha224::new();
Sha2Digest::update(&mut h, b);
hex_encode(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha256", |b: &[u8]| {
let mut h = sha2::Sha256::new();
Sha2Digest::update(&mut h, b);
hex_encode(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha384", |b: &[u8]| {
let mut h = sha2::Sha384::new();
Sha2Digest::update(&mut h, b);
hex_encode(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha512", |b: &[u8]| {
let mut h = sha2::Sha512::new();
Sha2Digest::update(&mut h, b);
hex_encode(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_224", |b: &[u8]| {
let mut h = sha3::Sha3_224::new();
Sha3Digest::update(&mut h, b);
hex_encode(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_256", |b: &[u8]| {
let mut h = sha3::Sha3_256::new();
Sha3Digest::update(&mut h, b);
hex_encode(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_384", |b: &[u8]| {
let mut h = sha3::Sha3_384::new();
Sha3Digest::update(&mut h, b);
hex_encode(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_512", |b: &[u8]| {
let mut h = sha3::Sha3_512::new();
Sha3Digest::update(&mut h, b);
hex_encode(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "blake2b", |b: &[u8]| {
let mut h = blake2::Blake2b512::new();
blake2::Digest::update(&mut h, b);
hex_encode(&blake2::Digest::finalize(h))
});
register_hash_fn!(tera, "blake2s", |b: &[u8]| {
let mut h = blake2::Blake2s256::new();
blake2::Digest::update(&mut h, b);
hex_encode(&blake2::Digest::finalize(h))
});
register_hash_fn!(tera, "blake3", |b: &[u8]| {
hex_encode(blake3::hash(b).as_bytes())
});
register_hash_fn!(tera, "md5", |b: &[u8]| {
let mut h = md5::Md5::new();
md5::Digest::update(&mut h, b);
hex_encode(&md5::Digest::finalize(h))
});
register_hash_fn!(tera, "crc32", |b: &[u8]| {
format!("{:08x}", crc32fast::hash(b))
});
tera.register_function(
"readFile",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("readFile requires `path` argument"))?;
let resolved = expand_tilde(path);
let content = std::fs::read_to_string(resolved).unwrap_or_default();
Ok(Value::String(content.trim().to_string()))
},
);
tera.register_function(
"mustReadFile",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("mustReadFile requires `path` argument"))?;
let resolved = expand_tilde(path);
let content = std::fs::read_to_string(&resolved)
.map_err(|e| tera::Error::msg(format!("mustReadFile: {}: {}", resolved, e)))?;
Ok(Value::String(content.trim().to_string()))
},
);
tera.register_function(
"time",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let fmt = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%dT%H:%M:%SZ");
let chrono_fmt = translate_go_time_format(fmt);
let now = chrono::Utc::now();
Ok(Value::String(now.format(&chrono_fmt).to_string()))
},
);
tera.register_filter("dir", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("dir", "value", String, value);
let p = std::path::Path::new(&s);
Ok(Value::String(
p.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
))
});
tera.register_filter("base", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("base", "value", String, value);
let p = std::path::Path::new(&s);
Ok(Value::String(
p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
))
});
tera.register_filter("abs", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("abs", "value", String, value);
let p = std::path::Path::new(&s);
if p.is_absolute() {
Ok(Value::String(s))
} else {
let abs = std::env::current_dir()
.map(|cwd| cwd.join(p).to_string_lossy().to_string())
.unwrap_or(s);
Ok(Value::String(abs))
}
});
tera.register_filter(
"urlPathEscape",
|value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("urlPathEscape", "value", String, value);
let encoded: String = s
.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
{
(b as char).to_string()
} else {
format!("%{:02X}", b)
}
})
.collect();
Ok(Value::String(encoded))
},
);
tera.register_filter("mdv2escape", |value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("mdv2escape", "value", String, value);
let escaped = s
.chars()
.map(|c| {
if "_*[]()~`>#+-=|{}.!".contains(c) {
format!("\\{}", c)
} else {
c.to_string()
}
})
.collect::<String>();
Ok(Value::String(escaped))
});
tera.register_function(
"contains",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("contains requires `s` argument"))?;
let substr = args
.get("substr")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("contains requires `substr` argument"))?;
Ok(Value::Bool(s.contains(substr)))
},
);
tera.register_function(
"list",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let items = args
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| tera::Error::msg("list requires `items` argument"))?;
Ok(Value::Array(items.clone()))
},
);
tera.register_function(
"map",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let pairs = args
.get("pairs")
.and_then(|v| v.as_array())
.ok_or_else(|| tera::Error::msg("map requires `pairs` argument"))?;
if pairs.len() % 2 != 0 {
return Err(tera::Error::msg(
"map requires an even number of arguments (key-value pairs)",
));
}
let mut result = tera::Map::new();
for chunk in pairs.chunks(2) {
let key = chunk[0].as_str().unwrap_or("").to_string();
result.insert(key, chunk[1].clone());
}
Ok(Value::Object(result))
},
);
let in_fn = |args: &HashMap<String, Value>| -> tera::Result<Value> {
let items = args
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| tera::Error::msg("in requires `items` argument (must be an array)"))?;
let value = args
.get("value")
.ok_or_else(|| tera::Error::msg("in requires `value` argument"))?;
let needle = value_to_string(value);
let found = items.iter().any(|item| value_to_string(item) == needle);
Ok(Value::Bool(found))
};
tera.register_function("in", in_fn);
tera.register_function("contains_any", in_fn);
tera.register_function(
"reReplaceAll",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let pattern = args
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("reReplaceAll requires `pattern` argument"))?;
let input = args
.get("input")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("reReplaceAll requires `input` argument"))?;
let replacement = args
.get("replacement")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("reReplaceAll requires `replacement` argument"))?;
let re = Regex::new(pattern).map_err(|e| {
tera::Error::msg(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
})?;
Ok(Value::String(
re.replace_all(input, replacement).to_string(),
))
},
);
tera.register_filter(
"reReplaceAll",
|value: &Value, args: &HashMap<String, Value>| {
let input = tera::try_get_value!("reReplaceAll", "value", String, value);
let pattern = args
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| {
tera::Error::msg("reReplaceAll filter requires `pattern` argument")
})?;
let replacement = args
.get("replacement")
.and_then(|v| v.as_str())
.ok_or_else(|| {
tera::Error::msg("reReplaceAll filter requires `replacement` argument")
})?;
let re = Regex::new(pattern).map_err(|e| {
tera::Error::msg(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
})?;
Ok(Value::String(
re.replace_all(&input, replacement).to_string(),
))
},
);
tera.register_function(
"englishJoin",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let items = args
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| tera::Error::msg("englishJoin requires `items` argument"))?;
let oxford = args.get("oxford").and_then(|v| v.as_bool()).unwrap_or(true);
let strs: Vec<String> = items
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.filter(|s| !s.trim().is_empty())
.collect();
let result = match strs.len() {
0 => String::new(),
1 => strs[0].clone(),
2 => format!("{} and {}", strs[0], strs[1]),
_ => {
let Some((last, rest)) = strs.split_last() else {
return Ok(Value::String(String::new()));
};
if oxford {
format!("{}, and {}", rest.join(", "), last)
} else {
format!("{} and {}", rest.join(", "), last)
}
}
};
Ok(Value::String(result))
},
);
tera.register_filter(
"englishJoin",
|value: &Value, args: &HashMap<String, Value>| {
let items = value
.as_array()
.ok_or_else(|| tera::Error::msg("englishJoin filter expects an array"))?;
let oxford = args.get("oxford").and_then(|v| v.as_bool()).unwrap_or(true);
let strs: Vec<String> = items
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.filter(|s| !s.trim().is_empty())
.collect();
let result = match strs.len() {
0 => String::new(),
1 => strs[0].clone(),
2 => format!("{} and {}", strs[0], strs[1]),
_ => {
let Some((last, rest)) = strs.split_last() else {
return Ok(Value::String(String::new()));
};
if oxford {
format!("{}, and {}", rest.join(", "), last)
} else {
format!("{} and {}", rest.join(", "), last)
}
}
};
Ok(Value::String(result))
},
);
tera.register_filter("filter", |value: &Value, args: &HashMap<String, Value>| {
let pattern = args
.get("regexp")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("filter requires `regexp` argument"))?;
let re = regex::Regex::new(pattern)
.map_err(|e| tera::Error::msg(format!("invalid regex '{}': {}", pattern, e)))?;
let input = value.as_str().unwrap_or("");
let result: Vec<&str> = input.lines().filter(|line| re.is_match(line)).collect();
Ok(Value::String(result.join("\n")))
});
tera.register_filter(
"reverseFilter",
|value: &Value, args: &HashMap<String, Value>| {
let pattern = args
.get("regexp")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("reverseFilter requires `regexp` argument"))?;
let re = regex::Regex::new(pattern)
.map_err(|e| tera::Error::msg(format!("invalid regex '{}': {}", pattern, e)))?;
let input = value.as_str().unwrap_or("");
let result: Vec<&str> = input.lines().filter(|line| !re.is_match(line)).collect();
Ok(Value::String(result.join("\n")))
},
);
tera.register_function(
"filter",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let items_val = args
.get("items")
.ok_or_else(|| tera::Error::msg("filter requires `items` argument"))?;
let pattern = args
.get("regexp")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("filter requires `regexp` argument"))?;
let re = Regex::new(pattern)
.map_err(|e| tera::Error::msg(format!("filter: invalid regex: {}", e)))?;
if let Some(s) = items_val.as_str() {
let filtered: String = s
.lines()
.filter(|line| re.is_match(line))
.collect::<Vec<_>>()
.join("\n");
Ok(Value::String(filtered))
} else if let Some(arr) = items_val.as_array() {
let filtered: Vec<Value> = arr
.iter()
.filter(|v| v.as_str().is_some_and(|s| re.is_match(s)))
.cloned()
.collect();
Ok(Value::Array(filtered))
} else {
Err(tera::Error::msg(
"filter: `items` must be a string or array",
))
}
},
);
tera.register_function(
"reverseFilter",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let items_val = args
.get("items")
.ok_or_else(|| tera::Error::msg("reverseFilter requires `items` argument"))?;
let pattern = args
.get("regexp")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("reverseFilter requires `regexp` argument"))?;
let re = Regex::new(pattern)
.map_err(|e| tera::Error::msg(format!("reverseFilter: invalid regex: {}", e)))?;
if let Some(s) = items_val.as_str() {
let filtered: String = s
.lines()
.filter(|line| !re.is_match(line))
.collect::<Vec<_>>()
.join("\n");
Ok(Value::String(filtered))
} else if let Some(arr) = items_val.as_array() {
let filtered: Vec<Value> = arr
.iter()
.filter(|v| !v.as_str().is_some_and(|s| re.is_match(s)))
.cloned()
.collect();
Ok(Value::Array(filtered))
} else {
Err(tera::Error::msg(
"reverseFilter: `items` must be a string or array",
))
}
},
);
tera.register_function(
"indexOrDefault",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let map = args
.get("map")
.and_then(|v| v.as_object())
.ok_or_else(|| tera::Error::msg("indexOrDefault requires `map` argument"))?;
let key = args
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("indexOrDefault requires `key` argument"))?;
let default = args
.get("default")
.cloned()
.unwrap_or(Value::String(String::new()));
Ok(map.get(key).cloned().unwrap_or(default))
},
);
tera.register_function(
"replace",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("replace requires `s` argument"))?;
let old = args
.get("old")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("replace requires `old` argument"))?;
let new = args
.get("new")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("replace requires `new` argument"))?;
Ok(Value::String(s.replace(old, new)))
},
);
tera.register_filter("replace", |value: &Value, args: &HashMap<String, Value>| {
let s = tera::try_get_value!("replace", "value", String, value);
let from = args
.get("from")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("replace filter requires `from` argument"))?;
let to = args
.get("to")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("replace filter requires `to` argument"))?;
Ok(Value::String(s.replace(from, to)))
});
tera.register_function(
"split",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("split requires `s` argument"))?;
let sep = args
.get("sep")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("split requires `sep` argument"))?;
let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
Ok(Value::Array(parts))
},
);
tera.register_filter("split", |value: &Value, args: &HashMap<String, Value>| {
let s = tera::try_get_value!("split", "value", String, value);
let sep = args
.get("sep")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("split filter requires `sep` argument"))?;
let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
Ok(Value::Array(parts))
});
tera.register_filter(
"contains",
|value: &Value, args: &HashMap<String, Value>| {
let s = tera::try_get_value!("contains", "value", String, value);
let substr = args
.get("substr")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("contains filter requires `substr` argument"))?;
Ok(Value::Bool(s.contains(substr)))
},
);
tera.register_function(
"trim",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trim requires `s` argument"))?;
Ok(Value::String(s.trim().to_string()))
},
);
tera.register_function(
"title",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("title requires `s` argument"))?;
let titled = s
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => {
let upper: String = c.to_uppercase().collect();
format!("{}{}", upper, chars.as_str())
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ");
Ok(Value::String(titled))
},
);
tera.register_function(
"tolower",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("tolower requires `s` argument"))?;
Ok(Value::String(s.to_lowercase()))
},
);
tera.register_function(
"toupper",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("toupper requires `s` argument"))?;
Ok(Value::String(s.to_uppercase()))
},
);
tera.register_function(
"trimprefix",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimprefix requires `s` argument"))?;
let prefix = args
.get("prefix")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimprefix requires `prefix` argument"))?;
let result = s.strip_prefix(prefix).unwrap_or(s);
Ok(Value::String(result.to_string()))
},
);
tera.register_function(
"trimsuffix",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimsuffix requires `s` argument"))?;
let suffix = args
.get("suffix")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("trimsuffix requires `suffix` argument"))?;
let result = s.strip_suffix(suffix).unwrap_or(s);
Ok(Value::String(result.to_string()))
},
);
tera.register_function(
"dir",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("dir requires `s` argument"))?;
let p = std::path::Path::new(s);
Ok(Value::String(
p.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
))
},
);
tera.register_function(
"base",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("base requires `s` argument"))?;
let p = std::path::Path::new(s);
Ok(Value::String(
p.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
))
},
);
tera.register_function(
"abs",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("abs requires `s` argument"))?;
let p = std::path::Path::new(s);
if p.is_absolute() {
Ok(Value::String(s.to_string()))
} else {
let abs = std::env::current_dir()
.map(|cwd| cwd.join(p).to_string_lossy().to_string())
.unwrap_or_else(|_| s.to_string());
Ok(Value::String(abs))
}
},
);
tera.register_function(
"urlPathEscape",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("urlPathEscape requires `s` argument"))?;
let encoded: String = s
.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
{
(b as char).to_string()
} else {
format!("%{:02X}", b)
}
})
.collect();
Ok(Value::String(encoded))
},
);
tera.register_function(
"mdv2escape",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let s = args
.get("s")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("mdv2escape requires `s` argument"))?;
let escaped = s
.chars()
.map(|c| {
if "_*[]()~`>#+-=|{}.!".contains(c) {
format!("\\{}", c)
} else {
c.to_string()
}
})
.collect::<String>();
Ok(Value::String(escaped))
},
);
tera.register_filter("incpatch", |value: &Value, _: &HashMap<String, Value>| {
let v = tera::try_get_value!("incpatch", "value", String, value);
Ok(Value::String(increment_version(&v, VersionPart::Patch)))
});
tera.register_filter("incminor", |value: &Value, _: &HashMap<String, Value>| {
let v = tera::try_get_value!("incminor", "value", String, value);
Ok(Value::String(increment_version(&v, VersionPart::Minor)))
});
tera.register_filter("incmajor", |value: &Value, _: &HashMap<String, Value>| {
let v = tera::try_get_value!("incmajor", "value", String, value);
Ok(Value::String(increment_version(&v, VersionPart::Major)))
});
tera.register_filter(
"now_format",
|_value: &Value, args: &HashMap<String, Value>| {
let fmt = args
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("now_format requires a `format` argument"))?;
let chrono_fmt = translate_go_time_format(fmt);
let now = chrono::Utc::now();
Ok(Value::String(now.format(&chrono_fmt).to_string()))
},
);
tera.register_function(
"index",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let collection = args
.get("collection")
.ok_or_else(|| tera::Error::msg("index requires `collection` argument"))?;
let key = args
.get("key")
.ok_or_else(|| tera::Error::msg("index requires `key` argument"))?;
match collection {
Value::Object(map) => {
let key_str = value_to_string(key);
Ok(map
.get(key_str.as_ref())
.cloned()
.unwrap_or(Value::String(String::new())))
}
Value::Array(arr) => {
if let Some(idx) = key.as_u64() {
Ok(arr
.get(idx as usize)
.cloned()
.unwrap_or(Value::String(String::new())))
} else {
Err(tera::Error::msg("index: array index must be a number"))
}
}
_ => {
Ok(Value::String(String::new()))
}
}
},
);
let in_filter = |value: &Value, args: &HashMap<String, Value>| {
let items = value
.as_array()
.ok_or_else(|| tera::Error::msg("in filter requires an array as input"))?;
let needle = args
.get("value")
.ok_or_else(|| tera::Error::msg("in filter requires `value` argument"))?;
let needle_str = value_to_string(needle);
let found = items.iter().any(|item| value_to_string(item) == needle_str);
Ok(Value::Bool(found))
};
tera.register_filter("in", in_filter);
tera.register_filter("contains_any", in_filter);
tera
});
#[derive(Clone)]
pub struct TemplateVars {
vars: HashMap<String, String>,
env: HashMap<String, String>,
config_env: HashMap<String, String>,
custom_vars: HashMap<String, String>,
outputs: HashMap<String, String>,
structured: HashMap<String, Value>,
}
impl TemplateVars {
pub fn new() -> Self {
Self {
vars: HashMap::new(),
env: HashMap::new(),
config_env: HashMap::new(),
custom_vars: HashMap::new(),
outputs: HashMap::new(),
structured: HashMap::new(),
}
}
pub fn set(&mut self, key: &str, value: &str) {
self.vars.insert(key.to_string(), value.to_string());
}
pub fn get(&self, key: &str) -> Option<&String> {
self.vars.get(key)
}
pub fn set_env(&mut self, key: &str, value: &str) {
self.env.insert(key.to_string(), value.to_string());
}
pub fn set_config_env(&mut self, key: &str, value: &str) {
self.env.insert(key.to_string(), value.to_string());
self.config_env.insert(key.to_string(), value.to_string());
}
pub fn set_custom_var(&mut self, key: &str, value: &str) {
self.custom_vars.insert(key.to_string(), value.to_string());
}
pub fn set_output(&mut self, key: &str, value: &str) {
self.outputs.insert(key.to_string(), value.to_string());
}
pub fn get_output(&self, key: &str) -> Option<&String> {
self.outputs.get(key)
}
pub fn set_structured(&mut self, key: &str, value: Value) {
self.structured.insert(key.to_string(), value);
}
pub fn all(&self) -> &HashMap<String, String> {
&self.vars
}
pub fn all_env(&self) -> &HashMap<String, String> {
&self.env
}
pub fn all_config_env(&self) -> &HashMap<String, String> {
&self.config_env
}
pub fn get_structured(&self, key: &str) -> Option<&tera::Value> {
self.structured.get(key)
}
pub fn all_structured(&self) -> &HashMap<String, Value> {
&self.structured
}
}
impl Default for TemplateVars {
fn default() -> Self {
Self::new()
}
}
pub fn clear_per_target_vars(tv: &mut TemplateVars) {
for key in PER_TARGET_VARS {
tv.set(key, "");
}
}
pub const PER_TARGET_VARS: &[&str] = &[
"Os", "Arch", "Target", "Arm", "Arm64", "Amd64", "Mips", "I386",
];
const NUMERIC_FIELDS: &[&str] = &["Major", "Minor", "Patch", "Timestamp", "CommitTimestamp"];
static ENV_REF_RE: LazyLock<Regex> =
LazyLock::new(|| crate::util::static_regex(r"Env\.([A-Za-z_][A-Za-z0-9_]*)"));
fn build_tera_context_for_template(vars: &TemplateVars, preprocessed: &str) -> tera::Context {
let referenced_env_keys: Vec<String> = ENV_REF_RE
.captures_iter(preprocessed)
.map(|cap| cap[1].to_string())
.collect();
let mut env_with_defaults = HashMap::new();
for key in &referenced_env_keys {
if !vars.env.contains_key(key.as_str()) {
let value = std::env::var(key).unwrap_or_default();
env_with_defaults.insert(key.clone(), value);
}
}
for (k, v) in &vars.env {
env_with_defaults.insert(k.clone(), v.clone());
}
let mut augmented_vars = vars.clone();
augmented_vars.env = env_with_defaults;
build_tera_context(&augmented_vars)
}
fn build_tera_context(vars: &TemplateVars) -> tera::Context {
let mut ctx = tera::Context::new();
for (k, v) in &vars.vars {
if NUMERIC_FIELDS.contains(&k.as_str())
&& let Ok(n) = v.parse::<i64>()
{
ctx.insert(k.as_str(), &n);
continue;
}
match v.as_str() {
"true" => ctx.insert(k.as_str(), &true),
"false" => ctx.insert(k.as_str(), &false),
_ => ctx.insert(k.as_str(), v),
}
}
ctx.insert("Env", &vars.env);
ctx.insert("Var", &vars.custom_vars);
ctx.insert("Outputs", &vars.outputs);
let mut runtime = HashMap::new();
if let Some(goos) = vars.vars.get("RuntimeGoos") {
runtime.insert("Goos".to_string(), goos.clone());
}
if let Some(goarch) = vars.vars.get("RuntimeGoarch") {
runtime.insert("Goarch".to_string(), goarch.clone());
}
if !runtime.is_empty() {
ctx.insert("Runtime", &runtime);
}
for (k, v) in &vars.structured {
ctx.insert(k.as_str(), v);
}
ctx
}
pub fn render(template: &str, vars: &TemplateVars) -> Result<String> {
let preprocessed = preprocess(template);
let ctx = build_tera_context_for_template(vars, &preprocessed);
let mut tera = BASE_TERA.clone();
let env_map = std::sync::Arc::new(vars.all_env().clone());
let env_map_for_default = env_map.clone();
tera.register_function(
"envOrDefault",
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("envOrDefault requires `name` argument"))?;
let default = args.get("default").and_then(|v| v.as_str()).unwrap_or("");
let value = env_map_for_default
.get(name)
.cloned()
.or_else(|| std::env::var(name).ok())
.unwrap_or_else(|| default.to_string());
Ok(Value::String(value))
},
);
let env_map_for_isset = env_map.clone();
tera.register_function(
"isEnvSet",
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("isEnvSet requires `name` argument"))?;
let is_set = env_map_for_isset
.get(name)
.map(|v| !v.is_empty())
.unwrap_or_else(|| std::env::var(name).map(|v| !v.is_empty()).unwrap_or(false));
Ok(Value::Bool(is_set))
},
);
tera.add_raw_template("__inline__", &preprocessed)
.with_context(|| format!("failed to parse template: {}", template))?;
tera.render("__inline__", &ctx)
.with_context(|| format!("failed to render template: {}", template))
}
pub fn extract_artifact_ext(filename: &str) -> &str {
const TAR_COMPOUND_SUFFIXES: &[&str] = &[
".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tar.lz4", ".tar.sz",
];
let lower = filename.to_ascii_lowercase();
for suffix in TAR_COMPOUND_SUFFIXES {
if lower.ends_with(suffix) {
return &filename[filename.len() - suffix.len()..];
}
}
match filename.rfind('.') {
Some(pos) if pos > 0 => &filename[pos..],
_ => "",
}
}
pub fn validate_single_env_only(template: &str) -> Result<()> {
static ENV_ONLY_RE: LazyLock<Regex> = LazyLock::new(|| {
crate::util::static_regex(r"^\s*\{\{\s*\.?Env\.[A-Za-z_][A-Za-z0-9_]*\s*\}\}\s*$")
});
if ENV_ONLY_RE.is_match(template) {
Ok(())
} else {
anyhow::bail!(
"expected a single env var reference like '{{{{ .Env.VAR }}}}', got: {}",
template
)
}
}
#[cfg(test)]
mod tests {
use super::*;
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_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_nested_missing_var_in_expression_error() {
let vars = test_vars();
let result = render("{{ Undefined ~ ' suffix' }}", &vars);
assert!(
result.is_err(),
"undefined variable in an expression should produce an error"
);
}
#[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();
unsafe { std::env::set_var("ANODIZER_TEST_PRIORITY", "from-process") };
vars.set_env("ANODIZER_TEST_PRIORITY", "from-template");
let result = render(
"{{ envOrDefault(name=\"ANODIZER_TEST_PRIORITY\", default=\"fallback\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "from-template");
unsafe { std::env::remove_var("ANODIZER_TEST_PRIORITY") };
}
#[test]
fn test_env_or_default_falls_back_to_process_env() {
let vars = test_vars();
unsafe { std::env::set_var("ANODIZER_TEST_ENV_OR_DEFAULT", "from-process-env") };
let result = render(
"{{ envOrDefault(name=\"ANODIZER_TEST_ENV_OR_DEFAULT\", default=\"fallback\") }}",
&vars,
)
.unwrap();
assert_eq!(result, "from-process-env");
unsafe { std::env::remove_var("ANODIZER_TEST_ENV_OR_DEFAULT") };
}
#[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();
unsafe { std::env::set_var("ANODIZER_TEST_IS_SET", "yes") };
let result = render(
"{% if isEnvSet(name=\"ANODIZER_TEST_IS_SET\") %}SET{% else %}UNSET{% endif %}",
&vars,
)
.unwrap();
assert_eq!(result, "SET");
unsafe { std::env::remove_var("ANODIZER_TEST_IS_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");
}
#[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_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_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_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_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, "");
}
}