Skip to main content

yui/
template.rs

1//! Shared Tera engine + context builders.
2//!
3//! Two contexts:
4//!   - `config_context` — exposes only `yui.*` and the `env(name=…)` function.
5//!     Used while parsing `config*.toml` (vars aren't fully resolved yet).
6//!   - `template_context` — `yui.*` + `vars.*` + `env(…)`. Used to render
7//!     `*.tera` dotfiles after the merged config is known.
8
9use std::collections::HashMap;
10
11use serde::Serialize;
12use tera::{Context, Tera, Value};
13
14use crate::Result;
15use crate::vars::YuiVars;
16
17pub struct Engine {
18    tera: Tera,
19}
20
21impl Engine {
22    pub fn new() -> Self {
23        let mut tera = Tera::default();
24        tera.register_function("env", env_fn);
25        Self { tera }
26    }
27
28    pub fn render(&mut self, src: &str, ctx: &Context) -> Result<String> {
29        self.tera
30            .render_str(src, ctx)
31            .map_err(|e| crate::Error::Template(format_tera_error(&e)))
32    }
33}
34
35/// Tera's `Display` impl only emits the top-level message
36/// (`Failed to render '__tera_one_off'`), leaving the actual reason
37/// (`variable 'vars.home_root' not found in context` etc.) buried in
38/// the source chain. Walk the chain and join every level so the user
39/// sees something they can act on.
40fn format_tera_error(err: &tera::Error) -> String {
41    use std::error::Error as _;
42    let mut parts: Vec<String> = vec![err.to_string()];
43    let mut src = err.source();
44    while let Some(e) = src {
45        parts.push(e.to_string());
46        src = e.source();
47    }
48    parts.join(": ")
49}
50
51impl Default for Engine {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57/// `env(name="VAR", default="…")` — read an env var, return `default` (or empty
58/// string) when unset. Returning a string (rather than null) keeps `default`
59/// arg simple; callers can also chain Tera's `default` filter.
60fn env_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
61    let name = args
62        .get("name")
63        .and_then(|v| v.as_str())
64        .ok_or_else(|| tera::Error::msg("env(name=…): missing or non-string 'name'"))?;
65    let default = args.get("default").cloned();
66    match std::env::var(name) {
67        Ok(v) => Ok(Value::String(v)),
68        Err(_) => Ok(default.unwrap_or_else(|| Value::String(String::new()))),
69    }
70}
71
72pub fn config_context(yui: &YuiVars) -> Context {
73    let mut ctx = Context::new();
74    ctx.insert("yui", yui);
75    ctx
76}
77
78pub fn template_context<V: Serialize>(yui: &YuiVars, vars: &V) -> Context {
79    let mut ctx = Context::new();
80    ctx.insert("yui", yui);
81    ctx.insert("vars", vars);
82    ctx
83}
84
85/// `template_context` plus self-referencing placeholders for the
86/// hook script vars (`script_path` / `script_dir` / `script_name` /
87/// `script_stem` / `script_ext`). Use this *only* for the
88/// config-load Tera pass, where those tokens haven't been bound
89/// yet but should survive verbatim so the hook executor can
90/// resolve them at run time.
91///
92/// Why a separate builder rather than seeding the placeholders in
93/// `template_context` itself: dotfile (`*.tera`) rendering uses
94/// `template_context` too, and a typo'd `{{ script_path }}` in a
95/// dotfile should surface as "Variable not found in context"
96/// rather than silently rendering to the literal `{{ script_path }}`.
97/// Keeping the placeholders carve-out config-only preserves that
98/// loud failure for dotfiles.
99pub fn config_render_context<V: Serialize>(yui: &YuiVars, vars: &V) -> Context {
100    let mut ctx = template_context(yui, vars);
101    for placeholder in [
102        "script_path",
103        "script_dir",
104        "script_name",
105        "script_stem",
106        "script_ext",
107    ] {
108        ctx.insert(placeholder, &format!("{{{{ {placeholder} }}}}"));
109    }
110    ctx
111}
112
113/// Evaluate a Tera expression as a truthy/falsy boolean. Accepts either a
114/// bare expression (`yui.os == 'linux'`) or a pre-wrapped one
115/// (`{{ yui.os == 'linux' }}`); used wherever the user writes a `when`
116/// condition (mount entry, render rule, marker link, file-header).
117pub fn eval_truthy(expr: &str, engine: &mut Engine, ctx: &Context) -> Result<bool> {
118    let trimmed = expr.trim_start();
119    let to_render = if trimmed.starts_with("{{") || trimmed.starts_with("{%") {
120        expr.to_string()
121    } else {
122        format!("{{{{ {expr} }}}}")
123    };
124    let out = engine.render(&to_render, ctx)?;
125    let s = out.trim();
126    Ok(s.eq_ignore_ascii_case("true") || s == "1")
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use camino::Utf8Path;
133
134    fn vars() -> YuiVars {
135        YuiVars {
136            os: "linux".into(),
137            arch: "x86_64".into(),
138            host: "test".into(),
139            user: "u".into(),
140            source: "/dotfiles".into(),
141        }
142    }
143
144    #[test]
145    fn renders_yui_vars() {
146        let mut e = Engine::new();
147        let ctx = config_context(&vars());
148        let out = e
149            .render("os={{ yui.os }}, arch={{ yui.arch }}", &ctx)
150            .unwrap();
151        assert_eq!(out, "os=linux, arch=x86_64");
152    }
153
154    #[test]
155    fn env_function_with_default() {
156        // SAFETY: single-threaded test, no other env access in this case.
157        unsafe { std::env::remove_var("YUI_TEST_UNSET_VAR") };
158        let mut e = Engine::new();
159        let ctx = config_context(&vars());
160        let out = e
161            .render(
162                "{{ env(name='YUI_TEST_UNSET_VAR', default='fallback') }}",
163                &ctx,
164            )
165            .unwrap();
166        assert_eq!(out, "fallback");
167    }
168
169    #[test]
170    fn boolean_expression_renders_to_true_or_false() {
171        let mut e = Engine::new();
172        let ctx = config_context(&vars());
173        let out = e.render("{{ yui.os == 'linux' }}", &ctx).unwrap();
174        assert_eq!(out, "true");
175        let out = e.render("{{ yui.os == 'windows' }}", &ctx).unwrap();
176        assert_eq!(out, "false");
177    }
178
179    #[test]
180    fn template_context_exposes_user_vars() {
181        let mut e = Engine::new();
182        let mut user_vars = toml::Table::new();
183        user_vars.insert("greet".into(), toml::Value::String("hi".into()));
184        let ctx = template_context(&vars(), &user_vars);
185        let out = e.render("{{ vars.greet }} {{ yui.user }}", &ctx).unwrap();
186        assert_eq!(out, "hi u");
187        // ensure the camino import isn't unused
188        let _: &Utf8Path = Utf8Path::new(".");
189    }
190
191    /// Bare `template_context` (used by dotfile / status / list /
192    /// apply rendering) deliberately does NOT seed the hook
193    /// `script_*` placeholders — a typo'd `{{ script_path }}` in a
194    /// `*.tera` dotfile must error loudly so the user catches the
195    /// mistake instead of silently emitting a literal `{{ script_path }}`
196    /// into the rendered output.
197    #[test]
198    fn dotfile_render_errors_on_undefined_script_path() {
199        let mut e = Engine::new();
200        let user_vars = toml::Table::new();
201        let ctx = template_context(&vars(), &user_vars);
202        let err = e
203            .render("hello {{ script_path }}", &ctx)
204            .expect_err("dotfile render must reject undefined script_path");
205        assert!(format!("{err}").contains("script_path"), "{err}");
206    }
207
208    /// `config_render_context`, on the other hand, is used by the
209    /// config-load pass and seeds `script_*` as self-references so
210    /// `[[hook]] args = ["{{ script_path }}"]` survives Tera intact.
211    #[test]
212    fn config_render_context_preserves_script_path_placeholder() {
213        let mut e = Engine::new();
214        let user_vars = toml::Table::new();
215        let ctx = config_render_context(&vars(), &user_vars);
216        let out = e.render("hello {{ script_path }}", &ctx).unwrap();
217        assert_eq!(out, "hello {{ script_path }}");
218    }
219}