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!("{e:#}")))
32    }
33}
34
35impl Default for Engine {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41/// `env(name="VAR", default="…")` — read an env var, return `default` (or empty
42/// string) when unset. Returning a string (rather than null) keeps `default`
43/// arg simple; callers can also chain Tera's `default` filter.
44fn env_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
45    let name = args
46        .get("name")
47        .and_then(|v| v.as_str())
48        .ok_or_else(|| tera::Error::msg("env(name=…): missing or non-string 'name'"))?;
49    let default = args.get("default").cloned();
50    match std::env::var(name) {
51        Ok(v) => Ok(Value::String(v)),
52        Err(_) => Ok(default.unwrap_or_else(|| Value::String(String::new()))),
53    }
54}
55
56pub fn config_context(yui: &YuiVars) -> Context {
57    let mut ctx = Context::new();
58    ctx.insert("yui", yui);
59    ctx
60}
61
62pub fn template_context<V: Serialize>(yui: &YuiVars, vars: &V) -> Context {
63    let mut ctx = Context::new();
64    ctx.insert("yui", yui);
65    ctx.insert("vars", vars);
66    ctx
67}
68
69/// Evaluate a Tera expression as a truthy/falsy boolean. Accepts either a
70/// bare expression (`yui.os == 'linux'`) or a pre-wrapped one
71/// (`{{ yui.os == 'linux' }}`); used wherever the user writes a `when`
72/// condition (mount entry, render rule, marker link, file-header).
73pub fn eval_truthy(expr: &str, engine: &mut Engine, ctx: &Context) -> Result<bool> {
74    let trimmed = expr.trim_start();
75    let to_render = if trimmed.starts_with("{{") || trimmed.starts_with("{%") {
76        expr.to_string()
77    } else {
78        format!("{{{{ {expr} }}}}")
79    };
80    let out = engine.render(&to_render, ctx)?;
81    let s = out.trim();
82    Ok(s.eq_ignore_ascii_case("true") || s == "1")
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use camino::Utf8Path;
89
90    fn vars() -> YuiVars {
91        YuiVars {
92            os: "linux".into(),
93            arch: "x86_64".into(),
94            host: "test".into(),
95            user: "u".into(),
96            source: "/dotfiles".into(),
97        }
98    }
99
100    #[test]
101    fn renders_yui_vars() {
102        let mut e = Engine::new();
103        let ctx = config_context(&vars());
104        let out = e
105            .render("os={{ yui.os }}, arch={{ yui.arch }}", &ctx)
106            .unwrap();
107        assert_eq!(out, "os=linux, arch=x86_64");
108    }
109
110    #[test]
111    fn env_function_with_default() {
112        // SAFETY: single-threaded test, no other env access in this case.
113        unsafe { std::env::remove_var("YUI_TEST_UNSET_VAR") };
114        let mut e = Engine::new();
115        let ctx = config_context(&vars());
116        let out = e
117            .render(
118                "{{ env(name='YUI_TEST_UNSET_VAR', default='fallback') }}",
119                &ctx,
120            )
121            .unwrap();
122        assert_eq!(out, "fallback");
123    }
124
125    #[test]
126    fn boolean_expression_renders_to_true_or_false() {
127        let mut e = Engine::new();
128        let ctx = config_context(&vars());
129        let out = e.render("{{ yui.os == 'linux' }}", &ctx).unwrap();
130        assert_eq!(out, "true");
131        let out = e.render("{{ yui.os == 'windows' }}", &ctx).unwrap();
132        assert_eq!(out, "false");
133    }
134
135    #[test]
136    fn template_context_exposes_user_vars() {
137        let mut e = Engine::new();
138        let mut user_vars = toml::Table::new();
139        user_vars.insert("greet".into(), toml::Value::String("hi".into()));
140        let ctx = template_context(&vars(), &user_vars);
141        let out = e.render("{{ vars.greet }} {{ yui.user }}", &ctx).unwrap();
142        assert_eq!(out, "hi u");
143        // ensure the camino import isn't unused
144        let _: &Utf8Path = Utf8Path::new(".");
145    }
146}