use regex::Regex;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::LazyLock;
use tera::Value;
use sha1::Digest as Sha1Digest;
use sha2::Digest as Sha2Digest;
use sha3::Digest as Sha3Digest;
use crate::path_util::expand_tilde;
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 printf_default(v: &Value) -> String {
value_to_string(v).into_owned()
}
#[derive(Clone, Copy)]
struct PrintfSpec {
minus: bool,
plus: bool,
space: bool,
zero: bool,
hash: bool,
width: Option<usize>,
precision: Option<usize>,
verb: char,
}
fn pad(spec: &PrintfSpec, body: String, numeric_sign_prefix: Option<(&str, &str)>) -> String {
let (sign, prefix, core) = match numeric_sign_prefix {
Some((sign, prefix)) => (sign, prefix, body.as_str()),
None => ("", "", body.as_str()),
};
let assembled = format!("{}{}{}", sign, prefix, core);
let Some(width) = spec.width else {
return assembled;
};
let len = assembled.chars().count();
if len >= width {
return assembled;
}
let padding = width - len;
if spec.minus {
format!("{}{}", assembled, " ".repeat(padding))
} else if spec.zero && numeric_sign_prefix.is_some() {
format!("{}{}{}{}", sign, prefix, "0".repeat(padding), core)
} else if spec.zero {
format!("{}{}", "0".repeat(padding), assembled)
} else {
format!("{}{}", " ".repeat(padding), assembled)
}
}
fn pad_int(spec: &PrintfSpec, body: String, sign: &str, prefix: &str) -> String {
if spec.precision.is_some() && spec.zero {
let no_zero = PrintfSpec {
zero: false,
..*spec
};
pad(&no_zero, body, Some((sign, prefix)))
} else {
pad(spec, body, Some((sign, prefix)))
}
}
fn numeric_sign(negative: bool, plus: bool, space: bool) -> &'static str {
if negative {
"-"
} else if plus {
"+"
} else if space {
" "
} else {
""
}
}
fn int_precision(digits: &str, precision: Option<usize>) -> String {
match precision {
Some(0) if digits == "0" => String::new(),
Some(p) if digits.len() < p => format!("{}{}", "0".repeat(p - digits.len()), digits),
_ => digits.to_string(),
}
}
fn go_exponent(s: &str, uppercase: bool) -> String {
let letter = if uppercase { 'E' } else { 'e' };
let Some(pos) = s.find(['e', 'E']) else {
return s.to_string();
};
let (mantissa, exp_part) = s.split_at(pos);
let exp_str = &exp_part[1..];
let (sign, digits) = match exp_str.strip_prefix('-') {
Some(rest) => ('-', rest),
None => ('+', exp_str.strip_prefix('+').unwrap_or(exp_str)),
};
let padded = if digits.len() < 2 {
format!("{:0>2}", digits)
} else {
digits.to_string()
};
format!("{}{}{}{}", mantissa, letter, sign, padded)
}
fn trim_fraction_zeros(s: &str) -> &str {
if !s.contains('.') {
return s;
}
let trimmed = s.trim_end_matches('0');
trimmed.strip_suffix('.').unwrap_or(trimmed)
}
fn format_g(mag: f64, precision: Option<usize>, uppercase: bool) -> String {
let sci = format!("{:e}", mag);
let exp: i32 = sci
.split(['e', 'E'])
.nth(1)
.and_then(|e| e.parse().ok())
.unwrap_or(0);
let eprec = precision.map(|p| p as i32).unwrap_or(6).max(1);
if exp < -4 || exp >= eprec {
let body = match precision {
Some(p) => format!("{:.*e}", p.saturating_sub(1), mag),
None => sci.clone(),
};
let normalized = go_exponent(&body, uppercase);
if precision.is_some()
&& let Some(epos) = normalized.find(['e', 'E'])
{
let (mantissa, exp_part) = normalized.split_at(epos);
return format!("{}{}", trim_fraction_zeros(mantissa), exp_part);
}
normalized
} else {
let body = match precision {
Some(p) => {
let frac = (p as i32 - (exp + 1)).max(0) as usize;
format!("{:.*}", frac, mag)
}
None => format!("{}", mag),
};
trim_fraction_zeros(&body).to_string()
}
}
const PRINTF_FIELD_MAX: usize = 100_000;
fn format_verb(spec: &PrintfSpec, value: Option<&Value>) -> Result<String, tera::Error> {
let val = || -> Result<&Value, tera::Error> {
value
.ok_or_else(|| tera::Error::msg(format!("printf: missing argument for %{}", spec.verb)))
};
match spec.verb {
's' => {
let mut s = printf_default(val()?);
if let Some(prec) = spec.precision {
s = s.chars().take(prec).collect();
}
Ok(pad(spec, s, None))
}
'v' => Ok(pad(spec, printf_default(val()?), None)),
't' => {
let b = val()?
.as_bool()
.ok_or_else(|| tera::Error::msg("printf: %t expects a boolean argument"))?;
Ok(pad(spec, b.to_string(), None))
}
'q' => {
let s = printf_default(val()?);
Ok(pad(spec, format!("{:?}", s), None))
}
'c' => {
let v = val()?;
let code = v
.as_u64()
.ok_or_else(|| tera::Error::msg("printf: %c expects a non-negative integer"))?;
let ch = u32::try_from(code)
.ok()
.and_then(char::from_u32)
.ok_or_else(|| {
tera::Error::msg(format!("printf: %c: {} is not a valid code point", code))
})?;
Ok(pad(spec, ch.to_string(), None))
}
'd' => {
let n = val()?
.as_i64()
.ok_or_else(|| tera::Error::msg("printf: %d expects an integer argument"))?;
let sign = numeric_sign(n < 0, spec.plus, spec.space);
Ok(pad_int(
spec,
int_precision(&n.unsigned_abs().to_string(), spec.precision),
sign,
"",
))
}
'b' | 'o' | 'x' | 'X' => {
let n = val()?.as_i64().ok_or_else(|| {
tera::Error::msg(format!(
"printf: %{} expects an integer argument",
spec.verb
))
})?;
let mag = n.unsigned_abs();
let digits = match spec.verb {
'b' => format!("{:b}", mag),
'o' => format!("{:o}", mag),
'x' => format!("{:x}", mag),
'X' => format!("{:X}", mag),
_ => unreachable!(),
};
let body = int_precision(&digits, spec.precision);
let sign = numeric_sign(n < 0, spec.plus, spec.space);
let prefix = if spec.hash {
match spec.verb {
'b' => "0b",
'o' => "0",
'x' => "0x",
'X' => "0X",
_ => "",
}
} else {
""
};
Ok(pad_int(spec, body, sign, prefix))
}
'f' | 'e' | 'E' | 'g' | 'G' => {
let f = val()?.as_f64().ok_or_else(|| {
tera::Error::msg(format!("printf: %{} expects a numeric argument", spec.verb))
})?;
let prec = spec.precision.unwrap_or(6);
let mag = f.abs();
let body = match spec.verb {
'f' => format!("{:.*}", prec, mag),
'e' | 'E' => go_exponent(&format!("{:.*e}", prec, mag), spec.verb == 'E'),
'g' | 'G' => format_g(mag, spec.precision, spec.verb == 'G'),
_ => unreachable!(),
};
let sign = numeric_sign(f.is_sign_negative() && f != 0.0, spec.plus, spec.space);
Ok(pad(spec, body, Some((sign, ""))))
}
other => Err(tera::Error::msg(format!(
"printf: unsupported verb %{} (supported: s d v x X o b c q f e E g G t %%)",
other
))),
}
}
fn sprintf(format: &str, args: &[Value]) -> Result<String, tera::Error> {
let mut out = String::new();
let mut arg_idx = 0usize;
let mut chars = format.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
if chars.peek() == Some(&'%') {
chars.next();
out.push('%');
continue;
}
let mut spec = PrintfSpec {
minus: false,
plus: false,
space: false,
zero: false,
hash: false,
width: None,
precision: None,
verb: ' ',
};
while let Some(&f) = chars.peek() {
match f {
'-' => spec.minus = true,
'+' => spec.plus = true,
' ' => spec.space = true,
'0' => spec.zero = true,
'#' => spec.hash = true,
_ => break,
}
chars.next();
}
let mut width_digits = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
width_digits.push(d);
chars.next();
} else {
break;
}
}
if !width_digits.is_empty() {
let w = width_digits.parse::<usize>().unwrap_or(usize::MAX);
if w > PRINTF_FIELD_MAX {
return Err(tera::Error::msg(format!(
"printf width {} exceeds maximum {}",
width_digits, PRINTF_FIELD_MAX
)));
}
spec.width = Some(w);
}
if chars.peek() == Some(&'.') {
chars.next();
let mut prec_digits = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
prec_digits.push(d);
chars.next();
} else {
break;
}
}
let p = if prec_digits.is_empty() {
0
} else {
prec_digits.parse::<usize>().unwrap_or(usize::MAX)
};
if p > PRINTF_FIELD_MAX {
return Err(tera::Error::msg(format!(
"printf precision {} exceeds maximum {}",
prec_digits, PRINTF_FIELD_MAX
)));
}
spec.precision = Some(p);
}
let verb = chars
.next()
.ok_or_else(|| tera::Error::msg("printf: format string ends with a dangling '%'"))?;
spec.verb = verb;
let value = args.get(arg_idx);
out.push_str(&format_verb(&spec, value)?);
arg_idx += 1;
}
Ok(out)
}
pub(super) 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) -> Result<String, tera::Error> {
let stripped = v.strip_prefix('v').unwrap_or(v);
let parts: Vec<&str> = stripped.splitn(3, '.').collect();
let invalid = || {
tera::Error::msg(format!(
"incpatch/incminor/incmajor: '{}' is not a valid semver version (expected MAJOR.MINOR.PATCH)",
v
))
};
if parts.len() < 3 {
return Err(invalid());
}
let major: u64 = parts
.first()
.and_then(|s| s.parse().ok())
.ok_or_else(invalid)?;
let minor: u64 = parts
.get(1)
.and_then(|s| s.parse().ok())
.ok_or_else(invalid)?;
let patch: u64 = parts
.get(2)
.and_then(|s| {
s.split('-').next().and_then(|n| n.parse().ok())
})
.ok_or_else(invalid)?;
let prefix = if v.starts_with('v') { "v" } else { "" };
Ok(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),
})
}
pub fn ruby_escape_str(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(super) fn register_ruby_escape(tera: &mut tera::Tera) {
tera.register_filter(
"ruby_escape",
|value: &Value, _: &HashMap<String, Value>| {
let s = tera::try_get_value!("ruby_escape", "value", String, value);
Ok(Value::String(ruby_escape_str(&s)))
},
);
}
pub(super) static BASE_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
let mut tera = tera::Tera::default();
register_ruby_escape(&mut tera);
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);
crate::hashing::hex_lower(&Sha1Digest::finalize(h))
});
register_hash_fn!(tera, "sha224", |b: &[u8]| {
let mut h = sha2::Sha224::new();
Sha2Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha256", |b: &[u8]| {
let mut h = sha2::Sha256::new();
Sha2Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha384", |b: &[u8]| {
let mut h = sha2::Sha384::new();
Sha2Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha512", |b: &[u8]| {
let mut h = sha2::Sha512::new();
Sha2Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha2Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_224", |b: &[u8]| {
let mut h = sha3::Sha3_224::new();
Sha3Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_256", |b: &[u8]| {
let mut h = sha3::Sha3_256::new();
Sha3Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_384", |b: &[u8]| {
let mut h = sha3::Sha3_384::new();
Sha3Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "sha3_512", |b: &[u8]| {
let mut h = sha3::Sha3_512::new();
Sha3Digest::update(&mut h, b);
crate::hashing::hex_lower(&Sha3Digest::finalize(h))
});
register_hash_fn!(tera, "blake2b", |b: &[u8]| {
let mut h = blake2::Blake2b512::new();
blake2::Digest::update(&mut h, b);
crate::hashing::hex_lower(&blake2::Digest::finalize(h))
});
register_hash_fn!(tera, "blake2s", |b: &[u8]| {
let mut h = blake2::Blake2s256::new();
blake2::Digest::update(&mut h, b);
crate::hashing::hex_lower(&blake2::Digest::finalize(h))
});
register_hash_fn!(tera, "blake3", |b: &[u8]| {
crate::hashing::hex_lower(blake3::hash(b).as_bytes())
});
register_hash_fn!(tera, "md5", |b: &[u8]| {
let mut h = md5::Md5::new();
md5::Digest::update(&mut h, b);
crate::hashing::hex_lower(&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.as_ref()).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.as_ref())
.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 = crate::sde::resolve_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 = crate::sde::resolve_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.register_filter("slice", |value: &Value, args: &HashMap<String, Value>| {
let start = args.get("start").and_then(|v| v.as_i64()).unwrap_or(0);
let end = args.get("end").and_then(|v| v.as_i64());
let resolve = |idx: i64, len: i64| -> i64 {
let abs = if idx < 0 { len + idx } else { idx };
abs.clamp(0, len)
};
match value {
Value::String(s) => {
let chars: Vec<char> = s.chars().collect();
let len = chars.len() as i64;
let lo = resolve(start, len);
let hi = resolve(end.unwrap_or(len), len).max(lo) as usize;
Ok(Value::String(chars[lo as usize..hi].iter().collect()))
}
Value::Array(arr) => {
let len = arr.len() as i64;
let lo = resolve(start, len);
let hi = resolve(end.unwrap_or(len), len).max(lo) as usize;
Ok(Value::Array(arr[lo as usize..hi].to_vec()))
}
other => Err(tera::Error::msg(format!(
"slice: expected a string or array, got {}",
other
))),
}
});
tera.register_function(
"printf",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let format = args
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("printf requires a `format` argument"))?;
let fmt_args: Vec<Value> = args
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Ok(Value::String(sprintf(format, &fmt_args)?))
},
);
tera.register_function(
"print",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let fmt_args: Vec<Value> = args
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut out = String::new();
for (i, v) in fmt_args.iter().enumerate() {
if i > 0 {
let prev_str = fmt_args[i - 1].is_string();
let cur_str = v.is_string();
if !prev_str && !cur_str {
out.push(' ');
}
}
out.push_str(&printf_default(v));
}
Ok(Value::String(out))
},
);
tera.register_function(
"println",
|args: &HashMap<String, Value>| -> tera::Result<Value> {
let fmt_args: Vec<Value> = args
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut joined = fmt_args
.iter()
.map(printf_default)
.collect::<Vec<_>>()
.join(" ");
joined.push('\n');
Ok(Value::String(joined))
},
);
tera
});