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 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
113pub 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 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 let _: &Utf8Path = Utf8Path::new(".");
189 }
190
191 #[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 #[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}