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