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/// Evaluate a Tera expression as a truthy/falsy boolean. Accepts either a
86/// bare expression (`yui.os == 'linux'`) or a pre-wrapped one
87/// (`{{ yui.os == 'linux' }}`); used wherever the user writes a `when`
88/// condition (mount entry, render rule, marker link, file-header).
89pub fn eval_truthy(expr: &str, engine: &mut Engine, ctx: &Context) -> Result<bool> {
90    let trimmed = expr.trim_start();
91    let to_render = if trimmed.starts_with("{{") || trimmed.starts_with("{%") {
92        expr.to_string()
93    } else {
94        format!("{{{{ {expr} }}}}")
95    };
96    let out = engine.render(&to_render, ctx)?;
97    let s = out.trim();
98    Ok(s.eq_ignore_ascii_case("true") || s == "1")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use camino::Utf8Path;
105
106    fn vars() -> YuiVars {
107        YuiVars {
108            os: "linux".into(),
109            arch: "x86_64".into(),
110            host: "test".into(),
111            user: "u".into(),
112            source: "/dotfiles".into(),
113        }
114    }
115
116    #[test]
117    fn renders_yui_vars() {
118        let mut e = Engine::new();
119        let ctx = config_context(&vars());
120        let out = e
121            .render("os={{ yui.os }}, arch={{ yui.arch }}", &ctx)
122            .unwrap();
123        assert_eq!(out, "os=linux, arch=x86_64");
124    }
125
126    #[test]
127    fn env_function_with_default() {
128        // SAFETY: single-threaded test, no other env access in this case.
129        unsafe { std::env::remove_var("YUI_TEST_UNSET_VAR") };
130        let mut e = Engine::new();
131        let ctx = config_context(&vars());
132        let out = e
133            .render(
134                "{{ env(name='YUI_TEST_UNSET_VAR', default='fallback') }}",
135                &ctx,
136            )
137            .unwrap();
138        assert_eq!(out, "fallback");
139    }
140
141    #[test]
142    fn boolean_expression_renders_to_true_or_false() {
143        let mut e = Engine::new();
144        let ctx = config_context(&vars());
145        let out = e.render("{{ yui.os == 'linux' }}", &ctx).unwrap();
146        assert_eq!(out, "true");
147        let out = e.render("{{ yui.os == 'windows' }}", &ctx).unwrap();
148        assert_eq!(out, "false");
149    }
150
151    #[test]
152    fn template_context_exposes_user_vars() {
153        let mut e = Engine::new();
154        let mut user_vars = toml::Table::new();
155        user_vars.insert("greet".into(), toml::Value::String("hi".into()));
156        let ctx = template_context(&vars(), &user_vars);
157        let out = e.render("{{ vars.greet }} {{ yui.user }}", &ctx).unwrap();
158        assert_eq!(out, "hi u");
159        // ensure the camino import isn't unused
160        let _: &Utf8Path = Utf8Path::new(".");
161    }
162}