Skip to main content

anodizer_core/template/base_tera/
text.rs

1//! String builtins: case conversion, prefix/suffix trimming, substring search,
2//! literal and regex replacement, splitting, title-casing, and the Ruby and
3//! Telegram MarkdownV2 literal escapers.
4
5use regex::Regex;
6use serde_json::Value;
7use std::collections::HashMap;
8use tera::TeraResult;
9
10use crate::template::engine_adapter::{JsonRegisterExt, try_get_value};
11
12/// Escape a string for safe inclusion inside a **double-quoted Ruby string
13/// literal**: replace `\` with `\\` first, then `"` with `\"`.
14///
15/// Backslash must be escaped before the quote so the quote's inserted escape
16/// backslash is not itself doubled. Use this anywhere a user-supplied value is
17/// spliced into a `"…"` Ruby literal — both the Tera `ruby_escape` filter
18/// (registered by `register_ruby_escape`) and the `format!`/`push_str` sites in the Homebrew
19/// formula/cask generators route through it so there is a single escape
20/// implementation.
21pub fn ruby_escape_str(s: &str) -> String {
22    s.replace('\\', "\\\\").replace('"', "\\\"")
23}
24
25/// Register the `ruby_escape` filter on a Tera instance.
26///
27/// The filter delegates to [`ruby_escape_str`], making user-supplied values
28/// (descriptions, homepages, display names, URLs, …) safe to interpolate into
29/// `desc "…"`, `homepage "…"`, `url "…"`, and similar Homebrew formula/cask
30/// stanzas without producing invalid Ruby.
31///
32/// Shared by both `BASE_TERA` and `parse_static` so that the trusted
33/// formula/cask templates have the filter available even though they build a
34/// fresh `tera::Tera` rather than cloning `BASE_TERA`.
35pub(crate) fn register_ruby_escape(tera: &mut tera::Tera) {
36    tera.register_json_filter(
37        "ruby_escape",
38        |value: &Value, _: &HashMap<String, Value>| {
39            let s = try_get_value!("ruby_escape", "value", String, value);
40            Ok(Value::String(ruby_escape_str(&s)))
41        },
42    );
43}
44
45pub(super) fn register(tera: &mut tera::Tera) {
46    // Compatibility aliases
47    tera.register_json_filter("tolower", |value: &Value, _: &HashMap<String, Value>| {
48        let s = try_get_value!("tolower", "value", String, value);
49        Ok(Value::String(s.to_lowercase()))
50    });
51    tera.register_json_filter("toupper", |value: &Value, _: &HashMap<String, Value>| {
52        let s = try_get_value!("toupper", "value", String, value);
53        Ok(Value::String(s.to_uppercase()))
54    });
55
56    // trimprefix(prefix="...") — strip prefix from a string
57    tera.register_json_filter(
58        "trimprefix",
59        |value: &Value, args: &HashMap<String, Value>| {
60            let s = try_get_value!("trimprefix", "value", String, value);
61            let prefix = args
62                .get("prefix")
63                .and_then(|v| v.as_str())
64                .ok_or_else(|| tera::Error::message("trimprefix requires a `prefix` argument"))?;
65            let result = s.strip_prefix(prefix).unwrap_or(&s);
66            Ok(Value::String(result.to_string()))
67        },
68    );
69
70    // trimsuffix(suffix="...") — strip suffix from a string
71    tera.register_json_filter(
72        "trimsuffix",
73        |value: &Value, args: &HashMap<String, Value>| {
74            let s = try_get_value!("trimsuffix", "value", String, value);
75            let suffix = args
76                .get("suffix")
77                .and_then(|v| v.as_str())
78                .ok_or_else(|| tera::Error::message("trimsuffix requires a `suffix` argument"))?;
79            let result = s.strip_suffix(suffix).unwrap_or(&s);
80            Ok(Value::String(result.to_string()))
81        },
82    );
83
84    // mdv2escape — escape Telegram MarkdownV2 special characters
85    tera.register_json_filter("mdv2escape", |value: &Value, _: &HashMap<String, Value>| {
86        let s = try_get_value!("mdv2escape", "value", String, value);
87        let escaped = s
88            .chars()
89            .map(|c| {
90                if "_*[]()~`>#+-=|{}.!".contains(c) {
91                    format!("\\{}", c)
92                } else {
93                    c.to_string()
94                }
95            })
96            .collect::<String>();
97        Ok(Value::String(escaped))
98    });
99
100    // --- Go-style compatibility functions ---
101
102    // contains(s="haystack", substr="needle") — check string containment
103    tera.register_json_function(
104        "contains",
105        |args: &HashMap<String, Value>| -> TeraResult<Value> {
106            let s = args
107                .get("s")
108                .and_then(|v| v.as_str())
109                .ok_or_else(|| tera::Error::message("contains requires `s` argument"))?;
110            let substr = args
111                .get("substr")
112                .and_then(|v| v.as_str())
113                .ok_or_else(|| tera::Error::message("contains requires `substr` argument"))?;
114            Ok(Value::Bool(s.contains(substr)))
115        },
116    );
117
118    // reReplaceAll(pattern="...", input="...", replacement="...") — regex replace
119    // Go-style: {{ reReplaceAll "(.*)" .Message "$1" }}
120    // Named:    {{ reReplaceAll(pattern="(.*)", input="hello", replacement="$1") }}
121    // Supports capture group references ($1, $2, etc.).
122    // Returns a Tera error on invalid regex (no panic).
123    // Note: regex is compiled per call. This is acceptable for template rendering
124    // where each pattern is typically used once per render pass.
125    tera.register_json_function(
126        "reReplaceAll",
127        |args: &HashMap<String, Value>| -> TeraResult<Value> {
128            let pattern = args
129                .get("pattern")
130                .and_then(|v| v.as_str())
131                .ok_or_else(|| tera::Error::message("reReplaceAll requires `pattern` argument"))?;
132            let input = args
133                .get("input")
134                .and_then(|v| v.as_str())
135                .ok_or_else(|| tera::Error::message("reReplaceAll requires `input` argument"))?;
136            let replacement = args
137                .get("replacement")
138                .and_then(|v| v.as_str())
139                .ok_or_else(|| {
140                    tera::Error::message("reReplaceAll requires `replacement` argument")
141                })?;
142            let re = Regex::new(pattern).map_err(|e| {
143                tera::Error::message(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
144            })?;
145            Ok(Value::String(
146                re.replace_all(input, replacement).to_string(),
147            ))
148        },
149    );
150
151    // reReplaceAll filter form: {{ Field | reReplaceAll(pattern="...", replacement="...") }}
152    // Note: regex is compiled per call. This is acceptable for template rendering
153    // where each pattern is typically used once per render pass.
154    tera.register_json_filter(
155        "reReplaceAll",
156        |value: &Value, args: &HashMap<String, Value>| {
157            let input = try_get_value!("reReplaceAll", "value", String, value);
158            let pattern = args
159                .get("pattern")
160                .and_then(|v| v.as_str())
161                .ok_or_else(|| {
162                    tera::Error::message("reReplaceAll filter requires `pattern` argument")
163                })?;
164            let replacement = args
165                .get("replacement")
166                .and_then(|v| v.as_str())
167                .ok_or_else(|| {
168                    tera::Error::message("reReplaceAll filter requires `replacement` argument")
169                })?;
170            let re = Regex::new(pattern).map_err(|e| {
171                tera::Error::message(format!("reReplaceAll: invalid regex '{}': {}", pattern, e))
172            })?;
173            Ok(Value::String(
174                re.replace_all(&input, replacement).to_string(),
175            ))
176        },
177    );
178
179    // --- replace function ---
180    // Function form: replace(s="input", old="x", new="y")
181    tera.register_json_function(
182        "replace",
183        |args: &HashMap<String, Value>| -> TeraResult<Value> {
184            let s = args
185                .get("s")
186                .and_then(|v| v.as_str())
187                .ok_or_else(|| tera::Error::message("replace requires `s` argument"))?;
188            let old = args
189                .get("old")
190                .and_then(|v| v.as_str())
191                .ok_or_else(|| tera::Error::message("replace requires `old` argument"))?;
192            let new = args
193                .get("new")
194                .and_then(|v| v.as_str())
195                .ok_or_else(|| tera::Error::message("replace requires `new` argument"))?;
196            Ok(Value::String(s.replace(old, new)))
197        },
198    );
199    // Filter form: {{ Field | replace(from="old", to="new") }}
200    // Overrides Tera's built-in replace filter. Uses `from`/`to` arg names
201    // (same as the built-in) so existing Tera templates continue to work.
202    tera.register_json_filter("replace", |value: &Value, args: &HashMap<String, Value>| {
203        let s = try_get_value!("replace", "value", String, value);
204        let from = args
205            .get("from")
206            .and_then(|v| v.as_str())
207            .ok_or_else(|| tera::Error::message("replace filter requires `from` argument"))?;
208        let to = args
209            .get("to")
210            .and_then(|v| v.as_str())
211            .ok_or_else(|| tera::Error::message("replace filter requires `to` argument"))?;
212        Ok(Value::String(s.replace(from, to)))
213    });
214
215    // --- split function ---
216    // split(s="a,b,c", sep=",") → ["a", "b", "c"]
217    tera.register_json_function(
218        "split",
219        |args: &HashMap<String, Value>| -> TeraResult<Value> {
220            let s = args
221                .get("s")
222                .and_then(|v| v.as_str())
223                .ok_or_else(|| tera::Error::message("split requires `s` argument"))?;
224            let sep = args
225                .get("sep")
226                .and_then(|v| v.as_str())
227                .ok_or_else(|| tera::Error::message("split requires `sep` argument"))?;
228            let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
229            Ok(Value::Array(parts))
230        },
231    );
232    // Filter form: {{ Field | split(sep=".") }}
233    tera.register_json_filter("split", |value: &Value, args: &HashMap<String, Value>| {
234        let s = try_get_value!("split", "value", String, value);
235        let sep = args
236            .get("sep")
237            .and_then(|v| v.as_str())
238            .ok_or_else(|| tera::Error::message("split filter requires `sep` argument"))?;
239        let parts: Vec<Value> = s.split(sep).map(|p| Value::String(p.to_string())).collect();
240        Ok(Value::Array(parts))
241    });
242
243    // Filter form: {{ Field | contains(substr="needle") }}
244    tera.register_json_filter(
245        "contains",
246        |value: &Value, args: &HashMap<String, Value>| {
247            let s = try_get_value!("contains", "value", String, value);
248            let substr = args.get("substr").and_then(|v| v.as_str()).ok_or_else(|| {
249                tera::Error::message("contains filter requires `substr` argument")
250            })?;
251            Ok(Value::Bool(s.contains(substr)))
252        },
253    );
254
255    // --- trim function ---
256    // Function form: trim(s="  hello  ") → "hello"
257    // Tera already has a built-in `trim` filter, so we only add the function form.
258    tera.register_json_function(
259        "trim",
260        |args: &HashMap<String, Value>| -> TeraResult<Value> {
261            let s = args
262                .get("s")
263                .and_then(|v| v.as_str())
264                .ok_or_else(|| tera::Error::message("trim requires `s` argument"))?;
265            Ok(Value::String(s.trim().to_string()))
266        },
267    );
268
269    // --- title function ---
270    // Function form: title(s="hello world") → "Hello World"
271    // Tera already has a built-in `title` filter, so we only add the function form.
272    tera.register_json_function(
273        "title",
274        |args: &HashMap<String, Value>| -> TeraResult<Value> {
275            let s = args
276                .get("s")
277                .and_then(|v| v.as_str())
278                .ok_or_else(|| tera::Error::message("title requires `s` argument"))?;
279            // Title-case: capitalize the first letter of each word.
280            let titled = s
281                .split_whitespace()
282                .map(|word| {
283                    let mut chars = word.chars();
284                    match chars.next() {
285                        Some(c) => {
286                            let upper: String = c.to_uppercase().collect();
287                            format!("{}{}", upper, chars.as_str())
288                        }
289                        None => String::new(),
290                    }
291                })
292                .collect::<Vec<_>>()
293                .join(" ");
294            Ok(Value::String(titled))
295        },
296    );
297
298    // --- Dual registration: existing filters also as functions ---
299
300    // tolower(s="...") — function form of tolower filter
301    tera.register_json_function(
302        "tolower",
303        |args: &HashMap<String, Value>| -> TeraResult<Value> {
304            let s = args
305                .get("s")
306                .and_then(|v| v.as_str())
307                .ok_or_else(|| tera::Error::message("tolower requires `s` argument"))?;
308            Ok(Value::String(s.to_lowercase()))
309        },
310    );
311
312    // toupper(s="...") — function form of toupper filter
313    tera.register_json_function(
314        "toupper",
315        |args: &HashMap<String, Value>| -> TeraResult<Value> {
316            let s = args
317                .get("s")
318                .and_then(|v| v.as_str())
319                .ok_or_else(|| tera::Error::message("toupper requires `s` argument"))?;
320            Ok(Value::String(s.to_uppercase()))
321        },
322    );
323
324    // trimprefix(s="...", prefix="...") — function form of trimprefix filter
325    tera.register_json_function(
326        "trimprefix",
327        |args: &HashMap<String, Value>| -> TeraResult<Value> {
328            let s = args
329                .get("s")
330                .and_then(|v| v.as_str())
331                .ok_or_else(|| tera::Error::message("trimprefix requires `s` argument"))?;
332            let prefix = args
333                .get("prefix")
334                .and_then(|v| v.as_str())
335                .ok_or_else(|| tera::Error::message("trimprefix requires `prefix` argument"))?;
336            let result = s.strip_prefix(prefix).unwrap_or(s);
337            Ok(Value::String(result.to_string()))
338        },
339    );
340
341    // trimsuffix(s="...", suffix="...") — function form of trimsuffix filter
342    tera.register_json_function(
343        "trimsuffix",
344        |args: &HashMap<String, Value>| -> TeraResult<Value> {
345            let s = args
346                .get("s")
347                .and_then(|v| v.as_str())
348                .ok_or_else(|| tera::Error::message("trimsuffix requires `s` argument"))?;
349            let suffix = args
350                .get("suffix")
351                .and_then(|v| v.as_str())
352                .ok_or_else(|| tera::Error::message("trimsuffix requires `suffix` argument"))?;
353            let result = s.strip_suffix(suffix).unwrap_or(s);
354            Ok(Value::String(result.to_string()))
355        },
356    );
357
358    // mdv2escape(s="...") — function form of mdv2escape filter
359    tera.register_json_function(
360        "mdv2escape",
361        |args: &HashMap<String, Value>| -> TeraResult<Value> {
362            let s = args
363                .get("s")
364                .and_then(|v| v.as_str())
365                .ok_or_else(|| tera::Error::message("mdv2escape requires `s` argument"))?;
366            let escaped = s
367                .chars()
368                .map(|c| {
369                    if "_*[]()~`>#+-=|{}.!".contains(c) {
370                        format!("\\{}", c)
371                    } else {
372                        c.to_string()
373                    }
374                })
375                .collect::<String>();
376            Ok(Value::String(escaped))
377        },
378    );
379}