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(); let mut content = String::new();
16 let mut closed = false;
17 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 for _ in 0..content.chars().count() {
34 chars.next();
35 }
36 chars.next(); chars.next(); 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 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 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 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 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}