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