Skip to main content

anodizer_core/template/
render.rs

1use anyhow::{Context as _, Result};
2use std::collections::HashMap;
3use tera::Value;
4
5use crate::template_preprocess::preprocess;
6
7use super::base_tera::BASE_TERA;
8use super::vars::{ENV_REF_RE, NUMERIC_FIELDS, TemplateVars};
9
10/// Build a `tera::Context` from `TemplateVars`, pre-populating missing env var
11/// keys referenced in the template with empty strings.
12///
13/// GoReleaser returns empty string for `{{ .Env.NONEXISTENT }}` rather than
14/// erroring. Tera's strict mode would error on a missing map key, so we scan
15/// the preprocessed template for `Env.VARNAME` references and ensure every
16/// referenced key exists in the env map (defaulting to "").
17///
18/// Fallback semantics: when an `Env.X` key is not in `TemplateVars::env`,
19/// `std::env::var(X)` is consulted before defaulting to `""`. This is
20/// intentional — GoReleaser-compat semantics let a user reference any
21/// process env var via `{{ Env.X }}`, and the `StageLogger`-level
22/// redaction layer (see `crate::redact`) prevents accidental secret
23/// prints in the rendered output. A `*_TOKEN` / `*_KEY` / `*_PASSWORD`
24/// value flowing through here is redacted before it reaches stderr.
25fn build_tera_context_for_template(vars: &TemplateVars, preprocessed: &str) -> tera::Context {
26    // Discover all Env.VARNAME references in the template.
27    let referenced_env_keys: Vec<String> = ENV_REF_RE
28        .captures_iter(preprocessed)
29        .map(|cap| cap[1].to_string())
30        .collect();
31
32    // Build an env map that includes all referenced keys, defaulting missing ones to "".
33    let mut env_with_defaults = HashMap::new();
34    for key in &referenced_env_keys {
35        if !vars.env.contains_key(key.as_str()) {
36            // Check process env as fallback before defaulting to "".
37            let value = std::env::var(key).unwrap_or_default();
38            env_with_defaults.insert(key.clone(), value);
39        }
40    }
41    // Overlay with the actual env vars from TemplateVars.
42    for (k, v) in &vars.env {
43        env_with_defaults.insert(k.clone(), v.clone());
44    }
45
46    let mut augmented_vars = vars.clone();
47    // Replace the env map with our augmented one.
48    augmented_vars.env = env_with_defaults;
49
50    build_tera_context(&augmented_vars)
51}
52
53/// Build a `tera::Context` from `TemplateVars`.
54/// - Regular vars are inserted at the top level: `ProjectName`, `Version`, etc.
55/// - Env vars are nested under an `Env` key as a HashMap, so `{{ Env.GITHUB_TOKEN }}` works.
56/// - String values of `"true"` / `"false"` are inserted as bools so `{% if Var %}` works.
57/// - Known numeric fields (`Major`, `Minor`, `Patch`, `Timestamp`, `CommitTimestamp`)
58///   are inserted as integers so `{% if Major == 1 %}` works correctly.
59fn build_tera_context(vars: &TemplateVars) -> tera::Context {
60    let mut ctx = tera::Context::new();
61    for (k, v) in &vars.vars {
62        // For known numeric fields, parse as i64 and insert as a number so
63        // Tera comparisons like `{% if Major == 1 %}` work correctly.
64        if NUMERIC_FIELDS.contains(&k.as_str())
65            && let Ok(n) = v.parse::<i64>()
66        {
67            ctx.insert(k.as_str(), &n);
68            continue;
69        }
70        match v.as_str() {
71            "true" => ctx.insert(k.as_str(), &true),
72            "false" => ctx.insert(k.as_str(), &false),
73            _ => ctx.insert(k.as_str(), v),
74        }
75    }
76    ctx.insert("Env", &vars.env);
77
78    // Always insert Var (even when empty) so that referencing the `Var`
79    // namespace does not produce a hard Tera error. Accessing a missing key
80    // within the map still requires `| default(value="")`. This matches
81    // GoReleaser which provides an empty .Var map by default.
82    ctx.insert("Var", &vars.custom_vars);
83
84    // Always insert Outputs (even when empty) so that referencing the
85    // `Outputs` namespace does not produce a hard Tera error. Accessing a
86    // missing key within the map still requires `| default(value="")`.
87    ctx.insert("Outputs", &vars.outputs);
88
89    // Build a nested `Runtime` map for GoReleaser `Runtime.Goos` / `Runtime.Goarch` compat.
90    let mut runtime = HashMap::new();
91    if let Some(goos) = vars.vars.get("RuntimeGoos") {
92        runtime.insert("Goos".to_string(), goos.clone());
93    }
94    if let Some(goarch) = vars.vars.get("RuntimeGoarch") {
95        runtime.insert("Goarch".to_string(), goarch.clone());
96    }
97    if !runtime.is_empty() {
98        ctx.insert("Runtime", &runtime);
99    }
100
101    // Insert structured values (arrays, objects) directly into the context.
102    for (k, v) in &vars.structured {
103        ctx.insert(k.as_str(), v);
104    }
105
106    ctx
107}
108
109/// Render a template string with the given variables.
110///
111/// Supports both Go-style (`{{ .Field }}`) and native Tera-style (`{{ Field }}`).
112/// Go-style references are preprocessed into Tera-style before rendering.
113///
114/// Because this uses Tera under the hood, all Tera features are available:
115/// conditionals (`{% if %}` / `{% else %}` / `{% endif %}`), loops (`{% for %}`),
116/// filters (`| lower`, `| upper`, `| default`, `| trim`, `| title`, `| replace`, etc.).
117///
118/// Custom GoReleaser-compat filters are registered:
119/// - `tolower` / `toupper` — aliases for Tera's built-in `lower` / `upper`
120/// - `trimprefix(prefix="v")` — strip a prefix from a string
121/// - `trimsuffix(suffix=".exe")` — strip a suffix from a string
122pub fn render(template: &str, vars: &TemplateVars) -> Result<String> {
123    let preprocessed = preprocess(template);
124    let ctx = build_tera_context_for_template(vars, &preprocessed);
125
126    // Clone the base instance (cheap — filters carry over, no templates to clone)
127    let mut tera = BASE_TERA.clone();
128
129    // Override envOrDefault and isEnvSet with closures that read from the
130    // template context's Env map. This ensures .env file vars (loaded into
131    // TemplateVars via set_env) are visible, not just process env vars.
132    // Falls back to std::env::var for vars that exist in the process env
133    // but were not explicitly added to the template context.
134    let env_map = std::sync::Arc::new(vars.all_env().clone());
135    let env_map_for_default = env_map.clone();
136    tera.register_function(
137        "envOrDefault",
138        move |args: &HashMap<String, Value>| -> tera::Result<Value> {
139            let name = args
140                .get("name")
141                .and_then(|v| v.as_str())
142                .ok_or_else(|| tera::Error::msg("envOrDefault requires `name` argument"))?;
143            let default = args.get("default").and_then(|v| v.as_str()).unwrap_or("");
144            // Check template context Env map first, then fall back to process env.
145            let value = env_map_for_default
146                .get(name)
147                .cloned()
148                .or_else(|| std::env::var(name).ok())
149                .unwrap_or_else(|| default.to_string());
150            Ok(Value::String(value))
151        },
152    );
153
154    let env_map_for_isset = env_map.clone();
155    tera.register_function(
156        "isEnvSet",
157        move |args: &HashMap<String, Value>| -> tera::Result<Value> {
158            let name = args
159                .get("name")
160                .and_then(|v| v.as_str())
161                .ok_or_else(|| tera::Error::msg("isEnvSet requires `name` argument"))?;
162            // Check template context Env map first, then fall back to process env.
163            let is_set = env_map_for_isset
164                .get(name)
165                .map(|v| !v.is_empty())
166                .unwrap_or_else(|| std::env::var(name).map(|v| !v.is_empty()).unwrap_or(false));
167            Ok(Value::Bool(is_set))
168        },
169    );
170
171    tera.add_raw_template("__inline__", &preprocessed)
172        .with_context(|| format!("failed to parse template: {}", template))?;
173
174    tera.render("__inline__", &ctx)
175        .with_context(|| format!("failed to render template: {}", template))
176}
177
178/// Extract the extension from an artifact filename, including compound
179/// extensions like `.tar.gz`, `.tar.xz`, `.tar.zst`, `.tar.bz2`, `.tar.lz4`,
180/// `.tar.sz`. Returns the extension with a leading dot (e.g. `.tar.gz`, `.exe`,
181/// `.dmg`), or an empty string if there is no extension.
182///
183/// This matches GoReleaser's `.ArtifactExt` behavior.
184pub fn extract_artifact_ext(filename: &str) -> &str {
185    // Check for compound tar extensions first
186    const TAR_COMPOUND_SUFFIXES: &[&str] = &[
187        ".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tar.lz4", ".tar.sz",
188    ];
189    let lower = filename.to_ascii_lowercase();
190    for suffix in TAR_COMPOUND_SUFFIXES {
191        if lower.ends_with(suffix) {
192            // Return the slice from the original filename (preserving case)
193            return &filename[filename.len() - suffix.len()..];
194        }
195    }
196    // Fall back to the last dot-extension
197    match filename.rfind('.') {
198        Some(pos) if pos > 0 => &filename[pos..],
199        _ => "",
200    }
201}