Skip to main content

anodizer_core/template/
base_tera.rs

1use regex::Regex;
2use serde_json::Value;
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::sync::LazyLock;
6use tera::TeraResult;
7
8use sha1::Digest as Sha1Digest;
9use sha2::Digest as Sha2Digest;
10use sha3::Digest as Sha3Digest;
11
12// --- Helper functions for template engine ---
13
14use super::engine_adapter::{JsonRegisterExt, try_get_value};
15use crate::path_util::expand_tilde;
16
17/// Convert a JSON template `Value` to a string for comparison purposes.
18/// Numbers, bools, and strings are all stringified; null → "".
19/// Returns `Cow::Borrowed` for strings (avoiding a clone), `Cow::Owned` otherwise.
20fn value_to_string(v: &Value) -> Cow<'_, str> {
21    match v {
22        Value::String(s) => Cow::Borrowed(s.as_str()),
23        Value::Number(n) => Cow::Owned(n.to_string()),
24        Value::Bool(b) => Cow::Owned(b.to_string()),
25        Value::Null => Cow::Borrowed(""),
26        // Arrays and objects: fall back to JSON representation
27        other => Cow::Owned(other.to_string()),
28    }
29}
30
31/// Render a single Go/C-style `printf` value in its default (`%v`) form.
32///
33/// Strings render verbatim, numbers/bools render via their JSON scalar form,
34/// null renders empty, and arrays/objects fall back to their JSON text.
35fn printf_default(v: &Value) -> String {
36    value_to_string(v).into_owned()
37}
38
39/// A parsed `printf` conversion: optional flags, width, precision, and verb.
40#[derive(Clone, Copy)]
41struct PrintfSpec {
42    minus: bool,
43    plus: bool,
44    space: bool,
45    zero: bool,
46    hash: bool,
47    width: Option<usize>,
48    precision: Option<usize>,
49    verb: char,
50}
51
52/// Apply width padding (respecting the `-` left-align and `0` zero-pad flags)
53/// to an already-formatted body. Zero-padding is skipped for left-aligned
54/// output (matching C/Go) and when a sign/prefix must stay leftmost.
55fn pad(spec: &PrintfSpec, body: String, numeric_sign_prefix: Option<(&str, &str)>) -> String {
56    let (sign, prefix, core) = match numeric_sign_prefix {
57        Some((sign, prefix)) => (sign, prefix, body.as_str()),
58        None => ("", "", body.as_str()),
59    };
60    let assembled = format!("{}{}{}", sign, prefix, core);
61    let Some(width) = spec.width else {
62        return assembled;
63    };
64    let len = assembled.chars().count();
65    if len >= width {
66        return assembled;
67    }
68    let padding = width - len;
69    if spec.minus {
70        format!("{}{}", assembled, " ".repeat(padding))
71    } else if spec.zero && numeric_sign_prefix.is_some() {
72        // Zero-pad after the sign/prefix so `%+04d` of 7 → `+007`.
73        format!("{}{}{}{}", sign, prefix, "0".repeat(padding), core)
74    } else if spec.zero {
75        format!("{}{}", "0".repeat(padding), assembled)
76    } else {
77        format!("{}{}", " ".repeat(padding), assembled)
78    }
79}
80
81/// Pad an integer conversion to width, honoring Go's rule that an explicit
82/// precision DISABLES the `0` (zero-pad) flag for integer verbs — width is then
83/// space-padded. `%08.5d` of 7 → `   00007`, not `00000007`. Precision already
84/// supplied the zero-padding via [`int_precision`]; the `0` flag would
85/// double-count. Float verbs never call this — they keep honoring `0` with
86/// precision (`%08.2f` of 3.14 → `00003.14`).
87fn pad_int(spec: &PrintfSpec, body: String, sign: &str, prefix: &str) -> String {
88    if spec.precision.is_some() && spec.zero {
89        let no_zero = PrintfSpec {
90            zero: false,
91            ..*spec
92        };
93        pad(&no_zero, body, Some((sign, prefix)))
94    } else {
95        pad(spec, body, Some((sign, prefix)))
96    }
97}
98
99/// Compute the sign string for a signed numeric conversion given the value's
100/// sign and the active `+`/space flags.
101fn numeric_sign(negative: bool, plus: bool, space: bool) -> &'static str {
102    if negative {
103        "-"
104    } else if plus {
105        "+"
106    } else if space {
107        " "
108    } else {
109        ""
110    }
111}
112
113/// Apply integer-precision zero-padding to an unsigned digit string.
114///
115/// In Go, precision on `%d`/`%b`/`%o`/`%x`/`%X` sets the MINIMUM digit count,
116/// zero-left-padded (distinct from width, and applied before any sign/prefix or
117/// width padding). `%.5d` of 7 → `00007`. As a special case, precision 0 of the
118/// value 0 prints nothing (`%.0d` of 0 → ``), so width padding then applies to
119/// the empty string.
120fn int_precision(digits: &str, precision: Option<usize>) -> String {
121    match precision {
122        Some(0) if digits == "0" => String::new(),
123        Some(p) if digits.len() < p => format!("{}{}", "0".repeat(p - digits.len()), digits),
124        _ => digits.to_string(),
125    }
126}
127
128/// Normalize a Rust-formatted scientific string to Go's exponent style.
129///
130/// Rust's `{:e}` emits an unsigned exponent with no leading zeros (`1.23e4`,
131/// `1e-7`); Go always writes a sign and a minimum of two exponent digits
132/// (`1.23e+04`, `1e-07`, `1e+100`). When the input has no `e`/`E` (e.g. a `%g`
133/// value rendered in plain-decimal form), it is returned unchanged except for
134/// the requested exponent letter case.
135fn go_exponent(s: &str, uppercase: bool) -> String {
136    let letter = if uppercase { 'E' } else { 'e' };
137    let Some(pos) = s.find(['e', 'E']) else {
138        return s.to_string();
139    };
140    let (mantissa, exp_part) = s.split_at(pos);
141    // exp_part starts with the exponent letter; skip it to read the value.
142    let exp_str = &exp_part[1..];
143    let (sign, digits) = match exp_str.strip_prefix('-') {
144        Some(rest) => ('-', rest),
145        None => ('+', exp_str.strip_prefix('+').unwrap_or(exp_str)),
146    };
147    // Pad to a minimum of two digits, preserving 3+ digit exponents.
148    let padded = if digits.len() < 2 {
149        format!("{:0>2}", digits)
150    } else {
151        digits.to_string()
152    };
153    format!("{}{}{}{}", mantissa, letter, sign, padded)
154}
155
156/// Trim trailing fractional zeros (and a now-naked decimal point) from a plain
157/// decimal string, matching Go `%g`'s `%f`-branch zero-trimming.
158fn trim_fraction_zeros(s: &str) -> &str {
159    if !s.contains('.') {
160        return s;
161    }
162    let trimmed = s.trim_end_matches('0');
163    trimmed.strip_suffix('.').unwrap_or(trimmed)
164}
165
166/// Format a non-negative magnitude with Go `%g`/`%G` semantics.
167///
168/// Go selects exponential form when the decimal exponent is `< -4` or `>= eprec`
169/// (where `eprec` is 6 for the default/shortest precision, otherwise the
170/// requested precision), and decimal form otherwise; trailing fractional zeros
171/// are trimmed in both branches. The shortest mantissa comes from Rust's
172/// `{:e}`, which already yields the minimal unique digit count.
173fn format_g(mag: f64, precision: Option<usize>, uppercase: bool) -> String {
174    // Rust's `{:e}` gives the shortest mantissa and the decimal exponent, e.g.
175    // `9.9999999e7` for 99999999.0; parse the exponent to drive the branch.
176    let sci = format!("{:e}", mag);
177    let exp: i32 = sci
178        .split(['e', 'E'])
179        .nth(1)
180        .and_then(|e| e.parse().ok())
181        .unwrap_or(0);
182    let eprec = precision.map(|p| p as i32).unwrap_or(6).max(1);
183
184    if exp < -4 || exp >= eprec {
185        // Exponential branch. Go uses `prec-1` fractional digits for an
186        // explicit precision; for shortest it uses the minimal mantissa.
187        let body = match precision {
188            Some(p) => format!("{:.*e}", p.saturating_sub(1), mag),
189            None => sci.clone(),
190        };
191        let normalized = go_exponent(&body, uppercase);
192        // Trim trailing zeros in the mantissa for explicit precision (Go does).
193        if precision.is_some()
194            && let Some(epos) = normalized.find(['e', 'E'])
195        {
196            let (mantissa, exp_part) = normalized.split_at(epos);
197            return format!("{}{}", trim_fraction_zeros(mantissa), exp_part);
198        }
199        normalized
200    } else {
201        // Decimal branch. For shortest, render the full decimal value and trim;
202        // for explicit precision, Go uses `prec - dp` fractional digits, which
203        // `trim_fraction_zeros` then collapses — emulated by formatting with
204        // enough fractional digits and trimming.
205        let body = match precision {
206            // Significant-digit precision → fractional digits = prec - (exp+1).
207            Some(p) => {
208                let frac = (p as i32 - (exp + 1)).max(0) as usize;
209                format!("{:.*}", frac, mag)
210            }
211            None => format!("{}", mag),
212        };
213        trim_fraction_zeros(&body).to_string()
214    }
215}
216
217/// Ceiling for `printf` width and precision, guarding against an attacker (or a
218/// typo) requesting a huge `" ".repeat(width)` allocation from a template.
219const PRINTF_FIELD_MAX: usize = 100_000;
220
221/// Format one `printf` verb against a value, returning a `tera::Error` for any
222/// verb outside the supported bounded subset.
223///
224/// Supported verbs: `%s %d %v %x %X %o %b %c %q %f %e %E %g %G %t %%`, with
225/// flags `- + 0 (space) #`, width, and precision.
226fn format_verb(spec: &PrintfSpec, value: Option<&Value>) -> Result<String, tera::Error> {
227    let val = || -> Result<&Value, tera::Error> {
228        value.ok_or_else(|| {
229            tera::Error::message(format!("printf: missing argument for %{}", spec.verb))
230        })
231    };
232    match spec.verb {
233        's' => {
234            let mut s = printf_default(val()?);
235            if let Some(prec) = spec.precision {
236                s = s.chars().take(prec).collect();
237            }
238            Ok(pad(spec, s, None))
239        }
240        'v' => Ok(pad(spec, printf_default(val()?), None)),
241        't' => {
242            let b = val()?
243                .as_bool()
244                .ok_or_else(|| tera::Error::message("printf: %t expects a boolean argument"))?;
245            Ok(pad(spec, b.to_string(), None))
246        }
247        'q' => {
248            let s = printf_default(val()?);
249            Ok(pad(spec, format!("{:?}", s), None))
250        }
251        'c' => {
252            let v = val()?;
253            let code = v
254                .as_u64()
255                .ok_or_else(|| tera::Error::message("printf: %c expects a non-negative integer"))?;
256            let ch = u32::try_from(code)
257                .ok()
258                .and_then(char::from_u32)
259                .ok_or_else(|| {
260                    tera::Error::message(format!("printf: %c: {} is not a valid code point", code))
261                })?;
262            Ok(pad(spec, ch.to_string(), None))
263        }
264        'd' => {
265            let n = val()?
266                .as_i64()
267                .ok_or_else(|| tera::Error::message("printf: %d expects an integer argument"))?;
268            let sign = numeric_sign(n < 0, spec.plus, spec.space);
269            Ok(pad_int(
270                spec,
271                int_precision(&n.unsigned_abs().to_string(), spec.precision),
272                sign,
273                "",
274            ))
275        }
276        'b' | 'o' | 'x' | 'X' => {
277            let n = val()?.as_i64().ok_or_else(|| {
278                tera::Error::message(format!(
279                    "printf: %{} expects an integer argument",
280                    spec.verb
281                ))
282            })?;
283            let mag = n.unsigned_abs();
284            let digits = match spec.verb {
285                'b' => format!("{:b}", mag),
286                'o' => format!("{:o}", mag),
287                'x' => format!("{:x}", mag),
288                'X' => format!("{:X}", mag),
289                _ => unreachable!(),
290            };
291            let body = int_precision(&digits, spec.precision);
292            let sign = numeric_sign(n < 0, spec.plus, spec.space);
293            let prefix = if spec.hash {
294                match spec.verb {
295                    'b' => "0b",
296                    'o' => "0",
297                    'x' => "0x",
298                    'X' => "0X",
299                    _ => "",
300                }
301            } else {
302                ""
303            };
304            Ok(pad_int(spec, body, sign, prefix))
305        }
306        'f' | 'e' | 'E' | 'g' | 'G' => {
307            let f = val()?.as_f64().ok_or_else(|| {
308                tera::Error::message(format!("printf: %{} expects a numeric argument", spec.verb))
309            })?;
310            let prec = spec.precision.unwrap_or(6);
311            let mag = f.abs();
312            let body = match spec.verb {
313                'f' => format!("{:.*}", prec, mag),
314                // Rust prints `1.23e4`; Go prints `1.23e+04` (signed exponent,
315                // min two digits). Reformat the exponent to match Go so pasted
316                // GoReleaser templates produce byte-identical output.
317                'e' | 'E' => go_exponent(&format!("{:.*e}", prec, mag), spec.verb == 'E'),
318                // %g/%G pick exponential vs decimal form per Go's rule
319                // (exp < -4 or >= eprec), trimming trailing zeros.
320                'g' | 'G' => format_g(mag, spec.precision, spec.verb == 'G'),
321                _ => unreachable!(),
322            };
323            let sign = numeric_sign(f.is_sign_negative() && f != 0.0, spec.plus, spec.space);
324            Ok(pad(spec, body, Some((sign, ""))))
325        }
326        other => Err(tera::Error::message(format!(
327            "printf: unsupported verb %{} (supported: s d v x X o b c q f e E g G t %%)",
328            other
329        ))),
330    }
331}
332
333/// Render a Go/C-style `printf` format string against its argument list.
334///
335/// Implements a bounded verb subset (`%s %d %v %x %X %o %b %c %q %f %e %E %g
336/// %G %t %%`) with the `- + 0 (space) #` flags plus width and precision. Returns a
337/// `tera::Error` on an unsupported verb or a malformed conversion rather than
338/// panicking or emitting silently-wrong output.
339fn sprintf(format: &str, args: &[Value]) -> Result<String, tera::Error> {
340    let mut out = String::new();
341    let mut arg_idx = 0usize;
342    let mut chars = format.chars().peekable();
343
344    while let Some(c) = chars.next() {
345        if c != '%' {
346            out.push(c);
347            continue;
348        }
349        // Literal `%%`.
350        if chars.peek() == Some(&'%') {
351            chars.next();
352            out.push('%');
353            continue;
354        }
355
356        let mut spec = PrintfSpec {
357            minus: false,
358            plus: false,
359            space: false,
360            zero: false,
361            hash: false,
362            width: None,
363            precision: None,
364            verb: ' ',
365        };
366
367        // Flags.
368        while let Some(&f) = chars.peek() {
369            match f {
370                '-' => spec.minus = true,
371                '+' => spec.plus = true,
372                ' ' => spec.space = true,
373                '0' => spec.zero = true,
374                '#' => spec.hash = true,
375                _ => break,
376            }
377            chars.next();
378        }
379
380        // Width.
381        let mut width_digits = String::new();
382        while let Some(&d) = chars.peek() {
383            if d.is_ascii_digit() {
384                width_digits.push(d);
385                chars.next();
386            } else {
387                break;
388            }
389        }
390        if !width_digits.is_empty() {
391            // A value that overflows usize is, a fortiori, over the ceiling.
392            let w = width_digits.parse::<usize>().unwrap_or(usize::MAX);
393            if w > PRINTF_FIELD_MAX {
394                return Err(tera::Error::message(format!(
395                    "printf width {} exceeds maximum {}",
396                    width_digits, PRINTF_FIELD_MAX
397                )));
398            }
399            spec.width = Some(w);
400        }
401
402        // Precision.
403        if chars.peek() == Some(&'.') {
404            chars.next();
405            let mut prec_digits = String::new();
406            while let Some(&d) = chars.peek() {
407                if d.is_ascii_digit() {
408                    prec_digits.push(d);
409                    chars.next();
410                } else {
411                    break;
412                }
413            }
414            // Empty precision (`%.d`) means zero; overflow means over the cap.
415            let p = if prec_digits.is_empty() {
416                0
417            } else {
418                prec_digits.parse::<usize>().unwrap_or(usize::MAX)
419            };
420            if p > PRINTF_FIELD_MAX {
421                return Err(tera::Error::message(format!(
422                    "printf precision {} exceeds maximum {}",
423                    prec_digits, PRINTF_FIELD_MAX
424                )));
425            }
426            spec.precision = Some(p);
427        }
428
429        let verb = chars.next().ok_or_else(|| {
430            tera::Error::message("printf: format string ends with a dangling '%'")
431        })?;
432        spec.verb = verb;
433
434        let value = args.get(arg_idx);
435        out.push_str(&format_verb(&spec, value)?);
436        // `%%` is the only verb that consumes no argument; it returned early above.
437        arg_idx += 1;
438    }
439
440    Ok(out)
441}
442
443/// Translate a Go time format layout string to a chrono strftime format string.
444///
445/// Go uses a reference date (Mon Jan 2 15:04:05 MST 2006) as the layout template.
446/// If the format string contains `%` characters, it's already chrono format and is
447/// returned as-is. Otherwise, Go reference date components are replaced with chrono
448/// strftime equivalents, longest patterns first to avoid partial matches.
449pub(super) fn translate_go_time_format(fmt: &str) -> Cow<'_, str> {
450    // If the format contains `%`, it's already chrono strftime format.
451    if fmt.contains('%') {
452        return Cow::Borrowed(fmt);
453    }
454
455    // Check if any Go reference date patterns are present.
456    // Go reference date: Mon Jan 2 15:04:05 MST 2006
457    const GO_MARKERS: &[&str] = &[
458        "2006", "06", "January", "Jan", "01", "Monday", "Mon", "02", "15", "03", "04", "05", "PM",
459        "pm", "-0700", "Z0700", "MST",
460    ];
461    let has_go_patterns = GO_MARKERS.iter().any(|p| fmt.contains(p));
462    if !has_go_patterns {
463        return Cow::Borrowed(fmt);
464    }
465
466    // Replace Go patterns with chrono equivalents, longest first.
467    // Order matters: longer patterns must be replaced before shorter ones to avoid
468    // partial matches (e.g. "January" before "Jan", "2006" before "06").
469    let mut result = fmt.to_string();
470
471    let replacements: &[(&str, &str)] = &[
472        // Multi-char patterns (longest first)
473        ("January", "%B"), // full month name
474        ("Monday", "%A"),  // full weekday name
475        ("-0700", "%z"),   // timezone offset
476        ("Z0700", "%z"),   // timezone offset (Z variant)
477        ("2006", "%Y"),    // 4-digit year
478        ("Jan", "%b"),     // abbreviated month
479        ("Mon", "%a"),     // abbreviated weekday
480        ("MST", "%Z"),     // timezone name
481        ("PM", "%p"),      // AM/PM
482        ("pm", "%P"),      // am/pm
483        ("15", "%H"),      // 24-hour
484        ("06", "%y"),      // 2-digit year
485        ("05", "%S"),      // second
486        ("04", "%M"),      // minute
487        ("03", "%I"),      // 12-hour zero-padded
488        ("02", "%d"),      // zero-padded day
489        ("01", "%m"),      // zero-padded month
490    ];
491
492    for (go_pat, chrono_pat) in replacements {
493        result = result.replace(go_pat, chrono_pat);
494    }
495
496    Cow::Owned(result)
497}
498
499enum VersionPart {
500    Major,
501    Minor,
502    Patch,
503}
504
505/// Parse and increment a semver version string, returning a tera-friendly
506/// error when the input isn't valid semver.
507///
508/// Version-increment behavior, which calls
509/// `semver.MustParse(v)` and surfaces a hard template error on non-semver
510/// input. Previously every component was best-effort `unwrap_or(0)`, so
511/// `{{ "garbage" | incpatch }}` silently returned `"0.0.1"`.
512fn increment_version(v: &str, part: VersionPart) -> Result<String, tera::Error> {
513    let stripped = v.strip_prefix('v').unwrap_or(v);
514    let parts: Vec<&str> = stripped.splitn(3, '.').collect();
515    let invalid = || {
516        tera::Error::message(format!(
517            "incpatch/incminor/incmajor: '{}' is not a valid semver version (expected MAJOR.MINOR.PATCH)",
518            v
519        ))
520    };
521    if parts.len() < 3 {
522        return Err(invalid());
523    }
524    let major: u64 = parts
525        .first()
526        .and_then(|s| s.parse().ok())
527        .ok_or_else(invalid)?;
528    let minor: u64 = parts
529        .get(1)
530        .and_then(|s| s.parse().ok())
531        .ok_or_else(invalid)?;
532    let patch: u64 = parts
533        .get(2)
534        .and_then(|s| {
535            // Handle prerelease suffix: "3-rc.1" → "3"
536            s.split('-').next().and_then(|n| n.parse().ok())
537        })
538        .ok_or_else(invalid)?;
539    let prefix = if v.starts_with('v') { "v" } else { "" };
540    Ok(match part {
541        VersionPart::Major => format!("{}{}.0.0", prefix, major + 1),
542        VersionPart::Minor => format!("{}{}.{}.0", prefix, major, minor + 1),
543        VersionPart::Patch => format!("{}{}.{}.{}", prefix, major, minor, patch + 1),
544    })
545}
546
547/// Escape a string for safe inclusion inside a **double-quoted Ruby string
548/// literal**: replace `\` with `\\` first, then `"` with `\"`.
549///
550/// Backslash must be escaped before the quote so the quote's inserted escape
551/// backslash is not itself doubled. Use this anywhere a user-supplied value is
552/// spliced into a `"…"` Ruby literal — both the Tera [`ruby_escape`](register_ruby_escape)
553/// filter and the Rust `format!`/`push_str` sites in the Homebrew
554/// formula/cask generators route through it so there is a single escape
555/// implementation.
556pub fn ruby_escape_str(s: &str) -> String {
557    s.replace('\\', "\\\\").replace('"', "\\\"")
558}
559
560/// Register the `ruby_escape` filter on a Tera instance.
561///
562/// The filter delegates to [`ruby_escape_str`], making user-supplied values
563/// (descriptions, homepages, display names, URLs, …) safe to interpolate into
564/// `desc "…"`, `homepage "…"`, `url "…"`, and similar Homebrew formula/cask
565/// stanzas without producing invalid Ruby.
566///
567/// Shared by both [`BASE_TERA`] and `parse_static` so that the trusted
568/// formula/cask templates have the filter available even though they build a
569/// fresh `tera::Tera` rather than cloning `BASE_TERA`.
570pub(super) fn register_ruby_escape(tera: &mut tera::Tera) {
571    tera.register_json_filter(
572        "ruby_escape",
573        |value: &Value, _: &HashMap<String, Value>| {
574            let s = try_get_value!("ruby_escape", "value", String, value);
575            Ok(Value::String(ruby_escape_str(&s)))
576        },
577    );
578}
579
580/// Base Tera instance with custom filters pre-registered.
581/// Cloned per render() call (cheap — no templates to clone).
582pub(super) static BASE_TERA: LazyLock<tera::Tera> = LazyLock::new(|| {
583    let mut tera = tera::Tera::default();
584    register_ruby_escape(&mut tera);
585
586    // Compatibility aliases
587    tera.register_json_filter("tolower", |value: &Value, _: &HashMap<String, Value>| {
588        let s = try_get_value!("tolower", "value", String, value);
589        Ok(Value::String(s.to_lowercase()))
590    });
591    tera.register_json_filter("toupper", |value: &Value, _: &HashMap<String, Value>| {
592        let s = try_get_value!("toupper", "value", String, value);
593        Ok(Value::String(s.to_uppercase()))
594    });
595
596    // trimprefix(prefix="...") — strip prefix from a string
597    tera.register_json_filter(
598        "trimprefix",
599        |value: &Value, args: &HashMap<String, Value>| {
600            let s = try_get_value!("trimprefix", "value", String, value);
601            let prefix = args
602                .get("prefix")
603                .and_then(|v| v.as_str())
604                .ok_or_else(|| tera::Error::message("trimprefix requires a `prefix` argument"))?;
605            let result = s.strip_prefix(prefix).unwrap_or(&s);
606            Ok(Value::String(result.to_string()))
607        },
608    );
609
610    // trimsuffix(suffix="...") — strip suffix from a string
611    tera.register_json_filter(
612        "trimsuffix",
613        |value: &Value, args: &HashMap<String, Value>| {
614            let s = try_get_value!("trimsuffix", "value", String, value);
615            let suffix = args
616                .get("suffix")
617                .and_then(|v| v.as_str())
618                .ok_or_else(|| tera::Error::message("trimsuffix requires a `suffix` argument"))?;
619            let result = s.strip_suffix(suffix).unwrap_or(&s);
620            Ok(Value::String(result.to_string()))
621        },
622    );
623
624    // envOrDefault and isEnvSet are registered as placeholder functions here in
625    // BASE_TERA so that Tera's parser recognizes them. They are overridden with
626    // context-aware closures in render() before actual rendering occurs.
627    // See render() for the real implementations that read from the template
628    // context's Env map.
629    tera.register_json_function(
630        "envOrDefault",
631        |args: &HashMap<String, Value>| -> TeraResult<Value> {
632            let name = args
633                .get("name")
634                .and_then(|v| v.as_str())
635                .ok_or_else(|| tera::Error::message("envOrDefault requires `name` argument"))?;
636            let default = args.get("default").and_then(|v| v.as_str()).unwrap_or("");
637            let value = std::env::var(name).unwrap_or_else(|_| default.to_string());
638            Ok(Value::String(value))
639        },
640    );
641    tera.register_json_function(
642        "isEnvSet",
643        |args: &HashMap<String, Value>| -> TeraResult<Value> {
644            let name = args
645                .get("name")
646                .and_then(|v| v.as_str())
647                .ok_or_else(|| tera::Error::message("isEnvSet requires `name` argument"))?;
648            let is_set = std::env::var(name).map(|v| !v.is_empty()).unwrap_or(false);
649            Ok(Value::Bool(is_set))
650        },
651    );
652
653    // --- Version increment functions ---
654
655    // incpatch("1.2.3") → "1.2.4"
656    tera.register_json_function(
657        "incpatch",
658        |args: &HashMap<String, Value>| -> TeraResult<Value> {
659            let v = args
660                .get("v")
661                .and_then(|v| v.as_str())
662                .ok_or_else(|| tera::Error::message("incpatch requires `v` argument"))?;
663            Ok(Value::String(increment_version(v, VersionPart::Patch)?))
664        },
665    );
666
667    // incminor("1.2.3") → "1.3.0"
668    tera.register_json_function(
669        "incminor",
670        |args: &HashMap<String, Value>| -> TeraResult<Value> {
671            let v = args
672                .get("v")
673                .and_then(|v| v.as_str())
674                .ok_or_else(|| tera::Error::message("incminor requires `v` argument"))?;
675            Ok(Value::String(increment_version(v, VersionPart::Minor)?))
676        },
677    );
678
679    // incmajor("1.2.3") → "2.0.0"
680    tera.register_json_function(
681        "incmajor",
682        |args: &HashMap<String, Value>| -> TeraResult<Value> {
683            let v = args
684                .get("v")
685                .and_then(|v| v.as_str())
686                .ok_or_else(|| tera::Error::message("incmajor requires `v` argument"))?;
687            Ok(Value::String(increment_version(v, VersionPart::Major)?))
688        },
689    );
690
691    // --- Hash functions (all 14 algorithms) ---
692
693    macro_rules! register_hash_fn {
694        ($tera:expr, $name:expr, $hash_fn:expr) => {
695            $tera.register_json_function(
696                $name,
697                |args: &HashMap<String, Value>| -> TeraResult<Value> {
698                    let s = args.get("s").and_then(|v| v.as_str()).ok_or_else(|| {
699                        tera::Error::message(format!("{} requires `s` argument", $name))
700                    })?;
701                    // Read the file; error if it cannot be read (no silent fallback).
702                    let bytes = std::fs::read(s).map_err(|e| {
703                        tera::Error::message(format!(
704                            "{}: failed to read file '{}': {}",
705                            $name, s, e
706                        ))
707                    })?;
708                    Ok(Value::String($hash_fn(&bytes)))
709                },
710            );
711        };
712    }
713
714    register_hash_fn!(tera, "sha1", |b: &[u8]| {
715        let mut h = sha1::Sha1::new();
716        Sha1Digest::update(&mut h, b);
717        crate::hashing::hex_lower(&Sha1Digest::finalize(h))
718    });
719    register_hash_fn!(tera, "sha224", |b: &[u8]| {
720        let mut h = sha2::Sha224::new();
721        Sha2Digest::update(&mut h, b);
722        crate::hashing::hex_lower(&Sha2Digest::finalize(h))
723    });
724    register_hash_fn!(tera, "sha256", |b: &[u8]| {
725        let mut h = sha2::Sha256::new();
726        Sha2Digest::update(&mut h, b);
727        crate::hashing::hex_lower(&Sha2Digest::finalize(h))
728    });
729    register_hash_fn!(tera, "sha384", |b: &[u8]| {
730        let mut h = sha2::Sha384::new();
731        Sha2Digest::update(&mut h, b);
732        crate::hashing::hex_lower(&Sha2Digest::finalize(h))
733    });
734    register_hash_fn!(tera, "sha512", |b: &[u8]| {
735        let mut h = sha2::Sha512::new();
736        Sha2Digest::update(&mut h, b);
737        crate::hashing::hex_lower(&Sha2Digest::finalize(h))
738    });
739    register_hash_fn!(tera, "sha3_224", |b: &[u8]| {
740        let mut h = sha3::Sha3_224::new();
741        Sha3Digest::update(&mut h, b);
742        crate::hashing::hex_lower(&Sha3Digest::finalize(h))
743    });
744    register_hash_fn!(tera, "sha3_256", |b: &[u8]| {
745        let mut h = sha3::Sha3_256::new();
746        Sha3Digest::update(&mut h, b);
747        crate::hashing::hex_lower(&Sha3Digest::finalize(h))
748    });
749    register_hash_fn!(tera, "sha3_384", |b: &[u8]| {
750        let mut h = sha3::Sha3_384::new();
751        Sha3Digest::update(&mut h, b);
752        crate::hashing::hex_lower(&Sha3Digest::finalize(h))
753    });
754    register_hash_fn!(tera, "sha3_512", |b: &[u8]| {
755        let mut h = sha3::Sha3_512::new();
756        Sha3Digest::update(&mut h, b);
757        crate::hashing::hex_lower(&Sha3Digest::finalize(h))
758    });
759    register_hash_fn!(tera, "blake2b", |b: &[u8]| {
760        let mut h = blake2::Blake2b512::new();
761        blake2::Digest::update(&mut h, b);
762        crate::hashing::hex_lower(&blake2::Digest::finalize(h))
763    });
764    register_hash_fn!(tera, "blake2s", |b: &[u8]| {
765        let mut h = blake2::Blake2s256::new();
766        blake2::Digest::update(&mut h, b);
767        crate::hashing::hex_lower(&blake2::Digest::finalize(h))
768    });
769    register_hash_fn!(tera, "blake3", |b: &[u8]| {
770        crate::hashing::hex_lower(blake3::hash(b).as_bytes())
771    });
772    register_hash_fn!(tera, "md5", |b: &[u8]| {
773        let mut h = md5::Md5::new();
774        md5::Digest::update(&mut h, b);
775        crate::hashing::hex_lower(&md5::Digest::finalize(h))
776    });
777    register_hash_fn!(tera, "crc32", |b: &[u8]| {
778        format!("{:08x}", crc32fast::hash(b))
779    });
780
781    // --- File reading functions ---
782
783    // readFile(path="file.txt") — reads file, returns empty string on error.
784    // Intentionally returns empty on all errors (not just ENOENT).
785    // Whitespace is trimmed from the result.
786    tera.register_json_function(
787        "readFile",
788        |args: &HashMap<String, Value>| -> TeraResult<Value> {
789            let path = args
790                .get("path")
791                .and_then(|v| v.as_str())
792                .ok_or_else(|| tera::Error::message("readFile requires `path` argument"))?;
793            let resolved = expand_tilde(path);
794            let content = std::fs::read_to_string(resolved.as_ref()).unwrap_or_default();
795            Ok(Value::String(content.trim().to_string()))
796        },
797    );
798
799    // mustReadFile(path="file.txt") — reads file, errors if file doesn't exist
800    // Whitespace is trimmed from the result.
801    tera.register_json_function(
802        "mustReadFile",
803        |args: &HashMap<String, Value>| -> TeraResult<Value> {
804            let path = args
805                .get("path")
806                .and_then(|v| v.as_str())
807                .ok_or_else(|| tera::Error::message("mustReadFile requires `path` argument"))?;
808            let resolved = expand_tilde(path);
809            let content = std::fs::read_to_string(resolved.as_ref())
810                .map_err(|e| tera::Error::message(format!("mustReadFile: {}: {}", resolved, e)))?;
811            Ok(Value::String(content.trim().to_string()))
812        },
813    );
814
815    // --- time function ---
816    // time(format="%Y-%m-%d") — current UTC time formatted
817    // Also accepts Go time format layout (e.g. "2006-01-02") and translates
818    // to chrono strftime before formatting.
819    //
820    // SDE-aware: honors `SOURCE_DATE_EPOCH` so user templates that embed
821    // `{{ time(format="2006-01-02") }}` in artifact names or metadata
822    // produce byte-stable output under the determinism harness.
823    tera.register_json_function(
824        "time",
825        |args: &HashMap<String, Value>| -> TeraResult<Value> {
826            let fmt = args
827                .get("format")
828                .and_then(|v| v.as_str())
829                .unwrap_or("%Y-%m-%dT%H:%M:%SZ");
830            let chrono_fmt = translate_go_time_format(fmt);
831            let now = crate::sde::resolve_now();
832            Ok(Value::String(now.format(&chrono_fmt).to_string()))
833        },
834    );
835
836    // --- date filter (tera 1.x compat) ---
837    // tera 2.0 removed the builtin `date` filter; 1.x-era anodizer templates
838    // (`{{ Now | date(format='%Y%m%d') }}`) are a consumer contract, so it is
839    // re-registered: input is an i64 unix timestamp or a datetime string
840    // (RFC3339, naive `%Y-%m-%dT%H:%M:%S`, or plain `%Y-%m-%d`); `format` is
841    // chrono strftime, defaulting to `%Y-%m-%d`.
842    // The tz-CONVERSION rules replicate 1.x exactly: `timezone` takes an IANA
843    // name via chrono-tz (a tera 1.x DEFAULT feature, so it worked in every
844    // shipped 1.x binary) and converts only timestamps and offset-carrying
845    // RFC3339 strings; naive datetime and plain-date strings format as UTC
846    // with `timezone` unused. One deliberate widening beyond 1.x: the
847    // no-timezone timestamp path formats a `DateTime<Utc>` (so `%Z` renders
848    // `UTC` and `%z` renders `+0000`) where 1.x formatted a bare
849    // `NaiveDateTime`, whose `%Z`/`%z` formatting failed at render — a
850    // panic→defined-output widening, never a changed byte for any format
851    // 1.x could actually render.
852    // 1.x's `locale` argument needed tera's non-default `date-locale`
853    // feature; without it 1.x never read the argument and SILENTLY formatted
854    // in the POSIX locale. anodizer chooses to error instead — silently
855    // dropping an explicit argument hides the misconfiguration.
856    tera.register_json_filter("date", |value: &Value, args: &HashMap<String, Value>| {
857        use chrono::format::{Item, StrftimeItems};
858        use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
859        use chrono_tz::Tz;
860
861        if args.contains_key("locale") {
862            return Err(tera::Error::message(
863                "date: the `locale` argument is not supported; remove it — \
864                 output is always formatted in the POSIX locale",
865            ));
866        }
867        let format = args
868            .get("format")
869            .and_then(|v| v.as_str())
870            .unwrap_or("%Y-%m-%d");
871        if StrftimeItems::new(format).any(|item| matches!(item, Item::Error)) {
872            return Err(tera::Error::message(format!(
873                "date: invalid format `{format}` — expected chrono strftime \
874                 specifiers (e.g. `%Y-%m-%d`)"
875            )));
876        }
877        let timezone = match args.get("timezone") {
878            Some(val) => {
879                let tz = try_get_value!("date", "timezone", String, val);
880                Some(tz.parse::<Tz>().map_err(|_| {
881                    tera::Error::message(format!(
882                        "date: error parsing `{tz}` as a timezone \
883                         (expected an IANA name, e.g. `America/New_York`)"
884                    ))
885                })?)
886            }
887            None => None,
888        };
889
890        let formatted = match value {
891            Value::Number(n) => {
892                let secs = n.as_i64().ok_or_else(|| {
893                    tera::Error::message(format!("date: expected an integer timestamp, got {n}"))
894                })?;
895                let dt = DateTime::<Utc>::from_timestamp(secs, 0).ok_or_else(|| {
896                    tera::Error::message(format!("date: timestamp {secs} is out of range"))
897                })?;
898                match timezone {
899                    Some(tz) => tz
900                        .from_utc_datetime(&dt.naive_utc())
901                        .format(format)
902                        .to_string(),
903                    None => dt.format(format).to_string(),
904                }
905            }
906            Value::String(s) if s.contains('T') => match s.parse::<DateTime<FixedOffset>>() {
907                Ok(dt) => match timezone {
908                    Some(tz) => dt.with_timezone(&tz).format(format).to_string(),
909                    None => dt.format(format).to_string(),
910                },
911                Err(_) => {
912                    let dt = s.parse::<NaiveDateTime>().map_err(|_| {
913                        tera::Error::message(format!(
914                            "date: cannot parse `{s}` as an RFC3339 or naive datetime"
915                        ))
916                    })?;
917                    // tera 1.x treated an offset-less datetime as UTC and did
918                    // NOT apply `timezone` to it; replicated for parity.
919                    dt.and_utc().format(format).to_string()
920                }
921            },
922            Value::String(s) => {
923                let d = NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
924                    tera::Error::message(format!("date: cannot parse `{s}` as a YYYY-MM-DD date"))
925                })?;
926                // Midnight always exists for a valid NaiveDate; unwrap_or is
927                // unreachable but keeps the closure panic-free.
928                d.and_hms_opt(0, 0, 0)
929                    .unwrap_or_default()
930                    .and_utc()
931                    .format(format)
932                    .to_string()
933            }
934            other => {
935                return Err(tera::Error::message(format!(
936                    "date: expected a timestamp or datetime string, got {other}"
937                )));
938            }
939        };
940        Ok(Value::String(formatted))
941    });
942
943    // --- Path manipulation filters ---
944
945    // dir — returns the directory portion of a path
946    tera.register_json_filter("dir", |value: &Value, _: &HashMap<String, Value>| {
947        let s = try_get_value!("dir", "value", String, value);
948        let p = std::path::Path::new(&s);
949        Ok(Value::String(
950            p.parent()
951                .map(|p| p.to_string_lossy().to_string())
952                .unwrap_or_default(),
953        ))
954    });
955
956    // base — returns the filename portion of a path
957    tera.register_json_filter("base", |value: &Value, _: &HashMap<String, Value>| {
958        let s = try_get_value!("base", "value", String, value);
959        let p = std::path::Path::new(&s);
960        Ok(Value::String(
961            p.file_name()
962                .map(|n| n.to_string_lossy().to_string())
963                .unwrap_or_default(),
964        ))
965    });
966
967    // abs — returns absolute path (prefixes with cwd if relative)
968    tera.register_json_filter("abs", |value: &Value, _: &HashMap<String, Value>| {
969        let s = try_get_value!("abs", "value", String, value);
970        let p = std::path::Path::new(&s);
971        if p.is_absolute() {
972            Ok(Value::String(s))
973        } else {
974            let abs = std::env::current_dir()
975                .map(|cwd| cwd.join(p).to_string_lossy().to_string())
976                .unwrap_or(s);
977            Ok(Value::String(abs))
978        }
979    });
980
981    // urlPathEscape — URL-encode a path segment
982    tera.register_json_filter(
983        "urlPathEscape",
984        |value: &Value, _: &HashMap<String, Value>| {
985            let s = try_get_value!("urlPathEscape", "value", String, value);
986            // Percent-encode all non-unreserved characters per RFC 3986.
987            // Path escaping encodes `/` as `%2F`.
988            let encoded: String = s
989                .bytes()
990                .map(|b| {
991                    if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
992                    {
993                        (b as char).to_string()
994                    } else {
995                        format!("%{:02X}", b)
996                    }
997                })
998                .collect();
999            Ok(Value::String(encoded))
1000        },
1001    );
1002
1003    // mdv2escape — escape Telegram MarkdownV2 special characters
1004    tera.register_json_filter("mdv2escape", |value: &Value, _: &HashMap<String, Value>| {
1005        let s = try_get_value!("mdv2escape", "value", String, value);
1006        let escaped = s
1007            .chars()
1008            .map(|c| {
1009                if "_*[]()~`>#+-=|{}.!".contains(c) {
1010                    format!("\\{}", c)
1011                } else {
1012                    c.to_string()
1013                }
1014            })
1015            .collect::<String>();
1016        Ok(Value::String(escaped))
1017    });
1018
1019    // --- Go-style compatibility functions ---
1020
1021    // contains(s="haystack", substr="needle") — check string containment
1022    tera.register_json_function(
1023        "contains",
1024        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1025            let s = args
1026                .get("s")
1027                .and_then(|v| v.as_str())
1028                .ok_or_else(|| tera::Error::message("contains requires `s` argument"))?;
1029            let substr = args
1030                .get("substr")
1031                .and_then(|v| v.as_str())
1032                .ok_or_else(|| tera::Error::message("contains requires `substr` argument"))?;
1033            Ok(Value::Bool(s.contains(substr)))
1034        },
1035    );
1036
1037    // list(items=[...]) — creates a list from an items array.
1038    // Note: Go-style `(list "a" "b")` syntax is handled by the preprocessor
1039    // (Pass 2 in template_preprocess.rs), which rewrites it to `["a", "b"]`
1040    // before Tera sees it. This function registration exists for direct Tera
1041    // usage, e.g. `{{ list(items=["a", "b"]) }}`.
1042    tera.register_json_function(
1043        "list",
1044        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1045            let items = args
1046                .get("items")
1047                .and_then(|v| v.as_array())
1048                .ok_or_else(|| tera::Error::message("list requires `items` argument"))?;
1049            Ok(Value::Array(items.clone()))
1050        },
1051    );
1052
1053    // map(pairs=[k1, v1, k2, v2, ...]) — create a map from alternating key-value pairs
1054    // Example: {{ $m := map "a" "1" "b" "2" }}
1055    tera.register_json_function(
1056        "map",
1057        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1058            let pairs = args
1059                .get("pairs")
1060                .and_then(|v| v.as_array())
1061                .ok_or_else(|| tera::Error::message("map requires `pairs` argument"))?;
1062            if pairs.len() % 2 != 0 {
1063                return Err(tera::Error::message(
1064                    "map requires an even number of arguments (key-value pairs)",
1065                ));
1066            }
1067            let mut result = serde_json::Map::new();
1068            for chunk in pairs.chunks(2) {
1069                let key = chunk[0].as_str().unwrap_or("").to_string();
1070                result.insert(key, chunk[1].clone());
1071            }
1072            Ok(Value::Object(result))
1073        },
1074    );
1075
1076    // in(items=[...], value="x") — check if a list contains a value
1077    // Go-style: {{ in (list "a" "b" "c") "b" }} → true
1078    // Named:    {{ in(items=["a","b","c"], value="b") }} → true
1079    // Compares all elements as strings.
1080    let in_fn = |args: &HashMap<String, Value>| -> TeraResult<Value> {
1081        let items = args
1082            .get("items")
1083            .and_then(|v| v.as_array())
1084            .ok_or_else(|| {
1085                tera::Error::message("in requires `items` argument (must be an array)")
1086            })?;
1087        let value = args
1088            .get("value")
1089            .ok_or_else(|| tera::Error::message("in requires `value` argument"))?;
1090        // Convert the search value to a string for comparison.
1091        let needle = value_to_string(value);
1092        let found = items.iter().any(|item| value_to_string(item) == needle);
1093        Ok(Value::Bool(found))
1094    };
1095    tera.register_json_function("in", in_fn);
1096    // `contains_any` alias — avoids the Tera `in` keyword clash inside
1097    // `{% set x = ... %}` / `{% if ... %}` bodies.
1098    tera.register_json_function("contains_any", in_fn);
1099
1100    // reReplaceAll(pattern="...", input="...", replacement="...") — regex replace
1101    // Go-style: {{ reReplaceAll "(.*)" .Message "$1" }}
1102    // Named:    {{ reReplaceAll(pattern="(.*)", input="hello", replacement="$1") }}
1103    // Supports capture group references ($1, $2, etc.).
1104    // Returns a Tera error on invalid regex (no panic).
1105    // Note: regex is compiled per call. This is acceptable for template rendering
1106    // where each pattern is typically used once per render pass.
1107    tera.register_json_function(
1108        "reReplaceAll",
1109        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1110            let pattern = args
1111                .get("pattern")
1112                .and_then(|v| v.as_str())
1113                .ok_or_else(|| tera::Error::message("reReplaceAll requires `pattern` argument"))?;
1114            let input = args
1115                .get("input")
1116                .and_then(|v| v.as_str())
1117                .ok_or_else(|| tera::Error::message("reReplaceAll requires `input` argument"))?;
1118            let replacement = args
1119                .get("replacement")
1120                .and_then(|v| v.as_str())
1121                .ok_or_else(|| {
1122                    tera::Error::message("reReplaceAll requires `replacement` argument")
1123                })?;
1124            let re = Regex::new(pattern).map_err(|e| {
1125                tera::Error::message(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
1126            })?;
1127            Ok(Value::String(
1128                re.replace_all(input, replacement).to_string(),
1129            ))
1130        },
1131    );
1132
1133    // reReplaceAll filter form: {{ Field | reReplaceAll(pattern="...", replacement="...") }}
1134    // Note: regex is compiled per call. This is acceptable for template rendering
1135    // where each pattern is typically used once per render pass.
1136    tera.register_json_filter(
1137        "reReplaceAll",
1138        |value: &Value, args: &HashMap<String, Value>| {
1139            let input = try_get_value!("reReplaceAll", "value", String, value);
1140            let pattern = args
1141                .get("pattern")
1142                .and_then(|v| v.as_str())
1143                .ok_or_else(|| {
1144                    tera::Error::message("reReplaceAll filter requires `pattern` argument")
1145                })?;
1146            let replacement = args
1147                .get("replacement")
1148                .and_then(|v| v.as_str())
1149                .ok_or_else(|| {
1150                    tera::Error::message("reReplaceAll filter requires `replacement` argument")
1151                })?;
1152            let re = Regex::new(pattern).map_err(|e| {
1153                tera::Error::message(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
1154            })?;
1155            Ok(Value::String(
1156                re.replace_all(&input, replacement).to_string(),
1157            ))
1158        },
1159    );
1160
1161    // englishJoin(items=[...], oxford=true) — join list items with commas and "and"
1162    // Empty/whitespace-only items are filtered out before joining.
1163    tera.register_json_function(
1164        "englishJoin",
1165        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1166            let items = args
1167                .get("items")
1168                .and_then(|v| v.as_array())
1169                .ok_or_else(|| tera::Error::message("englishJoin requires `items` argument"))?;
1170            let oxford = args.get("oxford").and_then(|v| v.as_bool()).unwrap_or(true);
1171            let strs: Vec<String> = items
1172                .iter()
1173                .map(|v| v.as_str().unwrap_or("").to_string())
1174                .filter(|s| !s.trim().is_empty())
1175                .collect();
1176            let result = match strs.len() {
1177                0 => String::new(),
1178                1 => strs[0].clone(),
1179                2 => format!("{} and {}", strs[0], strs[1]),
1180                _ => {
1181                    // Safe: match arm `_` only reachable when `strs.len() >= 3`
1182                    // per the preceding 0/1/2 cases; split_last is always Some.
1183                    let Some((last, rest)) = strs.split_last() else {
1184                        return Ok(Value::String(String::new()));
1185                    };
1186                    if oxford {
1187                        format!("{}, and {}", rest.join(", "), last)
1188                    } else {
1189                        format!("{} and {}", rest.join(", "), last)
1190                    }
1191                }
1192            };
1193            Ok(Value::String(result))
1194        },
1195    );
1196
1197    // englishJoin filter: {{ list "a" "b" "c" | englishJoin }} — pipe form
1198    tera.register_json_filter(
1199        "englishJoin",
1200        |value: &Value, args: &HashMap<String, Value>| {
1201            let items = value
1202                .as_array()
1203                .ok_or_else(|| tera::Error::message("englishJoin filter expects an array"))?;
1204            let oxford = args.get("oxford").and_then(|v| v.as_bool()).unwrap_or(true);
1205            let strs: Vec<String> = items
1206                .iter()
1207                .map(|v| v.as_str().unwrap_or("").to_string())
1208                .filter(|s| !s.trim().is_empty())
1209                .collect();
1210            let result = match strs.len() {
1211                0 => String::new(),
1212                1 => strs[0].clone(),
1213                2 => format!("{} and {}", strs[0], strs[1]),
1214                _ => {
1215                    // Safe: match arm `_` only reachable when `strs.len() >= 3`
1216                    // per the preceding 0/1/2 cases; split_last is always Some.
1217                    let Some((last, rest)) = strs.split_last() else {
1218                        return Ok(Value::String(String::new()));
1219                    };
1220                    if oxford {
1221                        format!("{}, and {}", rest.join(", "), last)
1222                    } else {
1223                        format!("{} and {}", rest.join(", "), last)
1224                    }
1225                }
1226            };
1227            Ok(Value::String(result))
1228        },
1229    );
1230
1231    // filter as pipe form: {{ items | filter(regexp="pattern") }}
1232    tera.register_json_filter("filter", |value: &Value, args: &HashMap<String, Value>| {
1233        let pattern = args
1234            .get("regexp")
1235            .and_then(|v| v.as_str())
1236            .ok_or_else(|| tera::Error::message("filter requires `regexp` argument"))?;
1237        let re = regex::Regex::new(pattern)
1238            .map_err(|e| tera::Error::message(format!("invalid regex '{}': {}", pattern, e)))?;
1239        let input = value.as_str().unwrap_or("");
1240        let result: Vec<&str> = input.lines().filter(|line| re.is_match(line)).collect();
1241        Ok(Value::String(result.join("\n")))
1242    });
1243
1244    // reverseFilter as pipe form: {{ items | reverseFilter(regexp="pattern") }}
1245    tera.register_json_filter(
1246        "reverseFilter",
1247        |value: &Value, args: &HashMap<String, Value>| {
1248            let pattern = args
1249                .get("regexp")
1250                .and_then(|v| v.as_str())
1251                .ok_or_else(|| tera::Error::message("reverseFilter requires `regexp` argument"))?;
1252            let re = regex::Regex::new(pattern)
1253                .map_err(|e| tera::Error::message(format!("invalid regex '{}': {}", pattern, e)))?;
1254            let input = value.as_str().unwrap_or("");
1255            let result: Vec<&str> = input.lines().filter(|line| !re.is_match(line)).collect();
1256            Ok(Value::String(result.join("\n")))
1257        },
1258    );
1259
1260    // filter(items=<string|array>, regexp="pattern") — keep elements matching regex
1261    // Accepts a multiline STRING (splits by newline, filters lines, rejoins).
1262    // We also accept an array for convenience.
1263    // Note: regex is compiled per call. This is acceptable for template rendering
1264    // where each pattern is typically used once per render pass.
1265    tera.register_json_function(
1266        "filter",
1267        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1268            let items_val = args
1269                .get("items")
1270                .ok_or_else(|| tera::Error::message("filter requires `items` argument"))?;
1271            let pattern = args
1272                .get("regexp")
1273                .and_then(|v| v.as_str())
1274                .ok_or_else(|| tera::Error::message("filter requires `regexp` argument"))?;
1275            let re = Regex::new(pattern)
1276                .map_err(|e| tera::Error::message(format!("filter: invalid regex: {}", e)))?;
1277
1278            if let Some(s) = items_val.as_str() {
1279                // String input: split by newlines, filter matching lines, rejoin
1280                let filtered: String = s
1281                    .lines()
1282                    .filter(|line| re.is_match(line))
1283                    .collect::<Vec<_>>()
1284                    .join("\n");
1285                Ok(Value::String(filtered))
1286            } else if let Some(arr) = items_val.as_array() {
1287                // Array input: filter elements whose string value matches
1288                let filtered: Vec<Value> = arr
1289                    .iter()
1290                    .filter(|v| v.as_str().is_some_and(|s| re.is_match(s)))
1291                    .cloned()
1292                    .collect();
1293                Ok(Value::Array(filtered))
1294            } else {
1295                Err(tera::Error::message(
1296                    "filter: `items` must be a string or array",
1297                ))
1298            }
1299        },
1300    );
1301
1302    // reverseFilter(items=<string|array>, regexp="pattern") — exclude elements matching regex
1303    // Accepts a multiline STRING (splits by newline, filters lines, rejoins).
1304    // We also accept an array for convenience.
1305    // Note: regex is compiled per call. This is acceptable for template rendering
1306    // where each pattern is typically used once per render pass.
1307    tera.register_json_function(
1308        "reverseFilter",
1309        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1310            let items_val = args
1311                .get("items")
1312                .ok_or_else(|| tera::Error::message("reverseFilter requires `items` argument"))?;
1313            let pattern = args
1314                .get("regexp")
1315                .and_then(|v| v.as_str())
1316                .ok_or_else(|| tera::Error::message("reverseFilter requires `regexp` argument"))?;
1317            let re = Regex::new(pattern).map_err(|e| {
1318                tera::Error::message(format!("reverseFilter: invalid regex: {}", e))
1319            })?;
1320
1321            if let Some(s) = items_val.as_str() {
1322                // String input: split by newlines, exclude matching lines, rejoin
1323                let filtered: String = s
1324                    .lines()
1325                    .filter(|line| !re.is_match(line))
1326                    .collect::<Vec<_>>()
1327                    .join("\n");
1328                Ok(Value::String(filtered))
1329            } else if let Some(arr) = items_val.as_array() {
1330                // Array input: exclude elements whose string value matches
1331                let filtered: Vec<Value> = arr
1332                    .iter()
1333                    .filter(|v| !v.as_str().is_some_and(|s| re.is_match(s)))
1334                    .cloned()
1335                    .collect();
1336                Ok(Value::Array(filtered))
1337            } else {
1338                Err(tera::Error::message(
1339                    "reverseFilter: `items` must be a string or array",
1340                ))
1341            }
1342        },
1343    );
1344
1345    // map(items={...}, key="k", default="d") — lookup a key in a map with default
1346    tera.register_json_function(
1347        "indexOrDefault",
1348        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1349            let map = args
1350                .get("map")
1351                .and_then(|v| v.as_object())
1352                .ok_or_else(|| tera::Error::message("indexOrDefault requires `map` argument"))?;
1353            let key = args
1354                .get("key")
1355                .and_then(|v| v.as_str())
1356                .ok_or_else(|| tera::Error::message("indexOrDefault requires `key` argument"))?;
1357            let default = args
1358                .get("default")
1359                .cloned()
1360                .unwrap_or(Value::String(String::new()));
1361            Ok(map.get(key).cloned().unwrap_or(default))
1362        },
1363    );
1364
1365    // --- replace function ---
1366    // Function form: replace(s="input", old="x", new="y")
1367    tera.register_json_function(
1368        "replace",
1369        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1370            let s = args
1371                .get("s")
1372                .and_then(|v| v.as_str())
1373                .ok_or_else(|| tera::Error::message("replace requires `s` argument"))?;
1374            let old = args
1375                .get("old")
1376                .and_then(|v| v.as_str())
1377                .ok_or_else(|| tera::Error::message("replace requires `old` argument"))?;
1378            let new = args
1379                .get("new")
1380                .and_then(|v| v.as_str())
1381                .ok_or_else(|| tera::Error::message("replace requires `new` argument"))?;
1382            Ok(Value::String(s.replace(old, new)))
1383        },
1384    );
1385    // Filter form: {{ Field | replace(from="old", to="new") }}
1386    // Overrides Tera's built-in replace filter. Uses `from`/`to` arg names
1387    // (same as the built-in) so existing Tera templates continue to work.
1388    tera.register_json_filter("replace", |value: &Value, args: &HashMap<String, Value>| {
1389        let s = try_get_value!("replace", "value", String, value);
1390        let from = args
1391            .get("from")
1392            .and_then(|v| v.as_str())
1393            .ok_or_else(|| tera::Error::message("replace filter requires `from` argument"))?;
1394        let to = args
1395            .get("to")
1396            .and_then(|v| v.as_str())
1397            .ok_or_else(|| tera::Error::message("replace filter requires `to` argument"))?;
1398        Ok(Value::String(s.replace(from, to)))
1399    });
1400
1401    // --- split function ---
1402    // split(s="a,b,c", sep=",") → ["a", "b", "c"]
1403    tera.register_json_function(
1404        "split",
1405        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1406            let s = args
1407                .get("s")
1408                .and_then(|v| v.as_str())
1409                .ok_or_else(|| tera::Error::message("split requires `s` argument"))?;
1410            let sep = args
1411                .get("sep")
1412                .and_then(|v| v.as_str())
1413                .ok_or_else(|| tera::Error::message("split requires `sep` argument"))?;
1414            let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
1415            Ok(Value::Array(parts))
1416        },
1417    );
1418    // Filter form: {{ Field | split(sep=".") }}
1419    tera.register_json_filter("split", |value: &Value, args: &HashMap<String, Value>| {
1420        let s = try_get_value!("split", "value", String, value);
1421        let sep = args
1422            .get("sep")
1423            .and_then(|v| v.as_str())
1424            .ok_or_else(|| tera::Error::message("split filter requires `sep` argument"))?;
1425        let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
1426        Ok(Value::Array(parts))
1427    });
1428
1429    // Filter form: {{ Field | contains(substr="needle") }}
1430    tera.register_json_filter(
1431        "contains",
1432        |value: &Value, args: &HashMap<String, Value>| {
1433            let s = try_get_value!("contains", "value", String, value);
1434            let substr = args.get("substr").and_then(|v| v.as_str()).ok_or_else(|| {
1435                tera::Error::message("contains filter requires `substr` argument")
1436            })?;
1437            Ok(Value::Bool(s.contains(substr)))
1438        },
1439    );
1440
1441    // --- trim function ---
1442    // Function form: trim(s="  hello  ") → "hello"
1443    // Tera already has a built-in `trim` filter, so we only add the function form.
1444    tera.register_json_function(
1445        "trim",
1446        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1447            let s = args
1448                .get("s")
1449                .and_then(|v| v.as_str())
1450                .ok_or_else(|| tera::Error::message("trim requires `s` argument"))?;
1451            Ok(Value::String(s.trim().to_string()))
1452        },
1453    );
1454
1455    // --- title function ---
1456    // Function form: title(s="hello world") → "Hello World"
1457    // Tera already has a built-in `title` filter, so we only add the function form.
1458    tera.register_json_function(
1459        "title",
1460        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1461            let s = args
1462                .get("s")
1463                .and_then(|v| v.as_str())
1464                .ok_or_else(|| tera::Error::message("title requires `s` argument"))?;
1465            // Title-case: capitalize the first letter of each word.
1466            let titled = s
1467                .split_whitespace()
1468                .map(|word| {
1469                    let mut chars = word.chars();
1470                    match chars.next() {
1471                        Some(c) => {
1472                            let upper: String = c.to_uppercase().collect();
1473                            format!("{}{}", upper, chars.as_str())
1474                        }
1475                        None => String::new(),
1476                    }
1477                })
1478                .collect::<Vec<_>>()
1479                .join(" ");
1480            Ok(Value::String(titled))
1481        },
1482    );
1483
1484    // --- Dual registration: existing filters also as functions ---
1485
1486    // tolower(s="...") — function form of tolower filter
1487    tera.register_json_function(
1488        "tolower",
1489        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1490            let s = args
1491                .get("s")
1492                .and_then(|v| v.as_str())
1493                .ok_or_else(|| tera::Error::message("tolower requires `s` argument"))?;
1494            Ok(Value::String(s.to_lowercase()))
1495        },
1496    );
1497
1498    // toupper(s="...") — function form of toupper filter
1499    tera.register_json_function(
1500        "toupper",
1501        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1502            let s = args
1503                .get("s")
1504                .and_then(|v| v.as_str())
1505                .ok_or_else(|| tera::Error::message("toupper requires `s` argument"))?;
1506            Ok(Value::String(s.to_uppercase()))
1507        },
1508    );
1509
1510    // trimprefix(s="...", prefix="...") — function form of trimprefix filter
1511    tera.register_json_function(
1512        "trimprefix",
1513        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1514            let s = args
1515                .get("s")
1516                .and_then(|v| v.as_str())
1517                .ok_or_else(|| tera::Error::message("trimprefix requires `s` argument"))?;
1518            let prefix = args
1519                .get("prefix")
1520                .and_then(|v| v.as_str())
1521                .ok_or_else(|| tera::Error::message("trimprefix requires `prefix` argument"))?;
1522            let result = s.strip_prefix(prefix).unwrap_or(s);
1523            Ok(Value::String(result.to_string()))
1524        },
1525    );
1526
1527    // trimsuffix(s="...", suffix="...") — function form of trimsuffix filter
1528    tera.register_json_function(
1529        "trimsuffix",
1530        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1531            let s = args
1532                .get("s")
1533                .and_then(|v| v.as_str())
1534                .ok_or_else(|| tera::Error::message("trimsuffix requires `s` argument"))?;
1535            let suffix = args
1536                .get("suffix")
1537                .and_then(|v| v.as_str())
1538                .ok_or_else(|| tera::Error::message("trimsuffix requires `suffix` argument"))?;
1539            let result = s.strip_suffix(suffix).unwrap_or(s);
1540            Ok(Value::String(result.to_string()))
1541        },
1542    );
1543
1544    // dir(s="...") — function form of dir filter
1545    tera.register_json_function(
1546        "dir",
1547        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1548            let s = args
1549                .get("s")
1550                .and_then(|v| v.as_str())
1551                .ok_or_else(|| tera::Error::message("dir requires `s` argument"))?;
1552            let p = std::path::Path::new(s);
1553            Ok(Value::String(
1554                p.parent()
1555                    .map(|p| p.to_string_lossy().to_string())
1556                    .unwrap_or_default(),
1557            ))
1558        },
1559    );
1560
1561    // base(s="...") — function form of base filter
1562    tera.register_json_function(
1563        "base",
1564        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1565            let s = args
1566                .get("s")
1567                .and_then(|v| v.as_str())
1568                .ok_or_else(|| tera::Error::message("base requires `s` argument"))?;
1569            let p = std::path::Path::new(s);
1570            Ok(Value::String(
1571                p.file_name()
1572                    .map(|n| n.to_string_lossy().to_string())
1573                    .unwrap_or_default(),
1574            ))
1575        },
1576    );
1577
1578    // abs(s="...") — function form of abs filter
1579    tera.register_json_function(
1580        "abs",
1581        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1582            let s = args
1583                .get("s")
1584                .and_then(|v| v.as_str())
1585                .ok_or_else(|| tera::Error::message("abs requires `s` argument"))?;
1586            let p = std::path::Path::new(s);
1587            if p.is_absolute() {
1588                Ok(Value::String(s.to_string()))
1589            } else {
1590                let abs = std::env::current_dir()
1591                    .map(|cwd| cwd.join(p).to_string_lossy().to_string())
1592                    .unwrap_or_else(|_| s.to_string());
1593                Ok(Value::String(abs))
1594            }
1595        },
1596    );
1597
1598    // urlPathEscape(s="...") — function form of urlPathEscape filter
1599    tera.register_json_function(
1600        "urlPathEscape",
1601        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1602            let s = args
1603                .get("s")
1604                .and_then(|v| v.as_str())
1605                .ok_or_else(|| tera::Error::message("urlPathEscape requires `s` argument"))?;
1606            let encoded: String = s
1607                .bytes()
1608                .map(|b| {
1609                    if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
1610                    {
1611                        (b as char).to_string()
1612                    } else {
1613                        format!("%{:02X}", b)
1614                    }
1615                })
1616                .collect();
1617            Ok(Value::String(encoded))
1618        },
1619    );
1620
1621    // mdv2escape(s="...") — function form of mdv2escape filter
1622    tera.register_json_function(
1623        "mdv2escape",
1624        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1625            let s = args
1626                .get("s")
1627                .and_then(|v| v.as_str())
1628                .ok_or_else(|| tera::Error::message("mdv2escape requires `s` argument"))?;
1629            let escaped = s
1630                .chars()
1631                .map(|c| {
1632                    if "_*[]()~`>#+-=|{}.!".contains(c) {
1633                        format!("\\{}", c)
1634                    } else {
1635                        c.to_string()
1636                    }
1637                })
1638                .collect::<String>();
1639            Ok(Value::String(escaped))
1640        },
1641    );
1642
1643    // --- Dual registration: existing functions also as filters ---
1644
1645    // incpatch — filter form: {{ "1.2.3" | incpatch }}
1646    tera.register_json_filter("incpatch", |value: &Value, _: &HashMap<String, Value>| {
1647        let v = try_get_value!("incpatch", "value", String, value);
1648        Ok(Value::String(increment_version(&v, VersionPart::Patch)?))
1649    });
1650
1651    // incminor — filter form: {{ "1.2.3" | incminor }}
1652    tera.register_json_filter("incminor", |value: &Value, _: &HashMap<String, Value>| {
1653        let v = try_get_value!("incminor", "value", String, value);
1654        Ok(Value::String(increment_version(&v, VersionPart::Minor)?))
1655    });
1656
1657    // incmajor — filter form: {{ "1.2.3" | incmajor }}
1658    tera.register_json_filter("incmajor", |value: &Value, _: &HashMap<String, Value>| {
1659        let v = try_get_value!("incmajor", "value", String, value);
1660        Ok(Value::String(increment_version(&v, VersionPart::Major)?))
1661    });
1662
1663    // now_format — filter form: {{ Now | now_format(format="2006-01-02") }}
1664    // Formats the current UTC time using the given format string.
1665    // Accepts both Go time layout (e.g. "2006-01-02") and chrono strftime
1666    // (e.g. "%Y-%m-%d"). The piped value (Now) is ignored — the filter always
1667    // uses the current UTC time, the `.Now.Format` behavior.
1668    //
1669    // SDE-aware: honors `SOURCE_DATE_EPOCH` so the harness's two from-clean
1670    // rebuilds produce identical output for templates like
1671    // `{{ Now | now_format(format="2006-01-02") }}`.
1672    tera.register_json_filter(
1673        "now_format",
1674        |_value: &Value, args: &HashMap<String, Value>| {
1675            let fmt = args
1676                .get("format")
1677                .and_then(|v| v.as_str())
1678                .ok_or_else(|| tera::Error::message("now_format requires a `format` argument"))?;
1679            let chrono_fmt = translate_go_time_format(fmt);
1680            let now = crate::sde::resolve_now();
1681            Ok(Value::String(now.format(&chrono_fmt).to_string()))
1682        },
1683    );
1684
1685    // index(map={...}, key="k") — access a map by key or array by index.
1686    // Go template: {{ index .Map "key" }} → access map by key.
1687    // Go template: {{ index .Slice 0 }} → access array by index.
1688    // Returns empty string if key/index not found.
1689    tera.register_json_function(
1690        "index",
1691        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1692            let collection = args
1693                .get("collection")
1694                .ok_or_else(|| tera::Error::message("index requires `collection` argument"))?;
1695            let key = args
1696                .get("key")
1697                .ok_or_else(|| tera::Error::message("index requires `key` argument"))?;
1698
1699            match collection {
1700                Value::Object(map) => {
1701                    let key_str = value_to_string(key);
1702                    Ok(map
1703                        .get(key_str.as_ref())
1704                        .cloned()
1705                        .unwrap_or(Value::String(String::new())))
1706                }
1707                Value::Array(arr) => {
1708                    if let Some(idx) = key.as_u64() {
1709                        Ok(arr
1710                            .get(idx as usize)
1711                            .cloned()
1712                            .unwrap_or(Value::String(String::new())))
1713                    } else {
1714                        Err(tera::Error::message("index: array index must be a number"))
1715                    }
1716                }
1717                _ => {
1718                    // For non-collection types, return empty string (graceful)
1719                    Ok(Value::String(String::new()))
1720                }
1721            }
1722        },
1723    );
1724
1725    // in — filter form: {{ myList | in(value="x") }}
1726    // Checks whether the piped array contains the given value (string comparison).
1727    let in_filter = |value: &Value, args: &HashMap<String, Value>| {
1728        let items = value
1729            .as_array()
1730            .ok_or_else(|| tera::Error::message("in filter requires an array as input"))?;
1731        let needle = args
1732            .get("value")
1733            .ok_or_else(|| tera::Error::message("in filter requires `value` argument"))?;
1734        let needle_str = value_to_string(needle);
1735        let found = items.iter().any(|item| value_to_string(item) == needle_str);
1736        Ok(Value::Bool(found))
1737    };
1738    tera.register_json_filter("in", in_filter);
1739    tera.register_json_filter("contains_any", in_filter);
1740
1741    // --- Go `slice` builtin (superset of Tera's native slice) ---
1742    // slice(start=, end=) — substring of a string (char-boundary safe) or
1743    // sub-slice of an array, end-exclusive (`slice(s, 0, 7)` → first 7 chars).
1744    // `start` is OPTIONAL (default 0) and NEGATIVE indices count from the end
1745    // (`start=-2` → last 2), matching Tera's native array slice so user
1746    // templates relying on it keep working. Go's positional `slice X 0 7` only
1747    // ever passes non-negative bounds, so the Go usage is a strict subset.
1748    tera.register_json_filter("slice", |value: &Value, args: &HashMap<String, Value>| {
1749        let start = args.get("start").and_then(|v| v.as_i64()).unwrap_or(0);
1750        let end = args.get("end").and_then(|v| v.as_i64());
1751
1752        // Resolve a possibly-negative index against `len`, clamping into range.
1753        let resolve = |idx: i64, len: i64| -> i64 {
1754            let abs = if idx < 0 { len + idx } else { idx };
1755            abs.clamp(0, len)
1756        };
1757
1758        match value {
1759            Value::String(s) => {
1760                let chars: Vec<char> = s.chars().collect();
1761                let len = chars.len() as i64;
1762                let lo = resolve(start, len);
1763                let hi = resolve(end.unwrap_or(len), len).max(lo) as usize;
1764                Ok(Value::String(chars[lo as usize..hi].iter().collect()))
1765            }
1766            Value::Array(arr) => {
1767                let len = arr.len() as i64;
1768                let lo = resolve(start, len);
1769                let hi = resolve(end.unwrap_or(len), len).max(lo) as usize;
1770                Ok(Value::Array(arr[lo as usize..hi].to_vec()))
1771            }
1772            other => Err(tera::Error::message(format!(
1773                "slice: expected a string or array, got {}",
1774                other
1775            ))),
1776        }
1777    });
1778
1779    // --- Go `printf` builtin ---
1780    // printf(format="%04d", args=[Patch]) — formats args per a bounded Go/C
1781    // verb subset. Unsupported verbs return a clear error (never silent-wrong).
1782    tera.register_json_function(
1783        "printf",
1784        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1785            let format = args
1786                .get("format")
1787                .and_then(|v| v.as_str())
1788                .ok_or_else(|| tera::Error::message("printf requires a `format` argument"))?;
1789            let fmt_args: Vec<Value> = args
1790                .get("args")
1791                .and_then(|v| v.as_array())
1792                .cloned()
1793                .unwrap_or_default();
1794            Ok(Value::String(sprintf(format, &fmt_args)?))
1795        },
1796    );
1797
1798    // --- Go `print` / `println` builtins ---
1799    // print(args=[a, b]) follows Go `Sprint`: a space is added between two
1800    // adjacent operands only when NEITHER is a string (`print 1 2` → "1 2";
1801    // `print "a" "b"` → "ab"; `print "a" 1` → "a1").
1802    // println(args=[a, b]) joins with single spaces and appends a newline.
1803    tera.register_json_function(
1804        "print",
1805        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1806            let fmt_args: Vec<Value> = args
1807                .get("args")
1808                .and_then(|v| v.as_array())
1809                .cloned()
1810                .unwrap_or_default();
1811            let mut out = String::new();
1812            for (i, v) in fmt_args.iter().enumerate() {
1813                if i > 0 {
1814                    let prev_str = fmt_args[i - 1].is_string();
1815                    let cur_str = v.is_string();
1816                    if !prev_str && !cur_str {
1817                        out.push(' ');
1818                    }
1819                }
1820                out.push_str(&printf_default(v));
1821            }
1822            Ok(Value::String(out))
1823        },
1824    );
1825    tera.register_json_function(
1826        "println",
1827        |args: &HashMap<String, Value>| -> TeraResult<Value> {
1828            let fmt_args: Vec<Value> = args
1829                .get("args")
1830                .and_then(|v| v.as_array())
1831                .cloned()
1832                .unwrap_or_default();
1833            let mut joined = fmt_args
1834                .iter()
1835                .map(printf_default)
1836                .collect::<Vec<_>>()
1837                .join(" ");
1838            joined.push('\n');
1839            Ok(Value::String(joined))
1840        },
1841    );
1842
1843    tera
1844});