1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5#[derive(Default)]
7pub struct StepEnvironmentUpdates {
8 pub outputs: HashMap<String, String>,
10 pub env_vars: HashMap<String, String>,
12 pub path_entries: Vec<String>,
14 pub step_summary: String,
16}
17
18fn is_valid_identifier(s: &str) -> bool {
23 let mut chars = s.chars();
24 match chars.next() {
25 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
26 _ => return false,
27 }
28 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
29}
30
31pub fn parse_github_kv_file(content: &str) -> HashMap<String, String> {
37 let mut result = HashMap::new();
38 let lines: Vec<&str> = content.lines().collect();
39 let mut i = 0;
40
41 while i < lines.len() {
42 let line = lines[i];
43
44 if line.is_empty() {
46 i += 1;
47 continue;
48 }
49
50 if let Some(heredoc_sep_pos) = line.find("<<") {
54 let key = &line[..heredoc_sep_pos];
55 let delimiter = &line[heredoc_sep_pos + 2..];
56
57 if !key.is_empty() && !delimiter.is_empty() && is_valid_identifier(key) {
58 let mut value_lines = Vec::new();
60 i += 1;
61 while i < lines.len() {
62 if lines[i] == delimiter {
63 break;
64 }
65 value_lines.push(lines[i]);
66 i += 1;
67 }
68 result.insert(key.to_string(), value_lines.join("\n"));
69 i += 1; continue;
71 }
72 }
73
74 if let Some(eq_pos) = line.find('=') {
76 let key = &line[..eq_pos];
77 let value = &line[eq_pos + 1..];
78 if !key.is_empty() {
79 result.insert(key.to_string(), value.to_string());
80 }
81 }
82
83 i += 1;
84 }
85
86 result
87}
88
89pub fn parse_github_path_file(content: &str) -> Vec<String> {
91 content
92 .lines()
93 .map(|l| l.trim())
94 .filter(|l| !l.is_empty())
95 .map(|l| l.to_string())
96 .collect()
97}
98
99pub fn read_step_environment_updates(job_env: &HashMap<String, String>) -> StepEnvironmentUpdates {
104 let mut updates = StepEnvironmentUpdates::default();
105
106 if let Some(path) = job_env.get("GITHUB_OUTPUT") {
107 if let Ok(content) = fs::read_to_string(Path::new(path)) {
108 if !content.is_empty() {
109 updates.outputs = parse_github_kv_file(&content);
110 }
111 }
112 }
113
114 if let Some(path) = job_env.get("GITHUB_ENV") {
115 if let Ok(content) = fs::read_to_string(Path::new(path)) {
116 if !content.is_empty() {
117 updates.env_vars = parse_github_kv_file(&content);
118 }
119 }
120 }
121
122 if let Some(path) = job_env.get("GITHUB_PATH") {
123 if let Ok(content) = fs::read_to_string(Path::new(path)) {
124 if !content.is_empty() {
125 updates.path_entries = parse_github_path_file(&content);
126 }
127 }
128 }
129
130 if let Some(path) = job_env.get("GITHUB_STEP_SUMMARY") {
131 if let Ok(content) = fs::read_to_string(Path::new(path)) {
132 updates.step_summary = content;
133 }
134 }
135
136 updates
137}
138
139pub fn apply_step_environment_updates(
146 job_env: &mut HashMap<String, String>,
147 job_user_env: &mut HashMap<String, String>,
148 step_outputs_map: &mut HashMap<String, HashMap<String, String>>,
149 step_id: Option<&str>,
150) {
151 let updates = read_step_environment_updates(job_env);
152
153 if let Some(id) = step_id {
155 step_outputs_map.insert(id.to_string(), updates.outputs);
156 }
157
158 for (k, v) in updates.env_vars {
164 job_user_env.insert(k.clone(), v.clone());
165 job_env.insert(k, v);
166 }
167
168 if !updates.path_entries.is_empty() {
170 let current_path = job_env
171 .get("PATH")
172 .cloned()
173 .or_else(|| std::env::var("PATH").ok())
174 .unwrap_or_default();
175 let new_entries = updates.path_entries.join(":");
176 let new_path = if current_path.is_empty() {
177 new_entries
178 } else {
179 format!("{}:{}", new_entries, current_path)
180 };
181 job_env.insert("PATH".to_string(), new_path);
182 }
183
184 clear_step_files(job_env);
186}
187
188pub fn clear_step_files(job_env: &HashMap<String, String>) {
197 for key in &["GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH"] {
198 if let Some(path) = job_env.get(*key) {
199 let _ = fs::write(Path::new(path), "");
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use tempfile::tempdir;
208
209 #[test]
210 fn parse_simple_kv() {
211 let content = "key=value\nother=thing";
212 let result = parse_github_kv_file(content);
213 assert_eq!(result.get("key").unwrap(), "value");
214 assert_eq!(result.get("other").unwrap(), "thing");
215 }
216
217 #[test]
218 fn parse_heredoc() {
219 let content = "body<<EOF\nline1\nline2\nEOF";
220 let result = parse_github_kv_file(content);
221 assert_eq!(result.get("body").unwrap(), "line1\nline2");
222 }
223
224 #[test]
225 fn parse_heredoc_custom_delimiter() {
226 let content = "msg<<DELIM_123\nhello world\nDELIM_123";
227 let result = parse_github_kv_file(content);
228 assert_eq!(result.get("msg").unwrap(), "hello world");
229 }
230
231 #[test]
232 fn parse_mixed_formats() {
233 let content = "simple=val\nmulti<<END\nfoo\nbar\nEND\nanother=baz";
234 let result = parse_github_kv_file(content);
235 assert_eq!(result.get("simple").unwrap(), "val");
236 assert_eq!(result.get("multi").unwrap(), "foo\nbar");
237 assert_eq!(result.get("another").unwrap(), "baz");
238 }
239
240 #[test]
241 fn parse_empty_input() {
242 let result = parse_github_kv_file("");
243 assert!(result.is_empty());
244 }
245
246 #[test]
247 fn parse_value_with_equals() {
248 let content = "url=https://example.com?a=1&b=2";
249 let result = parse_github_kv_file(content);
250 assert_eq!(result.get("url").unwrap(), "https://example.com?a=1&b=2");
251 }
252
253 #[test]
254 fn parse_empty_value() {
255 let content = "empty=";
256 let result = parse_github_kv_file(content);
257 assert_eq!(result.get("empty").unwrap(), "");
258 }
259
260 #[test]
261 fn parse_skips_blank_lines() {
262 let content = "\nkey=value\n\nother=thing\n";
263 let result = parse_github_kv_file(content);
264 assert_eq!(result.len(), 2);
265 assert_eq!(result.get("key").unwrap(), "value");
266 }
267
268 #[test]
269 fn parse_path_file() {
270 let content = "/usr/local/bin\n/opt/tools\n";
271 let result = parse_github_path_file(content);
272 assert_eq!(result, vec!["/usr/local/bin", "/opt/tools"]);
273 }
274
275 #[test]
276 fn parse_path_file_skips_blank_lines() {
277 let content = "\n/first\n\n/second\n";
278 let result = parse_github_path_file(content);
279 assert_eq!(result, vec!["/first", "/second"]);
280 }
281
282 #[test]
283 fn read_missing_files_returns_empty() {
284 let mut env = HashMap::new();
285 env.insert(
286 "GITHUB_OUTPUT".to_string(),
287 "/nonexistent/path/output".to_string(),
288 );
289 let updates = read_step_environment_updates(&env);
290 assert!(updates.outputs.is_empty());
291 assert!(updates.env_vars.is_empty());
292 assert!(updates.path_entries.is_empty());
293 assert!(updates.step_summary.is_empty());
294 }
295
296 #[test]
297 fn read_and_clear_round_trip() {
298 let dir = tempdir().unwrap();
299 let output_path = dir.path().join("output");
300 let env_path = dir.path().join("env");
301 let path_path = dir.path().join("path");
302 fs::write(&output_path, "version=1.2.3\n").unwrap();
303 fs::write(&env_path, "MY_VAR=hello\n").unwrap();
304 fs::write(&path_path, "/new/bin\n").unwrap();
305
306 let mut env = HashMap::new();
307 env.insert(
308 "GITHUB_OUTPUT".to_string(),
309 output_path.to_string_lossy().to_string(),
310 );
311 env.insert(
312 "GITHUB_ENV".to_string(),
313 env_path.to_string_lossy().to_string(),
314 );
315 env.insert(
316 "GITHUB_PATH".to_string(),
317 path_path.to_string_lossy().to_string(),
318 );
319
320 let updates = read_step_environment_updates(&env);
321 assert_eq!(updates.outputs.get("version").unwrap(), "1.2.3");
322 assert_eq!(updates.env_vars.get("MY_VAR").unwrap(), "hello");
323 assert_eq!(updates.path_entries, vec!["/new/bin"]);
324
325 clear_step_files(&env);
326 assert!(fs::read_to_string(&output_path).unwrap().is_empty());
327 assert!(fs::read_to_string(&env_path).unwrap().is_empty());
328 assert!(fs::read_to_string(&path_path).unwrap().is_empty());
329 }
330
331 #[test]
332 fn read_all_four_files() {
333 let dir = tempdir().unwrap();
334 let github_dir = dir.path().join("github");
335 fs::create_dir_all(&github_dir).unwrap();
336
337 fs::write(github_dir.join("output"), "result=ok\n").unwrap();
338 fs::write(github_dir.join("env"), "MY_VAR=hello\n").unwrap();
339 fs::write(github_dir.join("path"), "/new/path\n").unwrap();
340 fs::write(github_dir.join("step_summary"), "## Summary\nAll good").unwrap();
341
342 let mut env = HashMap::new();
343 env.insert(
344 "GITHUB_OUTPUT".to_string(),
345 github_dir.join("output").to_string_lossy().to_string(),
346 );
347 env.insert(
348 "GITHUB_ENV".to_string(),
349 github_dir.join("env").to_string_lossy().to_string(),
350 );
351 env.insert(
352 "GITHUB_PATH".to_string(),
353 github_dir.join("path").to_string_lossy().to_string(),
354 );
355 env.insert(
356 "GITHUB_STEP_SUMMARY".to_string(),
357 github_dir
358 .join("step_summary")
359 .to_string_lossy()
360 .to_string(),
361 );
362
363 let updates = read_step_environment_updates(&env);
364 assert_eq!(updates.outputs.get("result").unwrap(), "ok");
365 assert_eq!(updates.env_vars.get("MY_VAR").unwrap(), "hello");
366 assert_eq!(updates.path_entries, vec!["/new/path"]);
367 assert_eq!(updates.step_summary, "## Summary\nAll good");
368 }
369
370 #[test]
371 fn parse_value_containing_heredoc_marker() {
372 let content = "url=https://example.com/path<<EOF";
376 let result = parse_github_kv_file(content);
377 assert_eq!(result.get("url").unwrap(), "https://example.com/path<<EOF");
378 }
379
380 #[test]
381 fn parse_unterminated_heredoc() {
382 let content = "body<<EOF\nline1\nline2";
384 let result = parse_github_kv_file(content);
385 assert_eq!(result.get("body").unwrap(), "line1\nline2");
386 }
387
388 #[test]
389 fn parse_heredoc_in_output_format() {
390 let content = "json<<EOF\n{\"key\": \"value\"}\nEOF\nversion=1.0";
392 let result = parse_github_kv_file(content);
393 assert_eq!(result.get("json").unwrap(), "{\"key\": \"value\"}");
394 assert_eq!(result.get("version").unwrap(), "1.0");
395 }
396
397 #[test]
398 fn apply_updates_merges_env_and_path() {
399 let dir = tempdir().unwrap();
400 let github_dir = dir.path().join("github");
401 fs::create_dir_all(&github_dir).unwrap();
402
403 fs::write(github_dir.join("output"), "artifact=build.tar\n").unwrap();
404 fs::write(github_dir.join("env"), "CC=gcc\n").unwrap();
405 fs::write(github_dir.join("path"), "/opt/gcc/bin\n").unwrap();
406
407 let mut job_env = HashMap::new();
408 job_env.insert(
409 "GITHUB_OUTPUT".to_string(),
410 github_dir.join("output").to_string_lossy().to_string(),
411 );
412 job_env.insert(
413 "GITHUB_ENV".to_string(),
414 github_dir.join("env").to_string_lossy().to_string(),
415 );
416 job_env.insert(
417 "GITHUB_PATH".to_string(),
418 github_dir.join("path").to_string_lossy().to_string(),
419 );
420 job_env.insert("PATH".to_string(), "/usr/bin".to_string());
421
422 let mut step_outputs_map = HashMap::new();
423 let mut job_user_env = HashMap::new();
424
425 apply_step_environment_updates(
426 &mut job_env,
427 &mut job_user_env,
428 &mut step_outputs_map,
429 Some("build"),
430 );
431
432 assert_eq!(
434 step_outputs_map
435 .get("build")
436 .unwrap()
437 .get("artifact")
438 .unwrap(),
439 "build.tar"
440 );
441 assert_eq!(job_env.get("CC").unwrap(), "gcc");
443 assert_eq!(job_user_env.get("CC").unwrap(), "gcc");
445 assert_eq!(job_env.get("PATH").unwrap(), "/opt/gcc/bin:/usr/bin");
447 assert!(
449 !job_user_env.contains_key("PATH"),
450 "PATH should not leak into user_env"
451 );
452 assert!(fs::read_to_string(github_dir.join("output"))
454 .unwrap()
455 .is_empty());
456 assert!(fs::read_to_string(github_dir.join("env"))
457 .unwrap()
458 .is_empty());
459 assert!(fs::read_to_string(github_dir.join("path"))
460 .unwrap()
461 .is_empty());
462 }
463
464 #[test]
465 fn apply_updates_no_duplicate_path_entries() {
466 let dir = tempdir().unwrap();
467 let github_dir = dir.path().join("github");
468 fs::create_dir_all(&github_dir).unwrap();
469
470 let output_path = github_dir.join("output");
471 let env_path = github_dir.join("env");
472 let path_path = github_dir.join("path");
473
474 let mut job_env = HashMap::new();
475 job_env.insert(
476 "GITHUB_OUTPUT".to_string(),
477 output_path.to_string_lossy().to_string(),
478 );
479 job_env.insert(
480 "GITHUB_ENV".to_string(),
481 env_path.to_string_lossy().to_string(),
482 );
483 job_env.insert(
484 "GITHUB_PATH".to_string(),
485 path_path.to_string_lossy().to_string(),
486 );
487 job_env.insert("PATH".to_string(), "/usr/bin".to_string());
488
489 let mut step_outputs_map = HashMap::new();
490 let mut job_user_env = HashMap::new();
491
492 fs::write(&output_path, "").unwrap();
494 fs::write(&env_path, "").unwrap();
495 fs::write(&path_path, "/opt/tool\n").unwrap();
496 apply_step_environment_updates(
497 &mut job_env,
498 &mut job_user_env,
499 &mut step_outputs_map,
500 None,
501 );
502 assert_eq!(job_env.get("PATH").unwrap(), "/opt/tool:/usr/bin");
503
504 fs::write(&output_path, "").unwrap();
506 fs::write(&env_path, "").unwrap();
507 fs::write(&path_path, "/opt/other\n").unwrap();
508 apply_step_environment_updates(
509 &mut job_env,
510 &mut job_user_env,
511 &mut step_outputs_map,
512 None,
513 );
514
515 let path = job_env.get("PATH").unwrap();
517 assert_eq!(path, "/opt/other:/opt/tool:/usr/bin");
518 }
519}