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::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
52/// Parse a script using the production `lower_command` dispatcher.
53/// The typed `ParseError` converts into `anyhow::Error` at this boundary
54/// with no intermediate `.context()` wrapping, so the message survives.
55/// Builtin function names seed the reserved set, so `FUNC` shadowing a
56/// native fails here; hosts unknown at parse time fall back to the runtime
57/// `define_func` guard.
58pub fn parse_script(input: &str) -> anyhow::Result<Vec<oxdock_parser::Step>> {
59    parse_script_with_modules(input, std_module_table())
60}
61
62/// Parse with a module provenance table so calls resolve statically:
63/// qualified `MODULE::NAME` checks membership, bare `NAME` resolves through
64/// `SCRIPT` definitions and `IMPORT`ed modules. Reserved covers builtins
65/// plus every table module, so `FUNC` shadowing any of them fails here;
66/// hosts unknown at parse time fall back to the runtime `define_func`
67/// guard.
68pub 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        // Snapshot-rooted RUN executes against the snapshot workdir.
154        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        // LOCAL-rooted RUN executes against the live tree.
162        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        // Retain the snapshot handle across the failing run: the lazy
195        // convenience wrapper discards it on error, so drive the manager
196        // directly to pin the unmaterialized invariant (issue #131).
197        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        // Same conditions as the LOCAL failure test, minus the WORKSPACE
247        // statement: the implicit default is snapshot mode, but the
248        // non-mutating steps must still leave it pending (issue #131).
249        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        // Retain the snapshot handle across the failing run: the lazy
258        // convenience wrapper discards it on error, so drive the manager
259        // directly to pin the unmaterialized invariant (issue #131).
260        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        // No WORKSPACE statement: a snapshot-targeted WRITE must transition
291        // the lazy handle to materialized (issue #131).
292        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        // Set PROFILE via script ENV; guards now only see script-level env.
517        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        // Background succeeds quickly; pipeline should complete without error.
610        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        // With the new poll-all model, both children run to completion.
669        // The second background (~0.5s) should also finish.
670        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        // The ASYNC handle is dropped mid-pipeline when the error propagates;
705        // its Drop safety net must kill the writer before it can emit the
706        // late artifact.
707        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}