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#[cfg(test)]
70mod tests {
71    use super::*;
72    use camino::Utf8Path;
73
74    fn vars() -> YuiVars {
75        YuiVars {
76            os: "linux".into(),
77            arch: "x86_64".into(),
78            host: "test".into(),
79            user: "u".into(),
80            source: "/dotfiles".into(),
81        }
82    }
83
84    #[test]
85    fn renders_yui_vars() {
86        let mut e = Engine::new();
87        let ctx = config_context(&vars());
88        let out = e
89            .render("os={{ yui.os }}, arch={{ yui.arch }}", &ctx)
90            .unwrap();
91        assert_eq!(out, "os=linux, arch=x86_64");
92    }
93
94    #[test]
95    fn env_function_with_default() {
96        // SAFETY: single-threaded test, no other env access in this case.
97        unsafe { std::env::remove_var("YUI_TEST_UNSET_VAR") };
98        let mut e = Engine::new();
99        let ctx = config_context(&vars());
100        let out = e
101            .render(
102                "{{ env(name='YUI_TEST_UNSET_VAR', default='fallback') }}",
103                &ctx,
104            )
105            .unwrap();
106        assert_eq!(out, "fallback");
107    }
108
109    #[test]
110    fn boolean_expression_renders_to_true_or_false() {
111        let mut e = Engine::new();
112        let ctx = config_context(&vars());
113        let out = e.render("{{ yui.os == 'linux' }}", &ctx).unwrap();
114        assert_eq!(out, "true");
115        let out = e.render("{{ yui.os == 'windows' }}", &ctx).unwrap();
116        assert_eq!(out, "false");
117    }
118
119    #[test]
120    fn template_context_exposes_user_vars() {
121        let mut e = Engine::new();
122        let mut user_vars = toml::Table::new();
123        user_vars.insert("greet".into(), toml::Value::String("hi".into()));
124        let ctx = template_context(&vars(), &user_vars);
125        let out = e.render("{{ vars.greet }} {{ yui.user }}", &ctx).unwrap();
126        assert_eq!(out, "hi u");
127        // ensure the camino import isn't unused
128        let _: &Utf8Path = Utf8Path::new(".");
129    }
130}