Skip to main content

orch/
template.rs

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