Skip to main content

anodizer_core/template/
render.rs

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