Skip to main content

anodizer_core/
template_file_render.rs

1//! Shared per-entry rendering for the [`crate::config::TemplateFileConfig`]
2//! shape — the field type behind both top-level `template_files:` and
3//! archive-scoped `archives[].templated_files:`.
4//!
5//! Centralizes the skip → render-src → read-bytes → from_utf8 →
6//! render-contents → render-dst → empty-skip → traversal-reject pipeline
7//! so the top-level stage in `crates/stage-templatefiles/src/lib.rs` and
8//! the archive-scoped renderer in `crates/stage-archive/src/run.rs` share
9//! one implementation. Each caller appends its own write/registration
10//! logic on top of the returned struct.
11
12use std::path::Path;
13
14use anyhow::{Context as _, Result, bail};
15
16use crate::config::TemplateFileConfig;
17use crate::context::Context;
18
19/// Outcome of [`render_templated_file_entry`].
20///
21/// `rendered_dst` is the relative path the caller should resolve against
22/// its output directory (the top-level stage uses `dist/`; the archive
23/// stage uses a per-`(archive_id, target, format)` staging dir).
24///
25/// `rendered_contents` is the UTF-8 body to write at `rendered_dst`.
26#[derive(Debug)]
27pub struct TemplatedFileRender {
28    pub rendered_dst: String,
29    pub rendered_contents: String,
30}
31
32/// Render a single [`TemplateFileConfig`] entry.
33///
34/// Returns `Ok(None)` when the entry should be silently skipped:
35/// either `entry.skip` evaluates truthy, or the rendered `dst` is the
36/// empty string (the "ignored if empty" contract).
37///
38/// `label_prefix` is the error-context prefix the caller wants on every
39/// rendered error (e.g. `"templatefiles: id 'greeting'"` or
40/// `"archives[default].templated_files[default]"`). It is woven into the
41/// `with_context` chain so users see which entry blew up.
42pub fn render_templated_file_entry(
43    ctx: &Context,
44    entry: &TemplateFileConfig,
45    label_prefix: &str,
46) -> Result<Option<TemplatedFileRender>> {
47    if let Some(ref skip) = entry.skip {
48        let off = skip
49            .try_evaluates_to_true(|tmpl| ctx.render_template(tmpl))
50            .with_context(|| format!("{label_prefix}: render skip template"))?;
51        if off {
52            return Ok(None);
53        }
54    }
55
56    let rendered_src = ctx
57        .render_template(&entry.src)
58        .with_context(|| format!("{label_prefix}: render src path"))?;
59    let src_path = Path::new(&rendered_src).to_path_buf();
60    let src_bytes = std::fs::read(&src_path).with_context(|| {
61        format!(
62            "{label_prefix}: source file '{}' not found",
63            src_path.display()
64        )
65    })?;
66    let src_contents = std::str::from_utf8(&src_bytes).map_err(|_| {
67        anyhow::anyhow!(
68            "{label_prefix}: source file '{}' is not valid UTF-8 — \
69             templated files must be text. Use a `files:` entry for binary contents.",
70            src_path.display()
71        )
72    })?;
73    let rendered_contents = ctx.render_template(src_contents).with_context(|| {
74        format!(
75            "{label_prefix}: render contents of '{}'",
76            src_path.display()
77        )
78    })?;
79
80    let rendered_dst = ctx
81        .render_template(&entry.dst)
82        .with_context(|| format!("{label_prefix}: render dst path"))?;
83    if rendered_dst.is_empty() {
84        return Ok(None);
85    }
86    if rendered_dst.contains("..") || Path::new(&rendered_dst).is_absolute() {
87        bail!(
88            "{label_prefix}: dst '{}' must be a relative path with no '..' segments",
89            rendered_dst
90        );
91    }
92
93    Ok(Some(TemplatedFileRender {
94        rendered_dst,
95        rendered_contents,
96    }))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::test_helpers::TestContextBuilder;
103    use tempfile::TempDir;
104
105    fn build_ctx() -> Context {
106        TestContextBuilder::new()
107            .project_name("myapp")
108            .tag("v1.0.0")
109            .build()
110    }
111
112    #[test]
113    fn renders_contents_and_dst() {
114        let tmp = TempDir::new().unwrap();
115        let src = tmp.path().join("input.tpl");
116        std::fs::write(&src, "name={{ .ProjectName }} version={{ .Version }}").unwrap();
117
118        let ctx = build_ctx();
119        let entry = TemplateFileConfig {
120            id: Some("e1".to_string()),
121            src: src.to_string_lossy().to_string(),
122            dst: "{{ .ProjectName }}.txt".to_string(),
123            mode: None,
124            skip: None,
125        };
126
127        let out = render_templated_file_entry(&ctx, &entry, "test")
128            .unwrap()
129            .expect("entry should render");
130        assert_eq!(out.rendered_dst, "myapp.txt");
131        assert_eq!(out.rendered_contents, "name=myapp version=1.0.0");
132    }
133
134    #[test]
135    fn skip_eval_true_returns_none() {
136        let tmp = TempDir::new().unwrap();
137        let src = tmp.path().join("input.tpl");
138        std::fs::write(&src, "hi").unwrap();
139
140        let ctx = build_ctx();
141        let entry = TemplateFileConfig {
142            id: None,
143            src: src.to_string_lossy().to_string(),
144            dst: "out.txt".to_string(),
145            mode: None,
146            skip: Some(crate::config::StringOrBool::Bool(true)),
147        };
148
149        let out = render_templated_file_entry(&ctx, &entry, "test").unwrap();
150        assert!(out.is_none(), "skip=true must short-circuit to None");
151    }
152
153    #[test]
154    fn empty_dst_returns_none() {
155        let tmp = TempDir::new().unwrap();
156        let src = tmp.path().join("input.tpl");
157        std::fs::write(&src, "hi").unwrap();
158
159        let ctx = build_ctx();
160        let entry = TemplateFileConfig {
161            id: None,
162            src: src.to_string_lossy().to_string(),
163            // dst renders to empty (literal empty string) — must short-circuit.
164            dst: String::new(),
165            mode: None,
166            skip: None,
167        };
168
169        let out = render_templated_file_entry(&ctx, &entry, "test").unwrap();
170        assert!(out.is_none(), "empty rendered dst must short-circuit");
171    }
172
173    #[test]
174    fn path_traversal_rejected() {
175        let tmp = TempDir::new().unwrap();
176        let src = tmp.path().join("input.tpl");
177        std::fs::write(&src, "hi").unwrap();
178
179        let ctx = build_ctx();
180        let entry = TemplateFileConfig {
181            id: None,
182            src: src.to_string_lossy().to_string(),
183            dst: "../escape.txt".to_string(),
184            mode: None,
185            skip: None,
186        };
187
188        let err = render_templated_file_entry(&ctx, &entry, "test").unwrap_err();
189        let chain = format!("{err:#}");
190        assert!(
191            chain.contains("relative path") || chain.contains(".."),
192            "expected traversal rejection, got {chain}"
193        );
194    }
195
196    #[test]
197    fn non_utf8_source_errors_clearly() {
198        let tmp = TempDir::new().unwrap();
199        let src = tmp.path().join("binary.bin");
200        std::fs::write(&src, [0xFF, 0xFE, 0x00, 0x80]).unwrap();
201
202        let ctx = build_ctx();
203        let entry = TemplateFileConfig {
204            id: None,
205            src: src.to_string_lossy().to_string(),
206            dst: "out.txt".to_string(),
207            mode: None,
208            skip: None,
209        };
210        let err = render_templated_file_entry(&ctx, &entry, "test").unwrap_err();
211        let chain = format!("{err:#}");
212        assert!(
213            chain.contains("not valid UTF-8"),
214            "expected UTF-8 error, got {chain}"
215        );
216    }
217
218    #[test]
219    fn missing_src_errors_with_label() {
220        let ctx = build_ctx();
221        let entry = TemplateFileConfig {
222            id: None,
223            src: "/nonexistent/file.tpl".to_string(),
224            dst: "out.txt".to_string(),
225            mode: None,
226            skip: None,
227        };
228        let err = render_templated_file_entry(&ctx, &entry, "mylabel").unwrap_err();
229        let chain = format!("{err:#}");
230        assert!(
231            chain.contains("mylabel") && chain.contains("not found"),
232            "expected label + 'not found' in chain, got {chain}"
233        );
234    }
235}