1use regex::Regex;
2use std::collections::HashMap;
3use std::fs;
4use std::hash::BuildHasher;
5use std::io::{self, Write};
6use std::sync::LazyLock;
7
8fn compile_pattern(pattern: &str, label: &str) -> Result<Regex, String> {
10 Regex::new(pattern).map_err(|err| format!("invalid {label} regex: {err}"))
11}
12
13fn if_pattern() -> Result<&'static Regex, String> {
14 static IF_PATTERN: LazyLock<Result<Regex, String>> =
15 LazyLock::new(|| compile_pattern(r"(?s)\{\{#if\s+(\w+)\}\}(.*?)\{\{/if\}\}", "if_pattern"));
16 IF_PATTERN.as_ref().map_err(Clone::clone)
17}
18
19fn var_pattern() -> Result<&'static Regex, String> {
21 static VAR_PATTERN: LazyLock<Result<Regex, String>> =
22 LazyLock::new(|| compile_pattern(r"\{\{(\w+)\}\}", "var_pattern"));
23 VAR_PATTERN.as_ref().map_err(Clone::clone)
24}
25
26fn render_template_with_vars<S: BuildHasher>(
27 template: &str,
28 vars: &HashMap<String, String, S>,
29) -> Result<String, String> {
30 let mut data = template.to_string();
31
32 loop {
33 let mut changed = false;
34 let new_data = if_pattern()?
35 .replace_all(&data, |caps: ®ex::Captures<'_>| {
36 changed = true;
37 let var_name = &caps[1];
38 let content = &caps[2];
39 match vars.get(var_name) {
40 Some(value) if !value.trim().is_empty() => content.to_string(),
41 _ => String::new(),
42 }
43 })
44 .to_string();
45
46 data = new_data;
47 if !changed {
48 break;
49 }
50 }
51
52 let result = var_pattern()?
53 .replace_all(&data, |caps: ®ex::Captures<'_>| {
54 let var_name = &caps[1];
55 vars.get(var_name).cloned().unwrap_or_default()
56 })
57 .to_string();
58
59 if result.is_empty() {
60 return Err("empty output".to_string());
61 }
62
63 Ok(result)
64}
65
66pub fn render_template_str<S: BuildHasher>(
67 template: &str,
68 vars: &HashMap<String, String, S>,
69) -> Result<String, String> {
70 render_template_with_vars(template, vars)
71}
72
73pub fn render_template(template_path: &str, extra_vars: &[String]) -> Result<String, String> {
74 if !fs::metadata(template_path).is_ok_and(|m| m.is_file()) {
75 return Err(format!("template not found: {template_path}"));
76 }
77
78 let data =
79 fs::read_to_string(template_path).map_err(|e| format!("failed to read template: {e}"))?;
80
81 let mut vars: HashMap<String, String> = HashMap::new();
82
83 for var in extra_vars {
84 if let Some((key, value)) = var.split_once('=') {
85 vars.insert(key.to_string(), value.to_string());
86 }
87 }
88
89 render_template_with_vars(&data, &vars)
90}
91
92pub fn render_and_print(template_path: &str, extra_vars: &[String]) -> io::Result<()> {
93 match render_template(template_path, extra_vars) {
94 Ok(output) => {
95 io::stdout().write_all(output.as_bytes())?;
96 Ok(())
97 }
98 Err(e) => {
99 io::stderr().write_all(e.as_bytes())?;
100 Err(io::Error::other(e))
101 }
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use std::env;
109 use std::ffi::OsString;
110 use std::io::Write;
111 use std::sync::{Mutex, OnceLock};
112 use tempfile::NamedTempFile;
113
114 fn env_mutex() -> &'static Mutex<()> {
115 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
116 ENV_MUTEX.get_or_init(|| Mutex::new(()))
117 }
118
119 struct EnvVarGuard {
120 key: &'static str,
121 previous: Option<OsString>,
122 }
123
124 impl EnvVarGuard {
125 fn set(key: &'static str, value: &str) -> Self {
126 let previous = env::var_os(key);
127 unsafe { env::set_var(key, value) };
129 Self { key, previous }
130 }
131 }
132
133 impl Drop for EnvVarGuard {
134 fn drop(&mut self) {
135 match self.previous.take() {
136 Some(value) => unsafe { env::set_var(self.key, value) },
138 None => unsafe { env::remove_var(self.key) },
140 }
141 }
142 }
143
144 #[test]
145 fn render_template_does_not_leak_env_vars() {
146 let _lock = env_mutex().lock().unwrap();
150 let _guard = EnvVarGuard::set("ORCH_TEST_SECRET_TOKEN", "should-not-appear");
151 let mut f = NamedTempFile::new().unwrap();
152 writeln!(f, "hello world").unwrap();
153
154 let result = render_template(f.path().to_str().unwrap(), &[]).unwrap();
155 assert!(
156 !result.contains("should-not-appear"),
157 "env var leaked into rendered template"
158 );
159 }
160
161 #[test]
162 fn render_template_uses_explicit_vars() {
163 let mut f = NamedTempFile::new().unwrap();
164 writeln!(f, "value={{{{MY_VAR}}}}").unwrap();
165
166 let result =
167 render_template(f.path().to_str().unwrap(), &["MY_VAR=hello".to_string()]).unwrap();
168 assert_eq!(result.trim(), "value=hello");
169 }
170
171 #[test]
172 fn compile_pattern_rejects_invalid_regex() {
173 let err = compile_pattern("(", "bad_pattern").unwrap_err();
174 assert!(err.contains("bad_pattern"));
175 }
176}