Skip to main content

orch/
template.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::fs;
4use std::hash::BuildHasher;
5use std::io::{self, Write};
6use std::sync::LazyLock;
7
8/// Matches `{{#if VAR}}...{{/if}}` blocks (non-greedy, dotall).
9fn 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(std::clone::Clone::clone)
17}
18
19/// Matches `{{VAR}}` variable placeholders.
20fn 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(std::clone::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: &regex::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: &regex::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::ffi::OsString;
109    use std::io::Write;
110    use std::sync::{Mutex, OnceLock};
111    use tempfile::NamedTempFile;
112
113    fn env_mutex() -> &'static Mutex<()> {
114        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
115        ENV_MUTEX.get_or_init(|| Mutex::new(()))
116    }
117
118    struct EnvVarGuard {
119        key: &'static str,
120        previous: Option<OsString>,
121    }
122
123    impl EnvVarGuard {
124        fn set(key: &'static str, value: &str) -> Self {
125            let previous = std::env::var_os(key);
126            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
127            unsafe { std::env::set_var(key, value) };
128            Self { key, previous }
129        }
130    }
131
132    impl Drop for EnvVarGuard {
133        fn drop(&mut self) {
134            match self.previous.take() {
135                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
136                Some(value) => unsafe { std::env::set_var(self.key, value) },
137                // SAFETY: nextest runs each test in its own process, so no other test touches the environment
138                None => unsafe { std::env::remove_var(self.key) },
139            }
140        }
141    }
142
143    #[test]
144    fn render_template_does_not_leak_env_vars() {
145        // Set a sensitive env var that must NOT appear in the rendered output
146        // Acquire a process-wide mutex to serialize env mutation with other tests
147        // and use EnvVarGuard to restore previous state on drop.
148        let _lock = env_mutex().lock().unwrap();
149        let _guard = EnvVarGuard::set("ORCH_TEST_SECRET_TOKEN", "should-not-appear");
150        let mut f = NamedTempFile::new().unwrap();
151        writeln!(f, "hello world").unwrap();
152
153        let result = render_template(f.path().to_str().unwrap(), &[]).unwrap();
154        assert!(
155            !result.contains("should-not-appear"),
156            "env var leaked into rendered template"
157        );
158    }
159
160    #[test]
161    fn render_template_uses_explicit_vars() {
162        let mut f = NamedTempFile::new().unwrap();
163        writeln!(f, "value={{{{MY_VAR}}}}").unwrap();
164
165        let result =
166            render_template(f.path().to_str().unwrap(), &["MY_VAR=hello".to_string()]).unwrap();
167        assert_eq!(result.trim(), "value=hello");
168    }
169
170    #[test]
171    fn compile_pattern_rejects_invalid_regex() {
172        let err = compile_pattern("(", "bad_pattern").unwrap_err();
173        assert!(err.contains("bad_pattern"));
174    }
175}