1use 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
35fn 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
57fn 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
85pub 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 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 let _: &Utf8Path = Utf8Path::new(".");
161 }
162}