Skip to main content

oxdock_process/
expand.rs

1use std::collections::HashMap;
2
3use crate::contract::CommandContext;
4
5pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup: F) -> String
6where
7    F: FnMut(&str) -> Option<String>,
8{
9    let mut out = String::with_capacity(input.len());
10    let mut chars = input.chars().peekable();
11    while let Some(c) = chars.next() {
12        if c == '{' {
13            if let Some(&'{') = chars.peek() {
14                chars.next(); // consume second '{'
15                let mut content = String::new();
16                let mut closed = false;
17                // Look ahead for closing }}
18                let mut inner_chars = chars.clone();
19                while let Some(ch) = inner_chars.next() {
20                    if ch == '}'
21                        && let Some(&'}') = inner_chars.peek()
22                    {
23                        closed = true;
24                        break;
25                    }
26                    content.push(ch);
27                }
28
29                if closed {
30                    // Advance main iterator past content and closing braces.
31                    // Count chars, not bytes: content may contain multi-byte
32                    // UTF-8 (e.g. non-ASCII placeholder names).
33                    for _ in 0..content.chars().count() {
34                        chars.next();
35                    }
36                    chars.next(); // first }
37                    chars.next(); // second }
38
39                    let key = content.trim();
40                    if !key.is_empty() {
41                        out.push_str(&lookup(key).unwrap_or_default());
42                    }
43                } else {
44                    out.push('{');
45                    out.push('{');
46                }
47            } else {
48                out.push('{');
49            }
50        } else {
51            out.push(c);
52        }
53    }
54    out
55}
56
57pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
58    expand_with_lookup(input, |name| {
59        if let Some(key) = name.strip_prefix("env:") {
60            script_envs
61                .get(key)
62                .cloned()
63                .or_else(|| std::env::var(key).ok())
64        } else {
65            None
66        }
67    })
68}
69
70pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
71    expand_with_lookup(input, |name| {
72        if let Some(key) = name.strip_prefix("env:") {
73            ctx.envs().get(key).cloned()
74        } else {
75            None
76        }
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use oxdock_fs::{GuardedPath, PolicyPath};
84    use oxdock_sys_test_utils::TestEnvGuard;
85    use std::collections::HashMap;
86
87    #[test]
88    fn expand_script_env_prefers_script_values() {
89        let mut script_envs = HashMap::new();
90        script_envs.insert("FOO".into(), "from-script".into());
91        script_envs.insert("ONLY".into(), "only".into());
92        let _env_guard = TestEnvGuard::set("FOO", "from-env");
93        let rendered = expand_script_env(
94            "{{ env:FOO }}:{{ env:ONLY }}:{{ env:MISSING }}",
95            &script_envs,
96        );
97        assert_eq!(rendered, "from-script:only:");
98    }
99
100    #[test]
101    fn expand_script_env_supports_colon_separator() {
102        let mut script_envs = HashMap::new();
103        script_envs.insert("FOO".into(), "val".into());
104        let rendered = expand_script_env("{{ env:FOO }}", &script_envs);
105        assert_eq!(rendered, "val");
106    }
107
108    #[test]
109    fn expand_command_env_handles_var_forms() {
110        let temp = GuardedPath::tempdir().expect("tempdir");
111        let guard = temp.as_guarded_path().clone();
112        let cwd: PolicyPath = guard.clone().into();
113        let mut envs = HashMap::new();
114        envs.insert("FOO".into(), "bar".into());
115        envs.insert("PCT".into(), "percent".into());
116        envs.insert("CARGO_TARGET_DIR".into(), guard.display().to_string());
117        envs.insert("HOST_ONLY".into(), "host".into());
118
119        let ctx = CommandContext::from_map(&cwd, &envs, &guard, &guard, &guard);
120
121        // Valid syntax: {{ env:VAR }}
122        let rendered = expand_command_env(
123            "{{ env:FOO }}-{{ env:PCT }}-{{ env:HOST_ONLY }}-{{ env:CARGO_TARGET_DIR }}",
124            &ctx,
125        );
126        assert_eq!(rendered, format!("bar-percent-host-{}", guard.display()));
127
128        // Invalid/Legacy syntax: treated as literal text
129        // %FOO% -> %FOO%
130        // {CARGO_TARGET_DIR} -> {CARGO_TARGET_DIR}
131        // $$ -> $$
132        let input_literal = "%FOO%-{CARGO_TARGET_DIR}-$$";
133        let rendered_literal = expand_command_env(input_literal, &ctx);
134        assert_eq!(rendered_literal, input_literal);
135    }
136
137    #[test]
138    fn expand_command_env_does_not_fallback_to_host() {
139        let temp = GuardedPath::tempdir().expect("tempdir");
140        let guard = temp.as_guarded_path().clone();
141        let cwd: PolicyPath = guard.clone().into();
142        let envs = HashMap::new();
143        let _env_guard = TestEnvGuard::set("HOST_ONLY", "host");
144
145        let ctx = CommandContext::from_map(&cwd, &envs, &guard, &guard, &guard);
146        let rendered = expand_command_env("{{ env:HOST_ONLY }}", &ctx);
147        assert_eq!(rendered, "");
148    }
149
150    #[test]
151    fn expand_with_lookup_handles_multibyte_placeholder_names() {
152        // Regression: advancement used to count bytes instead of chars, so a
153        // multi-byte placeholder name swallowed characters after `}}`.
154        let rendered = expand_with_lookup("{{ env:héllo }}X", |name| {
155            if name == "env:héllo" {
156                Some("value".to_string())
157            } else {
158                None
159            }
160        });
161        assert_eq!(rendered, "valueX");
162    }
163
164    #[test]
165    fn expand_with_lookup_preserves_multibyte_text_outside_placeholders() {
166        let rendered = expand_with_lookup("héllo wörld {{ env:A }} ✓", |name| {
167            if name == "env:A" {
168                Some("1".to_string())
169            } else {
170                None
171            }
172        });
173        assert_eq!(rendered, "héllo wörld 1 ✓");
174    }
175
176    #[test]
177    fn expand_with_lookup_keeps_unclosed_double_brace_literal() {
178        let rendered = expand_with_lookup("a {{ b", |_| -> Option<String> {
179            panic!("input without any closing braces must not produce lookups")
180        });
181        assert_eq!(rendered, "a {{ b");
182    }
183
184    #[test]
185    fn expand_with_lookup_binds_first_open_to_next_close_across_text() {
186        // Pins current greedy behavior: the first `{{` binds to the next `}}`
187        // even across an interior `{{`, and the entire span is trimmed into a
188        // single lookup key. With no resolver entry for that composite key,
189        // the whole placeholder renders as empty text.
190        let seen = std::cell::RefCell::new(None);
191        let rendered = expand_with_lookup("a {{ b {{ env:X }} c", |name| {
192            *seen.borrow_mut() = Some(name.to_string());
193            if name == "env:X" {
194                Some("V".to_string())
195            } else {
196                None
197            }
198        });
199        assert_eq!(rendered, "a  c");
200        assert_eq!(seen.borrow().as_deref(), Some("b {{ env:X"));
201    }
202
203    #[test]
204    fn expand_with_lookup_skips_empty_and_blank_keys() {
205        let rendered = expand_with_lookup("x{{}}y", |_| -> Option<String> {
206            panic!("empty key must not be looked up")
207        });
208        assert_eq!(rendered, "xy");
209
210        let rendered_blank = expand_with_lookup("x{{   }}y", |_| -> Option<String> {
211            panic!("blank key must not be looked up")
212        });
213        assert_eq!(rendered_blank, "xy");
214    }
215
216    #[test]
217    fn expand_with_lookup_passes_through_stray_braces() {
218        let rendered_close = expand_with_lookup("a }} b", |_| -> Option<String> {
219            panic!("stray closing braces must not be looked up")
220        });
221        assert_eq!(rendered_close, "a }} b");
222
223        let rendered_single = expand_with_lookup("{ alone {", |_| -> Option<String> {
224            panic!("single brace must not be looked up")
225        });
226        assert_eq!(rendered_single, "{ alone {");
227    }
228
229    #[test]
230    fn expand_with_lookup_supports_adjacent_placeholders() {
231        let rendered = expand_with_lookup("{{ env:A }}{{ env:B }}", |name| match name {
232            "env:A" => Some("a".to_string()),
233            "env:B" => Some("b".to_string()),
234            _ => None,
235        });
236        assert_eq!(rendered, "ab");
237    }
238}