Skip to main content

anodizer_core/template/
render.rs

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