Skip to main content

anodizer_core/template/
base_tera.rs

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