Skip to main content

anodizer_core/template/
render.rs

1use anyhow::{Context as _, Result};
2use serde_json::Value;
3use std::collections::{BTreeMap, HashMap};
4use tera::TeraResult;
5
6use crate::env_source::{EnvSource, ProcessEnvSource};
7use crate::template_preprocess::{
8    check_block_expressions, preprocess, protect_shell_param_length, restore_shell_param_length,
9};
10
11use super::base_tera::{BASE_TERA, translate_go_time_format};
12use super::engine_adapter::{JsonRegisterExt, double_string_literal_backslashes};
13use super::vars::{ENV_REF_RE, NUMERIC_FIELDS, TemplateVars};
14
15/// Insert a string map into the context in sorted key order.
16///
17/// tera 2.0 preserves insertion order (`preserve_order` feature), which for a
18/// `HashMap` would be a random per-process order. Sorting into a `BTreeMap`
19/// before insert guarantees `{% for k, v in Env %}` iterates keys sorted,
20/// deterministically, regardless of how `serde_json`'s own map type is
21/// configured elsewhere in the workspace.
22fn insert_sorted(ctx: &mut tera::Context, key: &'static str, map: &HashMap<String, String>) {
23    let sorted: BTreeMap<&str, &str> = map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
24    ctx.insert(key, &sorted);
25}
26
27/// Build a `tera::Context` from `TemplateVars`, pre-populating missing env var
28/// keys referenced in the template with empty strings.
29///
30/// An empty string is returned for `{{ .Env.NONEXISTENT }}` rather than
31/// erroring. Tera's strict mode would error on a missing map key, so the
32/// preprocessed template is scanned for `Env.VARNAME` references and every
33/// referenced key is ensured to exist in the env map (defaulting to "").
34///
35/// Fallback semantics: when an `Env.X` key is not in `TemplateVars::env`,
36/// `std::env::var(X)` is consulted before defaulting to `""`. This is
37/// intentional — these semantics let a user reference any
38/// process env var via `{{ Env.X }}`, and the `StageLogger`-level
39/// redaction layer (see `crate::redact`) prevents accidental secret
40/// prints in the rendered output. A `*_TOKEN` / `*_KEY` / `*_PASSWORD`
41/// value flowing through here is redacted before it reaches stderr.
42fn build_tera_context_for_template(
43    vars: &TemplateVars,
44    preprocessed: &str,
45    host_env: &dyn EnvSource,
46) -> tera::Context {
47    // Discover all Env.VARNAME references in the template.
48    let referenced_env_keys: Vec<String> = ENV_REF_RE
49        .captures_iter(preprocessed)
50        .map(|cap| cap[1].to_string())
51        .collect();
52
53    // Build an env map that includes all referenced keys, defaulting missing ones to "".
54    let mut env_with_defaults = HashMap::new();
55    for key in &referenced_env_keys {
56        if !vars.env.contains_key(key.as_str()) {
57            // Check the injected host env as fallback before defaulting to "".
58            let value = host_env.var(key).unwrap_or_default();
59            env_with_defaults.insert(key.clone(), value);
60        }
61    }
62    // Overlay with the actual env vars from TemplateVars.
63    for (k, v) in &vars.env {
64        env_with_defaults.insert(k.clone(), v.clone());
65    }
66
67    let mut augmented_vars = vars.clone();
68    augmented_vars.env = env_with_defaults;
69
70    build_tera_context(&augmented_vars)
71}
72
73/// Build a `tera::Context` from `TemplateVars`.
74/// - Regular vars are inserted at the top level: `ProjectName`, `Version`, etc.
75/// - Env vars are nested under an `Env` key as a HashMap, so `{{ Env.GITHUB_TOKEN }}` works.
76/// - String values of `"true"` / `"false"` are inserted as bools so `{% if Var %}` works.
77/// - Known numeric fields (`Major`, `Minor`, `Patch`, `Timestamp`, `CommitTimestamp`)
78///   are inserted as integers so `{% if Major == 1 %}` works correctly.
79fn build_tera_context(vars: &TemplateVars) -> tera::Context {
80    let mut ctx = tera::Context::new();
81    for (k, v) in &vars.vars {
82        // For known numeric fields, parse as i64 and insert as a number so
83        // Tera comparisons like `{% if Major == 1 %}` work correctly.
84        if NUMERIC_FIELDS.contains(&k.as_str())
85            && let Ok(n) = v.parse::<i64>()
86        {
87            ctx.insert(k.clone(), &n);
88            continue;
89        }
90        match v.as_str() {
91            "true" => ctx.insert(k.clone(), &true),
92            "false" => ctx.insert(k.clone(), &false),
93            _ => ctx.insert(k.clone(), v),
94        }
95    }
96    insert_sorted(&mut ctx, "Env", &vars.env);
97
98    // Always insert Var (even when empty) so that referencing the `Var`
99    // namespace does not produce a hard Tera error. Accessing a missing key
100    // within the map still requires `| default(value="")`. This matches
101    // an empty .Var map is provided by default.
102    insert_sorted(&mut ctx, "Var", &vars.custom_vars);
103
104    // Always insert Outputs (even when empty) so that referencing the
105    // `Outputs` namespace does not produce a hard Tera error. Accessing a
106    // missing key within the map still requires `| default(value="")`.
107    insert_sorted(&mut ctx, "Outputs", &vars.outputs);
108
109    // Build a nested `Runtime` map for `Runtime.Goos` / `Runtime.Goarch`.
110    let mut runtime = HashMap::new();
111    if let Some(goos) = vars.vars.get("RuntimeGoos") {
112        runtime.insert("Goos".to_string(), goos.clone());
113    }
114    if let Some(goarch) = vars.vars.get("RuntimeGoarch") {
115        runtime.insert("Goarch".to_string(), goarch.clone());
116    }
117    if !runtime.is_empty() {
118        insert_sorted(&mut ctx, "Runtime", &runtime);
119    }
120
121    // Insert structured values (arrays, objects) directly into the context.
122    for (k, v) in &vars.structured {
123        ctx.insert(k.clone(), v);
124    }
125
126    ctx
127}
128
129/// Render a template string with the given variables.
130///
131/// Supports both Go-style (`{{ .Field }}`) and native Tera-style (`{{ Field }}`).
132/// Go-style references are preprocessed into Tera-style before rendering.
133///
134/// Because this uses Tera under the hood, all Tera features are available:
135/// conditionals (`{% if %}` / `{% else %}` / `{% endif %}`), loops (`{% for %}`),
136/// filters (`| lower`, `| upper`, `| default`, `| trim`, `| title`, `| replace`, etc.).
137///
138/// Custom filters are registered:
139/// - `tolower` / `toupper` — aliases for Tera's built-in `lower` / `upper`
140/// - `trimprefix(prefix="v")` — strip a prefix from a string
141/// - `trimsuffix(suffix=".exe")` — strip a suffix from a string
142pub fn render(template: &str, vars: &TemplateVars) -> Result<String> {
143    render_with_env(template, vars, &ProcessEnvSource)
144}
145
146/// Render `template` against `vars`, routing all env reads (Env.X fallback,
147/// `envOrDefault` / `isEnvSet` fallback, SDE-aware `time(...)` /
148/// `now_format` filters) through the injected `host_env`.
149///
150/// Production callers use [`render`] (which wires up [`ProcessEnvSource`]);
151/// tests inject a closed `MapEnvSource` so deterministic branches can be
152/// exercised without mutating the process env.
153pub fn render_with_env(
154    template: &str,
155    vars: &TemplateVars,
156    host_env: &dyn EnvSource,
157) -> Result<String> {
158    // Runs against the source text so the diagnostic quotes what the author
159    // wrote, not a half-rewritten intermediate.
160    check_block_expressions(template)?;
161    // Shield bash `${#…}` from Tera's `{#` comment-open before parsing, so it
162    // reaches the rendered output literally (GoReleaser's Go templates have no
163    // such collision); the inverse restore runs on the rendered string below.
164    let preprocessed_raw = preprocess(template);
165    let preprocessed = protect_shell_param_length(&preprocessed_raw)?;
166    // Restore 1.x raw string-literal semantics (`pattern="(\w+)"`) before
167    // tera 2.0's escape-processing lexer sees the template. Runs after the
168    // `${#…}` shield so comment-open collisions are already out of the text.
169    let preprocessed = double_string_literal_backslashes(preprocessed.as_ref());
170    let ctx = build_tera_context_for_template(vars, preprocessed.as_ref(), host_env);
171
172    // Clone the base instance (cheap — filters carry over, no templates to clone)
173    let mut tera = BASE_TERA.clone();
174
175    // Snapshot host-env entries the closures consult into an Arc<HashMap>
176    // so tera's `'static` closure requirement is met without leaking a
177    // borrow of `host_env` beyond this stack frame. The snapshot is a one-
178    // shot pull from the injected source: `MapEnvSource::vars()` returns
179    // the fixture map; `ProcessEnvSource::vars()` returns `std::env::vars()`
180    // at call time. Tera renderers run synchronously on the calling thread,
181    // so the snapshot is consistent for the lifetime of the call even if
182    // a sibling thread mutates process env between renders.
183    let host_snapshot: HashMap<String, String> = host_env.vars().into_iter().collect();
184    let host_snapshot = std::sync::Arc::new(host_snapshot);
185
186    // Override envOrDefault and isEnvSet with closures that read from the
187    // template context's Env map. This ensures .env file vars (loaded into
188    // TemplateVars via set_env) are visible, not just process env vars.
189    // Falls back to the injected `host_env` for vars that exist there but
190    // were not explicitly added to the template context.
191    let env_map = std::sync::Arc::new(vars.all_env().clone());
192    let env_map_for_default = env_map.clone();
193    let host_for_default = host_snapshot.clone();
194    tera.register_json_function(
195        "envOrDefault",
196        move |args: &HashMap<String, Value>| -> TeraResult<Value> {
197            let name = args
198                .get("name")
199                .and_then(|v| v.as_str())
200                .ok_or_else(|| tera::Error::message("envOrDefault requires `name` argument"))?;
201            let default = args.get("default").and_then(|v| v.as_str()).unwrap_or("");
202            // Check template context Env map first, then fall back to the
203            // injected host env.
204            let value = env_map_for_default
205                .get(name)
206                .cloned()
207                .or_else(|| host_for_default.get(name).cloned())
208                .unwrap_or_else(|| default.to_string());
209            Ok(Value::String(value))
210        },
211    );
212
213    let env_map_for_isset = env_map.clone();
214    let host_for_isset = host_snapshot.clone();
215    tera.register_json_function(
216        "isEnvSet",
217        move |args: &HashMap<String, Value>| -> TeraResult<Value> {
218            let name = args
219                .get("name")
220                .and_then(|v| v.as_str())
221                .ok_or_else(|| tera::Error::message("isEnvSet requires `name` argument"))?;
222            // Check template context Env map first, then fall back to the
223            // injected host env.
224            let is_set = env_map_for_isset
225                .get(name)
226                .map(|v| !v.is_empty())
227                .unwrap_or_else(|| {
228                    host_for_isset
229                        .get(name)
230                        .map(|v| !v.is_empty())
231                        .unwrap_or(false)
232                });
233            Ok(Value::Bool(is_set))
234        },
235    );
236
237    // Re-register the SDE-aware `time` / `now_format` helpers so they
238    // resolve `SOURCE_DATE_EPOCH` against the injected host env, not
239    // `std::env::var`. BASE_TERA registers placeholder shapes that read
240    // through `ProcessEnvSource` — fine for production, but tests injecting
241    // a `MapEnvSource` need the overrides to see their fixture value.
242    let host_for_time = host_snapshot.clone();
243    tera.register_json_function(
244        "time",
245        move |args: &HashMap<String, Value>| -> TeraResult<Value> {
246            let fmt = args
247                .get("format")
248                .and_then(|v| v.as_str())
249                .unwrap_or("%Y-%m-%dT%H:%M:%SZ");
250            let chrono_fmt = translate_go_time_format(fmt);
251            let sde = host_for_time.get("SOURCE_DATE_EPOCH").cloned();
252            let now = sde
253                .and_then(|s| s.parse::<i64>().ok())
254                .and_then(|secs| chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0))
255                .unwrap_or_else(chrono::Utc::now);
256            Ok(Value::String(now.format(&chrono_fmt).to_string()))
257        },
258    );
259
260    let host_for_now_format = host_snapshot.clone();
261    tera.register_json_filter(
262        "now_format",
263        move |_value: &Value, args: &HashMap<String, Value>| {
264            let fmt = args
265                .get("format")
266                .and_then(|v| v.as_str())
267                .ok_or_else(|| tera::Error::message("now_format requires a `format` argument"))?;
268            let chrono_fmt = translate_go_time_format(fmt);
269            let sde = host_for_now_format.get("SOURCE_DATE_EPOCH").cloned();
270            let now = sde
271                .and_then(|s| s.parse::<i64>().ok())
272                .and_then(|secs| chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0))
273                .unwrap_or_else(chrono::Utc::now);
274            Ok(Value::String(now.format(&chrono_fmt).to_string()))
275        },
276    );
277
278    tera.add_raw_template("__inline__", preprocessed.as_ref())
279        .with_context(|| format!("failed to parse template: {}", template))?;
280
281    let rendered = tera
282        .render("__inline__", &ctx)
283        .with_context(|| format!("failed to render template: {}", template))?;
284    Ok(restore_shell_param_length(&rendered).into_owned())
285}
286
287/// Extract the extension from an artifact filename, including compound
288/// extensions like `.tar.gz`, `.tar.xz`, `.tar.zst`, `.tar.bz2`, `.tar.lz4`,
289/// `.tar.sz`. Returns the extension with a leading dot (e.g. `.tar.gz`, `.exe`,
290/// `.dmg`), or an empty string if there is no extension.
291///
292/// This is the `.ArtifactExt` behavior.
293pub fn extract_artifact_ext(filename: &str) -> &str {
294    // Check for compound tar extensions first
295    const TAR_COMPOUND_SUFFIXES: &[&str] = &[
296        ".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tar.lz4", ".tar.sz",
297    ];
298    let lower = filename.to_ascii_lowercase();
299    for suffix in TAR_COMPOUND_SUFFIXES {
300        if lower.ends_with(suffix) {
301            // Return the slice from the original filename (preserving case)
302            return &filename[filename.len() - suffix.len()..];
303        }
304    }
305    // Fall back to the last dot-extension
306    match filename.rfind('.') {
307        Some(pos) if pos > 0 => &filename[pos..],
308        _ => "",
309    }
310}