use std::collections::HashMap;
use tera::{to_value, Tera, Value};
pub fn register_filters(tera: &mut Tera) {
tera.register_filter("pluralize", pluralize);
tera.register_filter("truncatewords", truncatewords);
tera.register_filter("linebreaks", linebreaks);
tera.register_filter("default_if_none", default_if_none);
tera.register_filter("add", add);
tera.register_filter("cut", cut);
tera.register_filter("divisibleby", divisibleby);
tera.register_filter("floatformat", floatformat);
tera.register_filter("escapejs", escapejs);
tera.register_filter("yesno", yesno);
tera.register_filter("get_digit", get_digit);
tera.register_filter("dictsort", dictsort);
tera.register_filter("slugify_unicode", slugify_unicode);
tera.register_filter("iriencode", iriencode);
tera.register_filter("wordwrap", wordwrap);
tera.register_filter("mask_email", mask_email);
tera.register_filter("mask_card", mask_card);
tera.register_filter("mask_phone", mask_phone);
tera.register_filter("dictsortreversed", dictsortreversed);
tera.register_filter("oxford_join", oxford_join);
tera.register_filter("initials", initials);
tera.register_filter("truncatechars", truncatechars);
tera.register_filter("normalize_whitespace", normalize_whitespace);
}
fn pluralize(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let count = count_for_pluralize(value);
let suffix_arg = args
.get("suffix")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("s");
let (singular, plural) = parse_pluralize_arg(suffix_arg);
let out = if count == 1 { singular } else { plural };
Ok(to_value(out)?)
}
fn count_for_pluralize(value: &Value) -> i64 {
if let Some(n) = value.as_i64() {
return n;
}
if let Some(n) = value.as_u64() {
return i64::try_from(n).unwrap_or(i64::MAX);
}
if let Some(f) = value.as_f64() {
return f as i64;
}
if let Some(s) = value.as_str() {
return i64::try_from(s.chars().count()).unwrap_or(i64::MAX);
}
if let Some(arr) = value.as_array() {
return i64::try_from(arr.len()).unwrap_or(i64::MAX);
}
if let Some(obj) = value.as_object() {
return i64::try_from(obj.len()).unwrap_or(i64::MAX);
}
0
}
fn parse_pluralize_arg(arg: &str) -> (String, String) {
let parts: Vec<&str> = arg.split(',').collect();
match parts.as_slice() {
[""] | [] => (String::new(), "s".to_owned()),
[one] => (String::new(), (*one).to_owned()),
[singular, plural, ..] => ((*singular).to_owned(), (*plural).to_owned()),
}
}
fn truncatewords(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let n = args
.get("count")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.unwrap_or(-1);
if n <= 0 {
return Ok(to_value("")?);
}
let n = usize::try_from(n).unwrap_or(0);
let words: Vec<&str> = s.split_whitespace().collect();
if words.len() <= n {
return Ok(to_value(words.join(" "))?);
}
let kept = words[..n].join(" ");
Ok(to_value(format!("{kept} …"))?)
}
fn linebreaks(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
if s.is_empty() {
return Ok(to_value("")?);
}
let s = s.replace("\r\n", "\n").replace('\r', "\n");
let escaped = html_escape(&s);
let paragraphs: Vec<String> = escaped
.split("\n\n")
.filter(|p| !p.is_empty())
.map(|p| {
let with_br = p.replace('\n', "<br>");
format!("<p>{with_br}</p>")
})
.collect();
Ok(to_value(paragraphs.join("\n\n"))?)
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn default_if_none(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
if value.is_null() {
let fallback = args
.get("default")
.or_else(|| args.values().next())
.cloned()
.unwrap_or(Value::String(String::new()));
return Ok(fallback);
}
Ok(value.clone())
}
fn add(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let rhs = args.get("value").or_else(|| args.values().next());
let Some(rhs) = rhs else {
return Ok(value.clone());
};
if let (Some(a), Some(b)) = (value.as_i64(), rhs.as_i64()) {
return Ok(to_value(a + b)?);
}
if let (Some(a), Some(b)) = (value.as_f64(), rhs.as_f64()) {
return Ok(to_value(a + b)?);
}
if let (Some(a), Some(b)) = (value.as_array(), rhs.as_array()) {
let mut out = a.clone();
out.extend(b.iter().cloned());
return Ok(Value::Array(out));
}
let lhs_s = value_to_string(value);
let rhs_s = value_to_string(rhs);
Ok(to_value(format!("{lhs_s}{rhs_s}"))?)
}
fn value_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn cut(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let needle = args
.get("needle")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("");
if needle.is_empty() {
return Ok(value.clone());
}
Ok(to_value(s.replace(needle, ""))?)
}
fn divisibleby(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let n = match value.as_i64() {
Some(n) => n,
None => match value.as_f64() {
Some(f) => f as i64,
None => return Ok(Value::Bool(false)),
},
};
let divisor = args
.get("divisor")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.unwrap_or(0);
if divisor == 0 {
return Ok(Value::Bool(false));
}
Ok(Value::Bool(n % divisor == 0))
}
fn floatformat(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(f) = value.as_f64() else {
return Ok(value.clone());
};
let precision: i64 = args
.get("precision")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.unwrap_or(-1);
let abs = precision.unsigned_abs() as usize;
let drop_trailing = precision <= 0;
let formatted = format!("{f:.abs$}");
if drop_trailing {
if let Some((int_part, frac_part)) = formatted.split_once('.') {
if frac_part.chars().all(|c| c == '0') {
return Ok(to_value(int_part)?);
}
}
}
Ok(to_value(formatted)?)
}
fn escapejs(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' | '\'' | '"' | '>' | '<' | '&' | '=' | '-' | ';' | '`' => {
out.push_str(&format!("\\u{:04X}", ch as u32));
}
'\u{2028}' | '\u{2029}' => {
out.push_str(&format!("\\u{:04X}", ch as u32));
}
ch if (ch as u32) < 0x20 => {
out.push_str(&format!("\\u{:04X}", ch as u32));
}
other => out.push(other),
}
}
Ok(to_value(out)?)
}
fn yesno(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let raw = args
.get("choices")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("yes,no,maybe");
let mut parts = raw.splitn(3, ',');
let yes = parts.next().unwrap_or("yes");
let no = parts.next().unwrap_or("no");
let maybe = parts.next().unwrap_or(no);
let pick = if value.is_null() {
maybe
} else if value.as_bool().unwrap_or(true) {
yes
} else {
no
};
Ok(to_value(pick)?)
}
fn get_digit(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(n) = value.as_i64() else {
return Ok(value.clone());
};
let idx = args
.get("index")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.unwrap_or(0);
if idx < 1 {
return Ok(value.clone());
}
let s = n.unsigned_abs().to_string();
let chars: Vec<char> = s.chars().rev().collect();
let pick = chars
.get(usize::try_from(idx - 1).unwrap_or(0))
.copied()
.unwrap_or('0');
Ok(to_value(pick.to_string())?)
}
fn dictsort(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(arr) = value.as_array() else {
return Ok(value.clone());
};
let key = args
.get("key")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("");
if key.is_empty() {
return Ok(value.clone());
}
let mut sorted = arr.clone();
sorted.sort_by(|a, b| {
let ak = a.get(key).cloned().unwrap_or(Value::Null);
let bk = b.get(key).cloned().unwrap_or(Value::Null);
compare_values(&ak, &bk)
});
Ok(Value::Array(sorted))
}
fn dictsortreversed(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(arr) = value.as_array() else {
return Ok(value.clone());
};
let key = args
.get("key")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("");
if key.is_empty() {
return Ok(value.clone());
}
let mut sorted = arr.clone();
sorted.sort_by(|a, b| {
let ak = a.get(key).cloned().unwrap_or(Value::Null);
let bk = b.get(key).cloned().unwrap_or(Value::Null);
compare_values(&bk, &ak) });
Ok(Value::Array(sorted))
}
fn oxford_join(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(arr) = value.as_array() else {
return Ok(value.clone());
};
let conj = args
.get("conj")
.or_else(|| args.values().next())
.and_then(Value::as_str)
.unwrap_or("and");
let items: Vec<String> = arr
.iter()
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
let out = match items.as_slice() {
[] => String::new(),
[one] => one.clone(),
[a, b] => format!("{a} {conj} {b}"),
rest => {
let (last, init) = rest.split_last().unwrap();
let head = init.join(", ");
format!("{head}, {conj} {last}")
}
};
Ok(to_value(out)?)
}
fn initials(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let limit = args
.get("count")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.map(|n| usize::try_from(n).unwrap_or(usize::MAX));
let mut out = String::new();
for word in s.split_whitespace() {
if let Some(ch) = word.chars().find(|c| c.is_alphabetic()) {
for upper_ch in ch.to_uppercase() {
out.push(upper_ch);
}
if let Some(lim) = limit {
if out.chars().count() >= lim {
break;
}
}
}
}
Ok(to_value(out)?)
}
fn truncatechars(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let n = match args
.get("count")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
{
Some(n) if n >= 0 => n as usize,
_ => return Ok(value.clone()),
};
let total = s.chars().count();
if total <= n {
return Ok(value.clone());
}
if n == 0 {
return Ok(to_value("")?);
}
let keep = n.saturating_sub(1);
let truncated: String = s.chars().take(keep).collect();
Ok(to_value(format!("{truncated}…"))?)
}
fn normalize_whitespace(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let normalized = s.split_whitespace().collect::<Vec<_>>().join(" ");
Ok(to_value(normalized)?)
}
fn compare_values(a: &Value, b: &Value) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
fn rank(v: &Value) -> u8 {
match v {
Value::Null => 0,
Value::Bool(_) => 1,
Value::Number(_) => 2,
Value::String(_) => 3,
Value::Array(_) => 4,
Value::Object(_) => 5,
}
}
let ra = rank(a);
let rb = rank(b);
if ra != rb {
return ra.cmp(&rb);
}
match (a, b) {
(Value::Null, Value::Null) => Equal,
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
(Value::Number(x), Value::Number(y)) => x
.as_f64()
.unwrap_or(0.0)
.partial_cmp(&y.as_f64().unwrap_or(0.0))
.unwrap_or(Equal),
(Value::String(x), Value::String(y)) => x.cmp(y),
_ => a.to_string().cmp(&b.to_string()),
}
}
fn slugify_unicode(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let mut out = String::with_capacity(s.len());
let mut last_was_dash = false;
for ch in s.to_lowercase().chars() {
if ch.is_alphanumeric() || ch == '_' {
out.push(ch);
last_was_dash = false;
} else if !last_was_dash && !out.is_empty() {
out.push('-');
last_was_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
Ok(to_value(out)?)
}
fn iriencode(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
let safe = matches!(
byte,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~'
| b'/' | b':' | b'?' | b'#' | b'[' | b']' | b'@'
| b'!' | b'$' | b'&' | b'\'' | b'(' | b')'
| b'*' | b'+' | b',' | b';' | b'=' | b'%'
);
if safe {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
Ok(to_value(out)?)
}
fn wordwrap(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let width = args
.get("width")
.or_else(|| args.values().next())
.and_then(Value::as_i64)
.unwrap_or(0);
if width <= 0 {
return Ok(value.clone());
}
let width = usize::try_from(width).unwrap_or(usize::MAX);
let wrapped = s
.split('\n')
.map(|line| wrap_one_line(line, width))
.collect::<Vec<_>>()
.join("\n");
Ok(to_value(wrapped)?)
}
fn wrap_one_line(line: &str, width: usize) -> String {
let mut out = String::with_capacity(line.len());
let mut current_len = 0usize;
for (i, word) in line.split_whitespace().enumerate() {
let word_chars = word.chars().count();
if i == 0 {
out.push_str(word);
current_len = word_chars;
continue;
}
let proposed = current_len + 1 + word_chars; if proposed <= width {
out.push(' ');
out.push_str(word);
current_len = proposed;
} else {
out.push('\n');
out.push_str(word);
current_len = word_chars;
}
}
out
}
fn mask_email(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let Some((local, domain)) = s.split_once('@') else {
return Ok(value.clone());
};
let local_chars: Vec<char> = local.chars().collect();
let masked_local = match local_chars.len() {
0 => String::new(),
1 => "*".to_owned(),
2 => format!("{}*", local_chars[0]),
n => format!("{}***{}", local_chars[0], local_chars[n - 1]),
};
Ok(to_value(format!("{masked_local}@{domain}"))?)
}
fn mask_card(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let cleaned: String = s
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect();
if cleaned.is_empty() || !cleaned.chars().all(|c| c.is_ascii_digit()) {
return Ok(value.clone());
}
let chars: Vec<char> = cleaned.chars().collect();
let n = chars.len();
if n <= 4 {
return Ok(to_value("*".repeat(n))?);
}
let last4: String = chars[n - 4..].iter().collect();
let masked = "*".repeat(n - 4);
Ok(to_value(format!("{masked}{last4}"))?)
}
fn mask_phone(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
let Some(s) = value.as_str() else {
return Ok(value.clone());
};
let total_digits = s.chars().filter(|c| c.is_ascii_digit()).count();
if total_digits == 0 {
return Ok(value.clone());
}
let keep_from = if total_digits <= 4 {
total_digits
} else {
total_digits - 4
};
let mut digit_idx = 0;
let masked: String = s
.chars()
.map(|c| {
if c.is_ascii_digit() {
let keep = digit_idx >= keep_from;
digit_idx += 1;
if keep {
c
} else {
'*'
}
} else {
c
}
})
.collect();
Ok(to_value(masked)?)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn args_pos(v: Value) -> HashMap<String, Value> {
let mut m = HashMap::new();
m.insert("0".to_owned(), v);
m
}
#[test]
fn pluralize_no_arg_one_yields_empty() {
let out = pluralize(&json!(1), &HashMap::new()).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn pluralize_no_arg_zero_yields_s() {
let out = pluralize(&json!(0), &HashMap::new()).unwrap();
assert_eq!(out, json!("s"));
}
#[test]
fn pluralize_no_arg_two_yields_s() {
let out = pluralize(&json!(2), &HashMap::new()).unwrap();
assert_eq!(out, json!("s"));
}
#[test]
fn pluralize_with_single_token_arg_uses_it_as_plural() {
let out = pluralize(&json!(2), &args_pos(json!("es"))).unwrap();
assert_eq!(out, json!("es"));
let out_one = pluralize(&json!(1), &args_pos(json!("es"))).unwrap();
assert_eq!(out_one, json!(""));
}
#[test]
fn pluralize_with_singular_and_plural_tokens() {
let two = pluralize(&json!(2), &args_pos(json!("y,ies"))).unwrap();
assert_eq!(two, json!("ies"));
let one = pluralize(&json!(1), &args_pos(json!("y,ies"))).unwrap();
assert_eq!(one, json!("y"));
}
#[test]
fn pluralize_uses_array_length() {
let one = pluralize(&json!(["a"]), &HashMap::new()).unwrap();
assert_eq!(one, json!(""));
let three = pluralize(&json!(["a", "b", "c"]), &HashMap::new()).unwrap();
assert_eq!(three, json!("s"));
}
#[test]
fn truncatewords_keeps_full_input_when_under_limit() {
let out = truncatewords(&json!("two words"), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!("two words"));
}
#[test]
fn truncatewords_trims_to_n_and_appends_ellipsis() {
let out = truncatewords(&json!("Joel is a slug"), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!("Joel is …"));
}
#[test]
fn truncatewords_collapses_multi_whitespace() {
let out = truncatewords(&json!("a\tb c"), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!("a b …"));
}
#[test]
fn truncatewords_zero_or_negative_returns_empty() {
let zero = truncatewords(&json!("anything"), &args_pos(json!(0))).unwrap();
assert_eq!(zero, json!(""));
let neg = truncatewords(&json!("anything"), &args_pos(json!(-1))).unwrap();
assert_eq!(neg, json!(""));
}
#[test]
fn truncatewords_passes_non_string_through() {
let out = truncatewords(&json!(42), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn linebreaks_wraps_single_paragraph_in_p_with_br_for_newlines() {
let out = linebreaks(&json!("foo\nbar"), &HashMap::new()).unwrap();
assert_eq!(out, json!("<p>foo<br>bar</p>"));
}
#[test]
fn linebreaks_splits_blank_lines_into_separate_paragraphs() {
let out = linebreaks(&json!("foo\n\nbar"), &HashMap::new()).unwrap();
assert_eq!(out, json!("<p>foo</p>\n\n<p>bar</p>"));
}
#[test]
fn linebreaks_html_escapes_input() {
let out = linebreaks(&json!("<script>x</script>"), &HashMap::new()).unwrap();
assert_eq!(out, json!("<p><script>x</script></p>"));
}
#[test]
fn linebreaks_empty_input_passes_through() {
let out = linebreaks(&json!(""), &HashMap::new()).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn linebreaks_normalizes_crlf_line_endings() {
let out = linebreaks(&json!("foo\r\nbar\r\n\r\nbaz"), &HashMap::new()).unwrap();
assert_eq!(out, json!("<p>foo<br>bar</p>\n\n<p>baz</p>"));
}
#[test]
fn default_if_none_replaces_null() {
let out = default_if_none(&Value::Null, &args_pos(json!("fallback"))).unwrap();
assert_eq!(out, json!("fallback"));
}
#[test]
fn default_if_none_passes_non_null_through() {
let out = default_if_none(&json!("hi"), &args_pos(json!("fallback"))).unwrap();
assert_eq!(out, json!("hi"));
}
#[test]
fn default_if_none_empty_string_is_not_null() {
let out = default_if_none(&json!(""), &args_pos(json!("fallback"))).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn default_if_none_passes_zero_through() {
let out = default_if_none(&json!(0), &args_pos(json!("fallback"))).unwrap();
assert_eq!(out, json!(0));
}
#[test]
fn register_filters_makes_pluralize_callable_via_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ n|pluralize }}").unwrap();
let mut ctx = tera::Context::new();
ctx.insert("n", &2);
assert_eq!(tera.render("t", &ctx).unwrap(), "s");
}
#[test]
fn register_filters_makes_truncatewords_callable_via_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s|truncatewords(count=2) }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "the quick brown fox");
assert_eq!(tera.render("t", &ctx).unwrap(), "the quick …");
}
#[test]
fn register_filters_makes_linebreaks_callable_via_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s|linebreaks|safe }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "a\nb");
assert_eq!(tera.render("t", &ctx).unwrap(), "<p>a<br>b</p>");
}
#[test]
fn add_sums_two_integers() {
let out = add(&json!(4), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!(9));
}
#[test]
fn add_sums_two_floats() {
let out = add(&json!(1.5), &args_pos(json!(2.25))).unwrap();
assert_eq!(out, json!(3.75));
}
#[test]
fn add_concatenates_strings() {
let out = add(&json!("abc"), &args_pos(json!("def"))).unwrap();
assert_eq!(out, json!("abcdef"));
}
#[test]
fn add_concatenates_arrays() {
let out = add(&json!([1, 2]), &args_pos(json!([3, 4]))).unwrap();
assert_eq!(out, json!([1, 2, 3, 4]));
}
#[test]
fn add_mixed_types_stringifies_concat() {
let out = add(&json!("5"), &args_pos(json!(3))).unwrap();
assert_eq!(out, json!("53"));
}
#[test]
fn cut_removes_every_occurrence_of_needle() {
let out = cut(&json!("Hello, world"), &args_pos(json!("l"))).unwrap();
assert_eq!(out, json!("Heo, word"));
}
#[test]
fn cut_handles_multichar_needle() {
let out = cut(&json!("abc abc"), &args_pos(json!("abc"))).unwrap();
assert_eq!(out, json!(" "));
}
#[test]
fn cut_empty_needle_returns_input_unchanged() {
let out = cut(&json!("hello"), &args_pos(json!(""))).unwrap();
assert_eq!(out, json!("hello"));
}
#[test]
fn cut_non_string_value_passes_through() {
let out = cut(&json!(42), &args_pos(json!("x"))).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn divisibleby_true_when_evenly_divisible() {
let out = divisibleby(&json!(6), &args_pos(json!(3))).unwrap();
assert_eq!(out, json!(true));
}
#[test]
fn divisibleby_false_with_remainder() {
let out = divisibleby(&json!(7), &args_pos(json!(3))).unwrap();
assert_eq!(out, json!(false));
}
#[test]
fn divisibleby_false_on_zero_divisor() {
let out = divisibleby(&json!(5), &args_pos(json!(0))).unwrap();
assert_eq!(out, json!(false));
}
#[test]
fn divisibleby_handles_zero_value() {
let out = divisibleby(&json!(0), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!(true));
}
#[test]
fn floatformat_default_is_one_decimal_with_trailing_drop() {
let nonzero = floatformat(&json!(34.23234), &HashMap::new()).unwrap();
assert_eq!(nonzero, json!("34.2"));
let round = floatformat(&json!(34.0), &HashMap::new()).unwrap();
assert_eq!(round, json!("34"));
}
#[test]
fn floatformat_positive_arg_keeps_trailing_zeros() {
let out = floatformat(&json!(34.0), &args_pos(json!(3))).unwrap();
assert_eq!(out, json!("34.000"));
}
#[test]
fn floatformat_positive_arg_truncates_to_n_decimals() {
let out = floatformat(&json!(34.23234), &args_pos(json!(3))).unwrap();
assert_eq!(out, json!("34.232"));
}
#[test]
fn floatformat_negative_arg_drops_trailing_zeros() {
let zero = floatformat(&json!(34.0), &args_pos(json!(-3))).unwrap();
assert_eq!(zero, json!("34"));
let nonzero = floatformat(&json!(34.23234), &args_pos(json!(-3))).unwrap();
assert_eq!(nonzero, json!("34.232"));
}
#[test]
fn floatformat_passes_non_numeric_through() {
let out = floatformat(&json!("hi"), &HashMap::new()).unwrap();
assert_eq!(out, json!("hi"));
}
#[test]
fn register_filters_wires_add_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ n|add(value=5) }}").unwrap();
let mut ctx = tera::Context::new();
ctx.insert("n", &4);
assert_eq!(tera.render("t", &ctx).unwrap(), "9");
}
#[test]
fn register_filters_wires_cut_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s|cut(needle=\"l\") }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "Hello");
assert_eq!(tera.render("t", &ctx).unwrap(), "Heo");
}
#[test]
fn register_filters_wires_floatformat_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ n|floatformat(precision=2) }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("n", &3.14159);
assert_eq!(tera.render("t", &ctx).unwrap(), "3.14");
}
#[test]
fn escapejs_escapes_quotes_and_brackets() {
let out = escapejs(&json!("<script>alert('xss')</script>"), &HashMap::new()).unwrap();
let s = out.as_str().unwrap().to_owned();
assert!(!s.contains('<'), "got: {s}");
assert!(!s.contains('>'), "got: {s}");
assert!(!s.contains('\''), "got: {s}");
assert!(s.contains("script"));
assert!(s.contains("alert"));
}
#[test]
fn escapejs_escapes_line_separators() {
let ls = "a\u{2028}b\u{2029}c";
let out = escapejs(&json!(ls), &HashMap::new()).unwrap();
let s = out.as_str().unwrap().to_owned();
assert!(s.contains("\\u2028"));
assert!(s.contains("\\u2029"));
assert!(!s.contains('\u{2028}'));
}
#[test]
fn escapejs_escapes_control_chars() {
let out = escapejs(&json!("a\nb"), &HashMap::new()).unwrap();
let s = out.as_str().unwrap().to_owned();
assert!(s.contains("\\u000A"), "got: {s}");
assert!(!s.contains('\n'));
}
#[test]
fn escapejs_passes_non_string_through() {
let out = escapejs(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn yesno_true_maps_to_first_token() {
let out = yesno(&json!(true), &args_pos(json!("yes,no"))).unwrap();
assert_eq!(out, json!("yes"));
}
#[test]
fn yesno_false_maps_to_second_token() {
let out = yesno(&json!(false), &args_pos(json!("yes,no"))).unwrap();
assert_eq!(out, json!("no"));
}
#[test]
fn yesno_null_uses_third_token_when_provided() {
let out = yesno(&Value::Null, &args_pos(json!("yes,no,maybe"))).unwrap();
assert_eq!(out, json!("maybe"));
}
#[test]
fn yesno_null_falls_back_to_no_when_third_token_omitted() {
let out = yesno(&Value::Null, &args_pos(json!("yes,no"))).unwrap();
assert_eq!(out, json!("no"));
}
#[test]
fn yesno_no_arg_defaults_to_yes_no_maybe() {
let out = yesno(&Value::Null, &HashMap::new()).unwrap();
assert_eq!(out, json!("maybe"));
}
#[test]
fn get_digit_extracts_rightmost_digit() {
let out = get_digit(&json!(1234), &args_pos(json!(1))).unwrap();
assert_eq!(out, json!("4"));
}
#[test]
fn get_digit_extracts_leftmost_digit() {
let out = get_digit(&json!(1234), &args_pos(json!(4))).unwrap();
assert_eq!(out, json!("1"));
}
#[test]
fn get_digit_past_leftmost_returns_zero() {
let out = get_digit(&json!(12), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!("0"));
}
#[test]
fn get_digit_invalid_index_returns_value_unchanged() {
let out = get_digit(&json!(12), &args_pos(json!(0))).unwrap();
assert_eq!(out, json!(12));
let neg = get_digit(&json!(12), &args_pos(json!(-1))).unwrap();
assert_eq!(neg, json!(12));
}
#[test]
fn get_digit_non_integer_passes_through() {
let out = get_digit(&json!("hi"), &args_pos(json!(1))).unwrap();
assert_eq!(out, json!("hi"));
}
#[test]
fn dictsort_sorts_by_string_key() {
let input = json!([
{"name": "Charlie"},
{"name": "Alice"},
{"name": "Bob"},
]);
let out = dictsort(&input, &args_pos(json!("name"))).unwrap();
let arr = out.as_array().unwrap();
assert_eq!(arr[0]["name"], "Alice");
assert_eq!(arr[1]["name"], "Bob");
assert_eq!(arr[2]["name"], "Charlie");
}
#[test]
fn dictsort_sorts_by_numeric_key() {
let input = json!([
{"age": 30},
{"age": 5},
{"age": 20},
]);
let out = dictsort(&input, &args_pos(json!("age"))).unwrap();
let arr = out.as_array().unwrap();
assert_eq!(arr[0]["age"], 5);
assert_eq!(arr[1]["age"], 20);
assert_eq!(arr[2]["age"], 30);
}
#[test]
fn dictsort_entries_missing_key_sort_first() {
let input = json!([
{"name": "C"},
{"other": "x"},
{"name": "A"},
]);
let out = dictsort(&input, &args_pos(json!("name"))).unwrap();
let arr = out.as_array().unwrap();
assert!(
arr[0].get("name").is_none(),
"missing-key entry should be first"
);
assert_eq!(arr[1]["name"], "A");
assert_eq!(arr[2]["name"], "C");
}
#[test]
fn dictsort_non_list_passes_through() {
let out = dictsort(&json!({"k": 1}), &args_pos(json!("k"))).unwrap();
assert_eq!(out, json!({"k": 1}));
}
#[test]
fn dictsort_empty_key_passes_through() {
let input = json!([{"a": 2}, {"a": 1}]);
let out = dictsort(&input, &args_pos(json!(""))).unwrap();
assert_eq!(out, input);
}
#[test]
fn register_filters_wires_yesno_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ b|yesno(choices=\"on,off\") }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("b", &true);
assert_eq!(tera.render("t", &ctx).unwrap(), "on");
}
#[test]
fn register_filters_wires_get_digit_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ n|get_digit(index=2) }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("n", &567);
assert_eq!(tera.render("t", &ctx).unwrap(), "6");
}
#[test]
fn slugify_unicode_handles_basic_ascii() {
let out = slugify_unicode(&json!("Hello World!"), &HashMap::new()).unwrap();
assert_eq!(out, json!("hello-world"));
}
#[test]
fn slugify_unicode_preserves_non_ascii_letters() {
let out = slugify_unicode(&json!("Привет мир"), &HashMap::new()).unwrap();
assert_eq!(out, json!("привет-мир"));
}
#[test]
fn slugify_unicode_lowercases_uppercase_diacritics() {
let out = slugify_unicode(&json!("CAFÉ AU LAIT"), &HashMap::new()).unwrap();
assert_eq!(out, json!("café-au-lait"));
}
#[test]
fn slugify_unicode_collapses_punctuation_runs_to_single_dash() {
let out = slugify_unicode(&json!("a---b___c d!!!e"), &HashMap::new()).unwrap();
assert_eq!(out, json!("a-b___c-d-e"));
}
#[test]
fn slugify_unicode_strips_leading_and_trailing_dashes() {
let out = slugify_unicode(&json!(" hello "), &HashMap::new()).unwrap();
assert_eq!(out, json!("hello"));
let out2 = slugify_unicode(&json!("!!!hi!!!"), &HashMap::new()).unwrap();
assert_eq!(out2, json!("hi"));
}
#[test]
fn slugify_unicode_keeps_digits_and_underscores() {
let out = slugify_unicode(&json!("year_2026!post_42"), &HashMap::new()).unwrap();
assert_eq!(out, json!("year_2026-post_42"));
}
#[test]
fn slugify_unicode_passes_non_string_through() {
let out = slugify_unicode(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn register_filters_wires_slugify_unicode_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s|slugify_unicode }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "Hello World 日本");
assert_eq!(tera.render("t", &ctx).unwrap(), "hello-world-日本");
}
#[test]
fn iriencode_preserves_ascii_uri_structural_chars() {
let out = iriencode(
&json!("https://example.com/path/with?q=1&z=2#frag"),
&HashMap::new(),
)
.unwrap();
assert_eq!(out, json!("https://example.com/path/with?q=1&z=2#frag"));
}
#[test]
fn iriencode_percent_encodes_non_ascii_bytes() {
let out = iriencode(&json!("/blog/café"), &HashMap::new()).unwrap();
assert_eq!(out, json!("/blog/caf%C3%A9"));
}
#[test]
fn iriencode_percent_encodes_spaces() {
let out = iriencode(&json!("/path with spaces"), &HashMap::new()).unwrap();
assert_eq!(out, json!("/path%20with%20spaces"));
}
#[test]
fn iriencode_preserves_already_percent_encoded_input() {
let out = iriencode(&json!("/path/caf%C3%A9"), &HashMap::new()).unwrap();
assert_eq!(out, json!("/path/caf%C3%A9"));
}
#[test]
fn iriencode_passes_non_string_through() {
let out = iriencode(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn register_filters_wires_iriencode_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ url|iriencode|safe }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("url", "/blog/café?lang=fr");
assert_eq!(tera.render("t", &ctx).unwrap(), "/blog/caf%C3%A9?lang=fr");
}
#[test]
fn wordwrap_wraps_at_word_boundaries() {
let out = wordwrap(&json!("Joel is a slug"), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!("Joel\nis a\nslug"));
}
#[test]
fn wordwrap_keeps_line_under_width() {
let out = wordwrap(&json!("one two three"), &args_pos(json!(7))).unwrap();
assert_eq!(out, json!("one two\nthree"));
}
#[test]
fn wordwrap_passes_short_input_unchanged() {
let out = wordwrap(&json!("hi"), &args_pos(json!(80))).unwrap();
assert_eq!(out, json!("hi"));
}
#[test]
fn wordwrap_honors_existing_newlines() {
let out = wordwrap(
&json!("first paragraph here\nsecond paragraph here"),
&args_pos(json!(15)),
)
.unwrap();
assert_eq!(out, json!("first paragraph\nhere\nsecond\nparagraph here"));
}
#[test]
fn wordwrap_zero_or_negative_width_passes_through() {
let zero = wordwrap(&json!("anything goes"), &args_pos(json!(0))).unwrap();
assert_eq!(zero, json!("anything goes"));
let neg = wordwrap(&json!("anything goes"), &args_pos(json!(-1))).unwrap();
assert_eq!(neg, json!("anything goes"));
}
#[test]
fn wordwrap_long_word_stands_alone_no_hyphenation() {
let out = wordwrap(&json!("a verylongword b"), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!("a\nverylongword\nb"));
}
#[test]
fn wordwrap_passes_non_string_through() {
let out = wordwrap(&json!(42), &args_pos(json!(5))).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn register_filters_wires_wordwrap_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s|wordwrap(width=5) }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "Joel is a slug");
assert_eq!(tera.render("t", &ctx).unwrap(), "Joel\nis a\nslug");
}
#[test]
fn mask_email_masks_middle_of_local_part() {
let out = mask_email(&json!("alice@example.com"), &HashMap::new()).unwrap();
assert_eq!(out, json!("a***e@example.com"));
}
#[test]
fn mask_email_handles_short_local_parts_gracefully() {
let one = mask_email(&json!("a@example.com"), &HashMap::new()).unwrap();
assert_eq!(one, json!("*@example.com"));
let two = mask_email(&json!("ab@example.com"), &HashMap::new()).unwrap();
assert_eq!(two, json!("a*@example.com"));
let three = mask_email(&json!("abc@example.com"), &HashMap::new()).unwrap();
assert_eq!(three, json!("a***c@example.com"));
}
#[test]
fn mask_email_handles_empty_local_part() {
let out = mask_email(&json!("@example.com"), &HashMap::new()).unwrap();
assert_eq!(out, json!("@example.com"));
}
#[test]
fn mask_email_passes_through_non_email() {
let out = mask_email(&json!("not-an-email"), &HashMap::new()).unwrap();
assert_eq!(out, json!("not-an-email"));
}
#[test]
fn mask_email_passes_through_non_string() {
let out = mask_email(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn register_filters_wires_mask_email_through_tera() {
let mut tera = Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ email|mask_email }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("email", "operator@example.com");
assert_eq!(tera.render("t", &ctx).unwrap(), "o***r@example.com");
}
#[test]
fn mask_card_keeps_last_four_digits() {
let out = mask_card(&json!("4111111111111111"), &HashMap::new()).unwrap();
assert_eq!(out, json!("************1111"));
}
#[test]
fn mask_card_strips_separators_then_masks() {
let out = mask_card(&json!("4111 1111 1111 1111"), &HashMap::new()).unwrap();
assert_eq!(out, json!("************1111"));
let out2 = mask_card(&json!("4111-1111-1111-1111"), &HashMap::new()).unwrap();
assert_eq!(out2, json!("************1111"));
}
#[test]
fn mask_card_fully_masks_short_input() {
let out = mask_card(&json!("1234"), &HashMap::new()).unwrap();
assert_eq!(out, json!("****"));
let out2 = mask_card(&json!("12"), &HashMap::new()).unwrap();
assert_eq!(out2, json!("**"));
}
#[test]
fn mask_card_passes_through_non_digit_input() {
let out = mask_card(&json!("not a card"), &HashMap::new()).unwrap();
assert_eq!(out, json!("not a card"));
let out2 = mask_card(&json!("4111-abcd"), &HashMap::new()).unwrap();
assert_eq!(out2, json!("4111-abcd"));
}
#[test]
fn mask_card_passes_through_non_string() {
let out = mask_card(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn mask_phone_keeps_separators_and_last_four_digits() {
let out = mask_phone(&json!("+1 415 555 2671"), &HashMap::new()).unwrap();
assert_eq!(out, json!("+* *** *** 2671"));
let out2 = mask_phone(&json!("(415) 555-2671"), &HashMap::new()).unwrap();
assert_eq!(out2, json!("(***) ***-2671"));
}
#[test]
fn mask_phone_handles_bare_digits() {
let out = mask_phone(&json!("4155552671"), &HashMap::new()).unwrap();
assert_eq!(out, json!("******2671"));
}
#[test]
fn mask_phone_fully_masks_short_input() {
let out = mask_phone(&json!("123"), &HashMap::new()).unwrap();
assert_eq!(out, json!("***"));
}
#[test]
fn mask_phone_passes_through_no_digits() {
let out = mask_phone(&json!("no digits"), &HashMap::new()).unwrap();
assert_eq!(out, json!("no digits"));
}
#[test]
fn mask_phone_passes_through_non_string() {
let out = mask_phone(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn dictsortreversed_sorts_descending_by_string_key() {
let input = json!([
{"name": "Alice"},
{"name": "Charlie"},
{"name": "Bob"},
]);
let out = dictsortreversed(&input, &args_pos(json!("name"))).unwrap();
let arr = out.as_array().unwrap();
assert_eq!(arr[0]["name"], "Charlie");
assert_eq!(arr[1]["name"], "Bob");
assert_eq!(arr[2]["name"], "Alice");
}
#[test]
fn dictsortreversed_sorts_descending_by_numeric_key() {
let input = json!([
{"age": 5},
{"age": 30},
{"age": 20},
]);
let out = dictsortreversed(&input, &args_pos(json!("age"))).unwrap();
let arr = out.as_array().unwrap();
assert_eq!(arr[0]["age"], 30);
assert_eq!(arr[1]["age"], 20);
assert_eq!(arr[2]["age"], 5);
}
#[test]
fn dictsortreversed_missing_key_sorts_last() {
let input = json!([
{"name": "A"},
{"other": "x"},
{"name": "C"},
]);
let out = dictsortreversed(&input, &args_pos(json!("name"))).unwrap();
let arr = out.as_array().unwrap();
assert_eq!(arr[0]["name"], "C");
assert_eq!(arr[1]["name"], "A");
assert!(arr[2].get("name").is_none());
}
#[test]
fn dictsortreversed_passes_through_non_list_and_empty_key() {
let out = dictsortreversed(&json!({"k": 1}), &args_pos(json!("k"))).unwrap();
assert_eq!(out, json!({"k": 1}));
let input = json!([{"a": 2}, {"a": 1}]);
let out2 = dictsortreversed(&input, &args_pos(json!(""))).unwrap();
assert_eq!(out2, input);
}
#[test]
fn oxford_join_empty_list_yields_empty_string() {
let out = oxford_join(&json!([]), &HashMap::new()).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn oxford_join_single_item_is_returned_as_is() {
let out = oxford_join(&json!(["Alice"]), &HashMap::new()).unwrap();
assert_eq!(out, json!("Alice"));
}
#[test]
fn oxford_join_two_items_uses_and_without_comma() {
let out = oxford_join(&json!(["Alice", "Bob"]), &HashMap::new()).unwrap();
assert_eq!(out, json!("Alice and Bob"));
}
#[test]
fn oxford_join_three_items_uses_serial_comma() {
let out = oxford_join(&json!(["Alice", "Bob", "Carol"]), &HashMap::new()).unwrap();
assert_eq!(out, json!("Alice, Bob, and Carol"));
}
#[test]
fn oxford_join_many_items_uses_serial_comma() {
let out = oxford_join(&json!(["one", "two", "three", "four"]), &HashMap::new()).unwrap();
assert_eq!(out, json!("one, two, three, and four"));
}
#[test]
fn oxford_join_with_custom_conjunction() {
let out = oxford_join(&json!(["red", "green", "blue"]), &args_pos(json!("or"))).unwrap();
assert_eq!(out, json!("red, green, or blue"));
}
#[test]
fn oxford_join_passes_through_non_array() {
let out = oxford_join(&json!("not a list"), &HashMap::new()).unwrap();
assert_eq!(out, json!("not a list"));
}
#[test]
fn initials_extracts_first_char_of_each_word() {
let out = initials(&json!("Alice Bob"), &HashMap::new()).unwrap();
assert_eq!(out, json!("AB"));
}
#[test]
fn initials_uppercases_lowercase_input() {
let out = initials(&json!("alice m. bob"), &HashMap::new()).unwrap();
assert_eq!(out, json!("AMB"));
}
#[test]
fn initials_single_word_yields_one_char() {
let out = initials(&json!("Alice"), &HashMap::new()).unwrap();
assert_eq!(out, json!("A"));
}
#[test]
fn initials_skips_leading_non_alpha_in_word() {
let out = initials(&json!("123 Alice"), &HashMap::new()).unwrap();
assert_eq!(out, json!("A"));
}
#[test]
fn initials_count_limits_result() {
let out = initials(&json!("alice m. bob"), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!("AM"));
}
#[test]
fn initials_handles_unicode_letters() {
let out = initials(&json!("ümlaut éclair"), &HashMap::new()).unwrap();
assert_eq!(out, json!("ÜÉ"));
}
#[test]
fn initials_empty_input_yields_empty() {
let out = initials(&json!(""), &HashMap::new()).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn initials_passes_through_non_string() {
let out = initials(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn truncatechars_keeps_input_under_or_equal_to_count() {
let out = truncatechars(&json!("Hi"), &args_pos(json!(10))).unwrap();
assert_eq!(out, json!("Hi"));
let boundary = truncatechars(&json!("abc"), &args_pos(json!(3))).unwrap();
assert_eq!(boundary, json!("abc"));
}
#[test]
fn truncatechars_appends_ellipsis_within_budget() {
let out = truncatechars(&json!("Joel is a slug"), &args_pos(json!(7))).unwrap();
assert_eq!(out, json!("Joel i…"));
let three = truncatechars(&json!("abcd"), &args_pos(json!(3))).unwrap();
assert_eq!(three, json!("ab…"));
}
#[test]
fn truncatechars_zero_count_returns_empty() {
let out = truncatechars(&json!("anything"), &args_pos(json!(0))).unwrap();
assert_eq!(out, json!(""));
}
#[test]
fn truncatechars_negative_or_non_int_passes_through() {
let neg = truncatechars(&json!("anything"), &args_pos(json!(-1))).unwrap();
assert_eq!(neg, json!("anything"));
let str_arg = truncatechars(&json!("anything"), &args_pos(json!("five"))).unwrap();
assert_eq!(str_arg, json!("anything"));
}
#[test]
fn truncatechars_unicode_counts_chars_not_bytes() {
let out = truncatechars(&json!("éé"), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!("éé"));
let trunc = truncatechars(&json!("ééé"), &args_pos(json!(2))).unwrap();
assert_eq!(trunc, json!("é…"));
}
#[test]
fn truncatechars_passes_through_non_string() {
let out = truncatechars(&json!(42), &args_pos(json!(2))).unwrap();
assert_eq!(out, json!(42));
}
#[test]
fn normalize_whitespace_collapses_runs_of_spaces() {
let out = normalize_whitespace(&json!(" hello world "), &HashMap::new()).unwrap();
assert_eq!(out, json!("hello world"));
}
#[test]
fn normalize_whitespace_collapses_tabs_and_newlines() {
let out = normalize_whitespace(&json!("line\n\n\twith tabs"), &HashMap::new()).unwrap();
assert_eq!(out, json!("line with tabs"));
}
#[test]
fn normalize_whitespace_trims_leading_and_trailing() {
let out = normalize_whitespace(&json!("\t hello \n"), &HashMap::new()).unwrap();
assert_eq!(out, json!("hello"));
}
#[test]
fn normalize_whitespace_empty_input_yields_empty() {
let out = normalize_whitespace(&json!(""), &HashMap::new()).unwrap();
assert_eq!(out, json!(""));
let blank = normalize_whitespace(&json!(" \t\n"), &HashMap::new()).unwrap();
assert_eq!(blank, json!(""));
}
#[test]
fn normalize_whitespace_passes_through_non_string() {
let out = normalize_whitespace(&json!(42), &HashMap::new()).unwrap();
assert_eq!(out, json!(42));
}
}