Skip to main content

oxdock_core/
lib.rs

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