1extern crate self as oxdock_core;
2
3pub mod exec;
4pub mod pipeline;
5pub use exec::*;
6pub use oxdock_parser::{
7 Arg, ArgType, CommandMeta, CommandSpec, StepKind, all_metadata, all_structural_metadata,
8 lower_command,
9};
10pub use oxdock_process::ProcessManager;
11
12define_pipeline! {
13 StepKind::Run(..) => exec::dispatch_run,
14 StepKind::RunExec { .. } => exec::dispatch_run_exec,
15 StepKind::AsyncBlock { .. } => exec::dispatch_async_block,
16 StepKind::Echo(..) => exec::dispatch_echo,
17 StepKind::Workdir(..) => exec::dispatch_workdir,
18 StepKind::Workspace(..) => exec::dispatch_workspace,
19 StepKind::Env { .. } => exec::dispatch_env,
20 StepKind::Copy { .. } => exec::dispatch_copy,
21 StepKind::CopyGit { .. } => exec::dispatch_copy_git,
22 StepKind::Symlink { .. } => exec::dispatch_symlink,
23 StepKind::Mkdir(..) => exec::dispatch_mkdir,
24 StepKind::Ls(..) => exec::dispatch_ls,
25 StepKind::Cwd => exec::dispatch_cwd,
26 StepKind::Read(..) => exec::dispatch_read,
27 StepKind::ReadLine { .. } => exec::dispatch_read_line,
28 StepKind::Write { .. } => exec::dispatch_write,
29 StepKind::Append { .. } => exec::dispatch_append,
30 StepKind::Expand { .. } => exec::dispatch_expand,
31 StepKind::AssertEq { .. } => exec::dispatch_assert_eq,
32 StepKind::AssertContains { .. } => exec::dispatch_assert_contains,
33 StepKind::HashSha256 { .. } => exec::dispatch_hash_sha256,
34 StepKind::Exit(..) => exec::dispatch_exit,
35 StepKind::AssignAsync { .. } => exec::dispatch_assign_async_step,
36 StepKind::Set { .. } => exec::dispatch_set,
37 StepKind::Await { .. } => exec::dispatch_await_step,
38 StepKind::AssignCapture { .. } => exec::dispatch_assign_capture_step,
39 StepKind::AwaitCapture { .. } => exec::dispatch_await_capture_step,
40 StepKind::Cancel { .. } => exec::dispatch_cancel_step,
41 StepKind::Timeout { .. } => exec::dispatch_timeout_step,
42 StepKind::Sleep { .. } => exec::dispatch_sleep_step,
43 StepKind::ListAppend { .. } => exec::dispatch_push_into_step,
44 StepKind::FuncDef { .. } => exec::dispatch_func_def,
45 StepKind::Call { .. } => exec::dispatch_call,
46 StepKind::Return { .. } => exec::dispatch_return,
47 StepKind::While { .. } => exec::dispatch_while_loop,
48 StepKind::Break => exec::dispatch_break,
49 StepKind::Continue => exec::dispatch_continue,
50}
51
52pub fn parse_script(input: &str) -> anyhow::Result<Vec<oxdock_parser::Step>> {
59 parse_script_with_modules(input, std_module_table())
60}
61
62pub fn parse_script_with_modules(
69 input: &str,
70 modules: oxdock_parser::ModuleTable,
71) -> anyhow::Result<Vec<oxdock_parser::Step>> {
72 let mut reserved = builtin_function_names();
73 reserved.extend(modules.reserved_base_names());
74 Ok(oxdock_parser::parse_script_with_modules(
75 input,
76 lower_command,
77 reserved,
78 modules,
79 )?)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use indoc::indoc;
86 use oxdock_fs::{GuardedPath, GuardedTempDir, PathResolver};
87 use oxdock_parser::{Step, StepKind};
88 #[cfg(unix)]
89 use std::time::Instant;
90
91 fn guard_root(temp: &GuardedTempDir) -> GuardedPath {
92 temp.as_guarded_path().clone()
93 }
94
95 fn read_trimmed(path: &GuardedPath) -> String {
96 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
97 resolver
98 .read_to_string(path)
99 .unwrap_or_default()
100 .trim()
101 .to_string()
102 }
103
104 fn create_dirs(path: &GuardedPath) {
105 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
106 resolver.create_dir_all(path).unwrap();
107 }
108
109 fn exists(root: &GuardedPath, rel: &str) -> bool {
110 root.join(rel).map(|p| p.exists()).unwrap_or(false)
111 }
112
113 #[test]
114 fn run_isolates_cargo_target_dir_from_workspace() {
115 let temp = GuardedPath::tempdir().unwrap();
116 let root = guard_root(&temp);
117
118 #[allow(clippy::disallowed_macros)]
119 let cmd = if cfg!(windows) {
120 "echo %CARGO_TARGET_DIR% > seen.txt"
121 } else {
122 "printf %s \"$CARGO_TARGET_DIR\" > seen.txt"
123 };
124
125 let steps = vec![Step {
126 guard: None,
127 kind: StepKind::Run(cmd.to_string().into()),
128 scope_enter: 0,
129 scope_exit: 0,
130 }];
131
132 run_steps(&root, &steps).unwrap();
133
134 let seen = read_trimmed(&root.join("seen.txt").unwrap());
135 let legacy = root.join(".cargo-target").unwrap();
136
137 assert_ne!(
138 seen.trim(),
139 legacy.display().to_string(),
140 "CARGO_TARGET_DIR must not target the workspace tree"
141 );
142 assert!(
143 seen.trim().contains("oxdock-cargo-"),
144 "CARGO_TARGET_DIR must point at the isolated scratch location, got {seen:?}"
145 );
146 }
147
148 #[test]
149 fn lazy_snapshot_run_materializes_but_local_run_does_not() {
150 let temp = GuardedPath::tempdir().unwrap();
151 let root = guard_root(&temp);
152
153 let steps = parse_script("RUN echo hi").unwrap();
155 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
156 assert!(
157 output.snapshot.is_materialized(),
158 "snapshot-rooted RUN must materialize the snapshot"
159 );
160
161 let steps = parse_script("WORKSPACE LOCAL\nRUN echo hi").unwrap();
163 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
164 assert!(
165 !output.snapshot.is_materialized(),
166 "LOCAL-rooted RUN must never materialize the snapshot"
167 );
168 }
169
170 #[test]
171 fn lazy_empty_run_creates_nothing() {
172 let temp = GuardedPath::tempdir().unwrap();
173 let root = guard_root(&temp);
174
175 let output = run_steps_with_lazy_snapshot(&root, &[], ExecIo::new()).unwrap();
176 assert!(!output.snapshot.is_materialized());
177 assert!(output.snapshot.get().is_none());
178 }
179
180 #[test]
181 fn lazy_local_echo_assert_failure_never_materializes_snapshot() {
182 let temp = GuardedPath::tempdir().unwrap();
183 let root = guard_root(&temp);
184
185 let script = indoc!(
186 r#"
187 WORKSPACE LOCAL
188 ECHO playground pid is 41067
189 ASSERT_CONTAINS stdout "playground pid i!!"
190 "#
191 );
192 let steps = parse_script(script).unwrap();
193
194 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
198 resolver.set_workspace_root(root.clone());
199 let snapshot = resolver.snapshot_handle();
200 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
201 let result = run_steps_with_manager(
202 fs,
203 &steps,
204 oxdock_process::default_process_manager(),
205 ExecIo::new(),
206 );
207 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
208 let err = result.err().unwrap();
209 assert!(!snapshot.is_materialized());
210 assert!(snapshot.get().is_none());
211
212 let enriched = enrich_lazy_error(&snapshot, &root, err);
213 let msg = enriched.to_string();
214 assert!(
215 msg.contains("did not contain 'playground pid i!!'"),
216 "{msg}"
217 );
218 assert!(msg.contains("never materialized"), "{msg}");
219 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
220 }
221
222 #[test]
223 fn lazy_local_echo_assert_success_leaves_snapshot_pending() {
224 let temp = GuardedPath::tempdir().unwrap();
225 let root = guard_root(&temp);
226
227 let script = indoc!(
228 r#"
229 WORKSPACE LOCAL
230 ECHO playground pid is 41067
231 ASSERT_CONTAINS stdout "playground pid is 41067"
232 "#
233 );
234 let steps = parse_script(script).unwrap();
235
236 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
237 assert!(!output.snapshot.is_materialized());
238 assert!(output.snapshot.get().is_none());
239 }
240
241 #[test]
242 fn lazy_default_mode_echo_assert_failure_keeps_snapshot_pending() {
243 let temp = GuardedPath::tempdir().unwrap();
244 let root = guard_root(&temp);
245
246 let script = indoc!(
250 r#"
251 ECHO playground pid is 41067
252 ASSERT_CONTAINS stdout "playground pid i!!"
253 "#
254 );
255 let steps = parse_script(script).unwrap();
256
257 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
261 resolver.set_workspace_root(root.clone());
262 let snapshot = resolver.snapshot_handle();
263 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
264 let result = run_steps_with_manager(
265 fs,
266 &steps,
267 oxdock_process::default_process_manager(),
268 ExecIo::new(),
269 );
270 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
271 let err = result.err().unwrap();
272 assert!(!snapshot.is_materialized());
273 assert!(snapshot.get().is_none());
274
275 let enriched = enrich_lazy_error(&snapshot, &root, err);
276 let msg = enriched.to_string();
277 assert!(
278 msg.contains("did not contain 'playground pid i!!'"),
279 "{msg}"
280 );
281 assert!(msg.contains("never materialized"), "{msg}");
282 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
283 }
284
285 #[test]
286 fn lazy_default_mode_write_materializes_snapshot() {
287 let temp = GuardedPath::tempdir().unwrap();
288 let root = guard_root(&temp);
289
290 let steps = parse_script("WRITE \"snap.txt\" \"snap\"").unwrap();
293
294 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
295 assert!(output.snapshot.is_materialized());
296 let snapshot_root = output.snapshot.get().expect("snapshot must be published");
297 assert!(
298 exists(snapshot_root, "snap.txt"),
299 "WRITE must land inside the materialized snapshot"
300 );
301 }
302
303 #[test]
304 fn guard_skips_when_env_missing() {
305 let temp = GuardedPath::tempdir().unwrap();
306 let root = guard_root(&temp);
307
308 let guard_var = "OXDOCK_GUARD_TEST_TOKEN_UNSET";
309 let script = format!(
310 indoc!(
311 r#"
312 [env:{guard}] WRITE "skipped.txt" "hi"
313 WRITE "kept.txt" "ok"
314 "#
315 ),
316 guard = guard_var
317 );
318 let steps = crate::parse_script(&script).unwrap();
319
320 run_steps(&root, &steps).unwrap();
321
322 assert!(
323 !exists(&root, "skipped.txt"),
324 "guarded WRITE should be skipped"
325 );
326 assert!(exists(&root, "kept.txt"), "unguarded WRITE should run");
327 }
328
329 #[test]
330 fn guard_sees_env_set_by_env_step() {
331 let temp = GuardedPath::tempdir().unwrap();
332 let root = guard_root(&temp);
333
334 let script = indoc!(
335 r#"
336 ENV FOO="1"
337 [env:FOO] WRITE "hit.txt" "yes"
338 WRITE "always.txt" "ok"
339 "#
340 );
341 let steps = crate::parse_script(script).unwrap();
342
343 run_steps(&root, &steps).unwrap();
344
345 assert!(
346 exists(&root, "hit.txt"),
347 "guarded WRITE should run after ENV sets variable"
348 );
349 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
350 }
351
352 #[test]
353 fn echo_runs_and_allows_subsequent_steps() {
354 let temp = GuardedPath::tempdir().unwrap();
355 let root = guard_root(&temp);
356
357 let script = indoc!(
358 r#"
359 ECHO "Hello, world"
360 WRITE "always.txt" "ok"
361 "#
362 );
363 let steps = crate::parse_script(script).unwrap();
364
365 run_steps(&root, &steps).unwrap();
366
367 assert!(exists(&root, "always.txt"), "WRITE after ECHO should run");
368 }
369
370 #[test]
371 fn guard_on_previous_line_applies_to_next_command() {
372 let temp = GuardedPath::tempdir().unwrap();
373 let root = guard_root(&temp);
374
375 let script = indoc!(
376 r#"
377 ENV FOO="1"
378 [env:FOO]
379 WRITE "hit.txt" "yes"
380 WRITE "always.txt" "ok"
381 "#
382 );
383 let steps = crate::parse_script(script).unwrap();
384
385 run_steps(&root, &steps).unwrap();
386
387 assert!(
388 exists(&root, "hit.txt"),
389 "guarded WRITE on next line should run"
390 );
391 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
392 }
393
394 #[test]
395 fn guard_respects_platform_negation() {
396 let temp = GuardedPath::tempdir().unwrap();
397 let root = guard_root(&temp);
398
399 let script = indoc!(
400 r#"
401 [not(unix)] WRITE "platform.txt" "hi"
402 WRITE "always.txt" "ok"
403 "#
404 );
405 let steps = crate::parse_script(script).unwrap();
406
407 run_steps(&root, &steps).unwrap();
408
409 #[allow(clippy::disallowed_macros)]
410 let expect_skipped = cfg!(unix);
411 assert_eq!(
412 exists(&root, "platform.txt"),
413 !expect_skipped,
414 "platform guard should skip on unix and run elsewhere"
415 );
416 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
417 }
418
419 #[test]
420 fn guard_block_env_scope_restores_after_exit() {
421 let temp = GuardedPath::tempdir().unwrap();
422 let root = guard_root(&temp);
423
424 let script = indoc!(
425 r#"
426 ENV RUN="1"
427 [env:RUN] {
428 ENV INNER="1"
429 WRITE "scoped.txt" "hit"
430 }
431 [env:INNER] WRITE "leak.txt" "nope"
432 "#
433 );
434 let steps = crate::parse_script(script).unwrap();
435
436 run_steps(&root, &steps).unwrap();
437
438 assert!(exists(&root, "scoped.txt"), "block should run");
439 assert!(
440 !exists(&root, "leak.txt"),
441 "env set inside block must not leak outward"
442 );
443 }
444
445 #[test]
446 fn guard_block_workdir_scope_restores_after_exit() {
447 let temp = GuardedPath::tempdir().unwrap();
448 let root = guard_root(&temp);
449
450 let script = indoc!(
451 r#"
452 MKDIR "nested"
453 ENV RUN="1"
454 [env:RUN] {
455 WORKDIR "nested"
456 WRITE "inside.txt" "ok"
457 }
458 WRITE "outside.txt" "root"
459 "#
460 );
461 let steps = crate::parse_script(script).unwrap();
462
463 run_steps(&root, &steps).unwrap();
464
465 assert!(
466 exists(&root, "nested/inside.txt"),
467 "inside write should land in nested dir"
468 );
469 assert!(
470 exists(&root, "outside.txt"),
471 "workdir should reset after block exits"
472 );
473 assert!(
474 !exists(&root, "nested/outside.txt"),
475 "writes after block should not stay scoped"
476 );
477 }
478
479 #[test]
480 fn workspace_scope_restores_after_guard_block() {
481 let snapshot = GuardedPath::tempdir().unwrap();
482 let local = GuardedPath::tempdir().unwrap();
483 let snapshot_root = guard_root(&snapshot);
484 let local_root = guard_root(&local);
485
486 let script = indoc!(
487 r#"
488 ENV RUN="1"
489 [env:RUN] {
490 WORKSPACE LOCAL
491 WRITE "local_only.txt" "inside"
492 }
493 WRITE "snapshot_only.txt" "outside"
494 "#
495 );
496 let steps = crate::parse_script(script).unwrap();
497
498 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
499
500 assert!(
501 local_root.join("local_only.txt").unwrap().exists(),
502 "workspace switch inside block should affect local root"
503 );
504 assert!(
505 snapshot_root.join("snapshot_only.txt").unwrap().exists(),
506 "writes after block must target snapshot again"
507 );
508 assert!(
509 !local_root.join("snapshot_only.txt").unwrap().exists(),
510 "workspace should reset after guard block exits"
511 );
512 }
513
514 #[test]
515 fn guard_matches_profile_env() {
516 let temp = GuardedPath::tempdir().unwrap();
518 let root = guard_root(&temp);
519
520 let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
521 let script = format!(
522 indoc!(
523 r#"
524 ENV PROFILE={0}
525 [eq(env:PROFILE, {0})] WRITE "hit.txt" "yes"
526 [ne(env:PROFILE, {0})] WRITE "miss.txt" "no"
527 "#
528 ),
529 profile
530 );
531
532 let steps = crate::parse_script(&script).unwrap();
533 run_steps(&root, &steps).unwrap();
534
535 assert!(
536 exists(&root, "hit.txt"),
537 "PROFILE-matching guard should run"
538 );
539 assert!(
540 !exists(&root, "miss.txt"),
541 "PROFILE inequality guard should skip for current profile"
542 );
543 }
544
545 #[test]
546 fn multiple_guards_all_must_pass() {
547 let temp = GuardedPath::tempdir().unwrap();
548 let root = guard_root(&temp);
549
550 let key = "OXDOCK_MULTI_GUARD_TEST_PASS";
551
552 let script = format!(
553 indoc!(
554 r#"
555 ENV {k}=ok
556 [env:{k},eq(env:{k}, ok)] WRITE "hit.txt" "yes"
557 WRITE "always.txt" "ok"
558 "#
559 ),
560 k = key
561 );
562 let steps = crate::parse_script(&script).unwrap();
563 run_steps(&root, &steps).unwrap();
564
565 assert!(
566 exists(&root, "hit.txt"),
567 "guarded step should run when all guards pass"
568 );
569 assert!(exists(&root, "always.txt"), "unguarded step should run");
570 }
571
572 #[test]
573 fn multiple_guards_skip_when_one_fails() {
574 let temp = GuardedPath::tempdir().unwrap();
575 let root = guard_root(&temp);
576
577 let key = "OXDOCK_MULTI_GUARD_TEST_FAIL";
578
579 let script = format!(
580 indoc!(
581 r#"
582 ENV {k}=ok
583 [env:{k},ne(env:{k}, ok)] WRITE "miss.txt" "yes"
584 WRITE "always.txt" "ok"
585 "#
586 ),
587 k = key
588 );
589 let steps = crate::parse_script(&script).unwrap();
590 run_steps(&root, &steps).unwrap();
591
592 assert!(
593 !exists(&root, "miss.txt"),
594 "guarded step should skip when any guard fails"
595 );
596 assert!(exists(&root, "always.txt"), "unguarded step should run");
597 }
598
599 #[cfg(unix)]
600 #[cfg_attr(
601 miri,
602 ignore = "stdout streaming not supported for background command under miri"
603 )]
604 #[test]
605 fn async_exits_success_and_stops_pipeline() {
606 let temp = GuardedPath::tempdir().unwrap();
607 let root = guard_root(&temp);
608
609 let script = "ASYNC RUN \"sh -c 'sleep 0.05'\"";
611 let steps = crate::parse_script(script).unwrap();
612 let res = run_steps(&root, &steps);
613 assert!(res.is_ok(), "ASYNC success should allow clean exit");
614 }
615
616 #[cfg(unix)]
617 #[cfg_attr(
618 miri,
619 ignore = "stdout streaming not supported for background command under miri"
620 )]
621 #[test]
622 fn async_failure_bubbles_status() {
623 let temp = GuardedPath::tempdir().unwrap();
624 let root = guard_root(&temp);
625
626 let script = "ASYNC RUN \"sh -c 'sleep 0.05; exit 7'\"";
627 let steps = crate::parse_script(script).unwrap();
628 let err = run_steps(&root, &steps).unwrap_err();
629 let msg = err.to_string();
630 assert!(
631 msg.contains("ASYNC process exited with status") || msg.contains("exit status: 7"),
632 "should surface failing ASYNC exit code"
633 );
634 }
635
636 #[cfg(unix)]
637 #[cfg_attr(
638 miri,
639 ignore = "timing-sensitive background process test is unreliable under Miri"
640 )]
641 #[test]
642 fn async_multiple_stops_on_first_exit_and_does_not_block_steps() {
643 let temp = GuardedPath::tempdir().unwrap();
644 let root = guard_root(&temp);
645
646 let script = indoc! {
647 r#"
648 ASYNC RUN "sh -c 'sleep 0.2; echo one > one.txt'"
649 ASYNC RUN "sh -c 'sleep 0.5; echo two > two.txt'"
650 WRITE "done.txt" "ok"
651 "#
652 };
653
654 let steps = crate::parse_script(script).unwrap();
655 let start = Instant::now();
656 let res = run_steps(&root, &steps);
657 let elapsed = start.elapsed();
658
659 assert!(res.is_ok(), "ASYNC success should allow clean exit");
660 assert!(
661 exists(&root, "done.txt"),
662 "foreground step should run after spawning backgrounds"
663 );
664 assert!(
665 exists(&root, "one.txt"),
666 "first background should finish and emit output"
667 );
668 assert!(
671 exists(&root, "two.txt"),
672 "second background should finish (poll-all waits for all)"
673 );
674
675 let upper = 0.8;
676 assert!(
677 elapsed.as_secs_f32() < upper && elapsed.as_secs_f32() > 0.15,
678 "should wait for both backgrounds (~0.5s); got {elapsed:?}"
679 );
680 }
681
682 #[cfg(unix)]
683 #[cfg_attr(
684 miri,
685 ignore = "timing-sensitive background process test is unreliable under Miri"
686 )]
687 #[test]
688 fn background_killed_when_unrelated_step_fails() {
689 let temp = GuardedPath::tempdir().unwrap();
690 let root = guard_root(&temp);
691
692 let script = indoc! {
693 r#"
694 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
695 RUN "__oxdock_missing_command_xyz__"
696 "#
697 };
698
699 let steps = crate::parse_script(script).unwrap();
700 assert!(
701 run_steps(&root, &steps).is_err(),
702 "pipeline should fail on the missing command"
703 );
704 assert!(
708 !exists(&root, "late.txt"),
709 "abandoned background writer must be killed by Drop teardown"
710 );
711 }
712
713 #[cfg(unix)]
714 #[cfg_attr(
715 miri,
716 ignore = "stdout streaming not supported for background command under miri"
717 )]
718 #[test]
719 fn exit_terminates_backgrounds_and_returns_code() {
720 let temp = GuardedPath::tempdir().unwrap();
721 let root = guard_root(&temp);
722
723 let script = indoc! {
724 r#"
725 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
726 EXIT 5
727 "#
728 };
729
730 let steps = crate::parse_script(script).unwrap();
731 let err = run_steps(&root, &steps).unwrap_err();
732 let msg = err.to_string();
733 assert!(msg.contains("EXIT requested with code 5"));
734 assert!(
735 !exists(&root, "late.txt"),
736 "background process should be killed when EXIT is hit"
737 );
738 }
739
740 #[test]
741 #[cfg_attr(
742 miri,
743 ignore = "EXIT joins real background threads; timing-sensitive under Miri"
744 )]
745 fn exit_inside_nested_block_reports_code_and_kills_backgrounds() {
746 let temp = GuardedPath::tempdir().unwrap();
747 let root = guard_root(&temp);
748
749 let script = indoc! {
750 r#"
751 ASYNC {
752 SLEEP 30s
753 }
754 [bool:true] {
755 EXIT 5
756 }
757 "#
758 };
759
760 let steps = crate::parse_script(script).unwrap();
761 let start = std::time::Instant::now();
762 let err = run_steps(&root, &steps).unwrap_err();
763 assert!(
764 err.to_string().contains("EXIT requested with code 5"),
765 "nested EXIT must report its code: {err}"
766 );
767 assert!(
768 start.elapsed() < std::time::Duration::from_secs(10),
769 "nested EXIT must kill the background SLEEP promptly"
770 );
771 }
772
773 #[cfg_attr(
774 miri,
775 ignore = "stdout streaming not supported for background command under miri"
776 )]
777 #[test]
778 fn env_applies_to_run_and_background() {
779 let temp = GuardedPath::tempdir().unwrap();
780 let root = guard_root(&temp);
781
782 #[allow(clippy::disallowed_macros)]
783 let script = if cfg!(windows) {
784 indoc! {
785 r#"
786 ENV FOO="bar"
787 RUN "echo %FOO% > run.txt"
788 ASYNC RUN "echo %FOO% > bg.txt"
789 "#
790 }
791 } else {
792 indoc! {
793 r#"
794 ENV FOO="bar"
795 RUN "sh -c 'printf %s \"$FOO\" > run.txt'"
796 ASYNC RUN "sh -c 'printf %s \"$FOO\" > bg.txt'"
797 "#
798 }
799 };
800
801 let steps = crate::parse_script(script).unwrap();
802 run_steps(&root, &steps).unwrap();
803
804 assert_eq!(read_trimmed(&root.join("run.txt").unwrap()), "bar");
805 assert_eq!(read_trimmed(&root.join("bg.txt").unwrap()), "bar");
806 }
807
808 #[test]
809 fn workspace_switches_between_snapshot_and_local() {
810 let snapshot = GuardedPath::tempdir().unwrap();
811 let local = GuardedPath::tempdir().unwrap();
812 let snapshot_root = guard_root(&snapshot);
813 let local_root = guard_root(&local);
814
815 let script = indoc! {
816 r#"
817 WRITE "snap.txt" "snap"
818 WORKSPACE LOCAL
819 WRITE "local.txt" "local"
820 WORKSPACE SNAPSHOT
821 WRITE "snap2.txt" "again"
822 "#
823 };
824
825 let steps = crate::parse_script(script).unwrap();
826 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
827
828 assert!(snapshot_root.join("snap.txt").unwrap().exists());
829 assert!(snapshot_root.join("snap2.txt").unwrap().exists());
830 assert!(local_root.join("local.txt").unwrap().exists());
831 }
832
833 #[test]
834 fn workspace_root_changes_where_slash_points() {
835 let snapshot = GuardedPath::tempdir().unwrap();
836 let local = GuardedPath::tempdir().unwrap();
837 let snapshot_root = guard_root(&snapshot);
838 let local_root = guard_root(&local);
839 let local_client = local_root.join("client").unwrap();
840 create_dirs(&local_client);
841
842 let script = indoc! {
843 r#"
844 WORKSPACE LOCAL
845 WORKDIR "/"
846 WRITE "localroot.txt" "one"
847 WORKDIR "client"
848 WRITE "client.txt" "two"
849 WORKSPACE SNAPSHOT
850 WORKDIR "/"
851 WRITE "snaproot.txt" "three"
852 "#
853 };
854
855 let steps = crate::parse_script(script).unwrap();
856 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
857
858 assert!(local_root.join("localroot.txt").unwrap().exists());
859 assert!(local_client.join("client.txt").unwrap().exists());
860 assert!(snapshot_root.join("snaproot.txt").unwrap().exists());
861 }
862}