Skip to main content

oxdock_core/
lib.rs

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