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::{
87        GuardedPath, GuardedTempDir, PathResolver, env as oxdock_env, to_forward_slashes,
88    };
89    use oxdock_parser::{Step, StepKind};
90    #[cfg(unix)]
91    use std::time::Instant;
92
93    fn guard_root(temp: &GuardedTempDir) -> GuardedPath {
94        temp.as_guarded_path().clone()
95    }
96
97    fn read_trimmed(path: &GuardedPath) -> String {
98        let resolver = PathResolver::new(path.root(), path.root()).unwrap();
99        resolver
100            .read_to_string(path)
101            .unwrap_or_default()
102            .trim()
103            .to_string()
104    }
105
106    fn create_dirs(path: &GuardedPath) {
107        let resolver = PathResolver::new(path.root(), path.root()).unwrap();
108        resolver.create_dir_all(path).unwrap();
109    }
110
111    fn exists(root: &GuardedPath, rel: &str) -> bool {
112        root.join(rel).map(|p| p.exists()).unwrap_or(false)
113    }
114
115    #[test]
116    fn run_isolates_cargo_target_dir_from_workspace() {
117        let temp = GuardedPath::tempdir().unwrap();
118        let root = guard_root(&temp);
119
120        #[allow(clippy::disallowed_macros)]
121        let cmd = if cfg!(windows) {
122            "echo %CARGO_TARGET_DIR% > seen.txt"
123        } else {
124            "printf %s \"$CARGO_TARGET_DIR\" > seen.txt"
125        };
126
127        let steps = vec![Step {
128            guard: None,
129            kind: StepKind::Run(cmd.to_string().into()),
130            scope_enter: 0,
131            scope_exit: 0,
132        }];
133
134        run_steps(&root, &steps).unwrap();
135
136        let seen = read_trimmed(&root.join("seen.txt").unwrap());
137        let legacy = root.join(".cargo-target").unwrap();
138
139        assert_ne!(
140            seen.trim(),
141            legacy.display().to_string(),
142            "CARGO_TARGET_DIR must not target the workspace tree"
143        );
144        assert!(
145            seen.trim().contains("oxdock-cargo-"),
146            "CARGO_TARGET_DIR must point at the isolated scratch location, got {seen:?}"
147        );
148    }
149
150    #[test]
151    fn lazy_snapshot_run_materializes_but_local_run_does_not() {
152        let temp = GuardedPath::tempdir().unwrap();
153        let root = guard_root(&temp);
154
155        // Snapshot-rooted RUN executes against the snapshot workdir.
156        let steps = parse_script("RUN echo hi").unwrap();
157        let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
158        assert!(
159            output.snapshot.is_materialized(),
160            "snapshot-rooted RUN must materialize the snapshot"
161        );
162
163        // LOCAL-rooted RUN executes against the live tree.
164        let steps = parse_script(indoc! {r#"
165            WORKSPACE LOCAL
166            RUN echo hi
167        "#})
168        .unwrap();
169        let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
170        assert!(
171            !output.snapshot.is_materialized(),
172            "LOCAL-rooted RUN must never materialize the snapshot"
173        );
174    }
175
176    #[test]
177    fn lazy_empty_run_creates_nothing() {
178        let temp = GuardedPath::tempdir().unwrap();
179        let root = guard_root(&temp);
180
181        let output = run_steps_with_lazy_snapshot(&root, &[], ExecIo::new()).unwrap();
182        assert!(!output.snapshot.is_materialized());
183        assert!(output.snapshot.get().is_none());
184    }
185
186    #[test]
187    fn lazy_local_echo_assert_failure_never_materializes_snapshot() {
188        let temp = GuardedPath::tempdir().unwrap();
189        let root = guard_root(&temp);
190
191        let script = indoc!(
192            r#"
193            WORKSPACE LOCAL
194            ECHO playground pid is 41067
195            ASSERT_CONTAINS stdout "playground pid i!!"
196            "#
197        );
198        let steps = parse_script(script).unwrap();
199
200        // Retain the snapshot handle across the failing run: the lazy
201        // convenience wrapper discards it on error, so drive the manager
202        // directly to pin the unmaterialized invariant (issue #131).
203        let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
204        resolver.set_workspace_root(root.clone());
205        let snapshot = resolver.snapshot_handle();
206        let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
207        let result = run_steps_with_manager(
208            fs,
209            &steps,
210            oxdock_process::default_process_manager(),
211            ExecIo::new(),
212        );
213        assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
214        let err = result.err().unwrap();
215        assert!(!snapshot.is_materialized());
216        assert!(snapshot.get().is_none());
217
218        let enriched = enrich_lazy_error(&snapshot, &root, err);
219        let msg = enriched.to_string();
220        assert!(
221            msg.contains("did not contain 'playground pid i!!'"),
222            "{msg}"
223        );
224        assert!(msg.contains("never materialized"), "{msg}");
225        assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
226    }
227
228    #[test]
229    fn lazy_local_echo_assert_success_leaves_snapshot_pending() {
230        let temp = GuardedPath::tempdir().unwrap();
231        let root = guard_root(&temp);
232
233        let script = indoc!(
234            r#"
235            WORKSPACE LOCAL
236            ECHO playground pid is 41067
237            ASSERT_CONTAINS stdout "playground pid is 41067"
238            "#
239        );
240        let steps = parse_script(script).unwrap();
241
242        let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
243        assert!(!output.snapshot.is_materialized());
244        assert!(output.snapshot.get().is_none());
245    }
246
247    #[test]
248    fn lazy_default_mode_echo_assert_failure_keeps_snapshot_pending() {
249        let temp = GuardedPath::tempdir().unwrap();
250        let root = guard_root(&temp);
251
252        // Same conditions as the LOCAL failure test, minus the WORKSPACE
253        // statement: the implicit default is snapshot mode, but the
254        // non-mutating steps must still leave it pending (issue #131).
255        let script = indoc!(
256            r#"
257            ECHO playground pid is 41067
258            ASSERT_CONTAINS stdout "playground pid i!!"
259            "#
260        );
261        let steps = parse_script(script).unwrap();
262
263        // Retain the snapshot handle across the failing run: the lazy
264        // convenience wrapper discards it on error, so drive the manager
265        // directly to pin the unmaterialized invariant (issue #131).
266        let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
267        resolver.set_workspace_root(root.clone());
268        let snapshot = resolver.snapshot_handle();
269        let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
270        let result = run_steps_with_manager(
271            fs,
272            &steps,
273            oxdock_process::default_process_manager(),
274            ExecIo::new(),
275        );
276        assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
277        let err = result.err().unwrap();
278        assert!(!snapshot.is_materialized());
279        assert!(snapshot.get().is_none());
280
281        let enriched = enrich_lazy_error(&snapshot, &root, err);
282        let msg = enriched.to_string();
283        assert!(
284            msg.contains("did not contain 'playground pid i!!'"),
285            "{msg}"
286        );
287        assert!(msg.contains("never materialized"), "{msg}");
288        assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
289    }
290
291    #[test]
292    fn lazy_default_mode_write_materializes_snapshot() {
293        let temp = GuardedPath::tempdir().unwrap();
294        let root = guard_root(&temp);
295
296        // No WORKSPACE statement: a snapshot-targeted WRITE must transition
297        // the lazy handle to materialized (issue #131).
298        let steps = parse_script("WRITE \"snap.txt\" \"snap\"").unwrap();
299
300        let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
301        assert!(output.snapshot.is_materialized());
302        let snapshot_root = output.snapshot.get().expect("snapshot must be published");
303        assert!(
304            exists(snapshot_root, "snap.txt"),
305            "WRITE must land inside the materialized snapshot"
306        );
307    }
308
309    #[test]
310    fn guard_skips_when_env_missing() {
311        let temp = GuardedPath::tempdir().unwrap();
312        let root = guard_root(&temp);
313
314        let guard_var = oxdock_env::GUARD_TEST_TOKEN_UNSET;
315        let script = format!(
316            indoc!(
317                r#"
318                [env:{guard}] WRITE "skipped.txt" "hi"
319                WRITE "kept.txt" "ok"
320                "#
321            ),
322            guard = guard_var
323        );
324        let steps = crate::parse_script(&script).unwrap();
325
326        run_steps(&root, &steps).unwrap();
327
328        assert!(
329            !exists(&root, "skipped.txt"),
330            "guarded WRITE should be skipped"
331        );
332        assert!(exists(&root, "kept.txt"), "unguarded WRITE should run");
333    }
334
335    #[test]
336    fn guard_sees_env_set_by_env_step() {
337        let temp = GuardedPath::tempdir().unwrap();
338        let root = guard_root(&temp);
339
340        let script = indoc!(
341            r#"
342            ENV FOO="1"
343            [env:FOO] WRITE "hit.txt" "yes"
344            WRITE "always.txt" "ok"
345            "#
346        );
347        let steps = crate::parse_script(script).unwrap();
348
349        run_steps(&root, &steps).unwrap();
350
351        assert!(
352            exists(&root, "hit.txt"),
353            "guarded WRITE should run after ENV sets variable"
354        );
355        assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
356    }
357
358    #[test]
359    fn echo_runs_and_allows_subsequent_steps() {
360        let temp = GuardedPath::tempdir().unwrap();
361        let root = guard_root(&temp);
362
363        let script = indoc!(
364            r#"
365            ECHO "Hello, world"
366            WRITE "always.txt" "ok"
367            "#
368        );
369        let steps = crate::parse_script(script).unwrap();
370
371        run_steps(&root, &steps).unwrap();
372
373        assert!(exists(&root, "always.txt"), "WRITE after ECHO should run");
374    }
375
376    #[test]
377    fn guard_on_previous_line_applies_to_next_command() {
378        let temp = GuardedPath::tempdir().unwrap();
379        let root = guard_root(&temp);
380
381        let script = indoc!(
382            r#"
383            ENV FOO="1"
384            [env:FOO]
385            WRITE "hit.txt" "yes"
386            WRITE "always.txt" "ok"
387            "#
388        );
389        let steps = crate::parse_script(script).unwrap();
390
391        run_steps(&root, &steps).unwrap();
392
393        assert!(
394            exists(&root, "hit.txt"),
395            "guarded WRITE on next line should run"
396        );
397        assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
398    }
399
400    #[test]
401    fn guard_respects_platform_negation() {
402        let temp = GuardedPath::tempdir().unwrap();
403        let root = guard_root(&temp);
404
405        let script = indoc!(
406            r#"
407            [not(unix)] WRITE "platform.txt" "hi"
408            WRITE "always.txt" "ok"
409            "#
410        );
411        let steps = crate::parse_script(script).unwrap();
412
413        run_steps(&root, &steps).unwrap();
414
415        #[allow(clippy::disallowed_macros)]
416        let expect_skipped = cfg!(unix);
417        assert_eq!(
418            exists(&root, "platform.txt"),
419            !expect_skipped,
420            "platform guard should skip on unix and run elsewhere"
421        );
422        assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
423    }
424
425    #[test]
426    fn guard_block_env_scope_restores_after_exit() {
427        let temp = GuardedPath::tempdir().unwrap();
428        let root = guard_root(&temp);
429
430        let script = indoc!(
431            r#"
432            ENV RUN="1"
433            [env:RUN] {
434                ENV INNER="1"
435                WRITE "scoped.txt" "hit"
436            }
437            [env:INNER] WRITE "leak.txt" "nope"
438            "#
439        );
440        let steps = crate::parse_script(script).unwrap();
441
442        run_steps(&root, &steps).unwrap();
443
444        assert!(exists(&root, "scoped.txt"), "block should run");
445        assert!(
446            !exists(&root, "leak.txt"),
447            "env set inside block must not leak outward"
448        );
449    }
450
451    #[test]
452    fn guard_block_workdir_scope_restores_after_exit() {
453        let temp = GuardedPath::tempdir().unwrap();
454        let root = guard_root(&temp);
455
456        let script = indoc!(
457            r#"
458            MKDIR "nested"
459            ENV RUN="1"
460            [env:RUN] {
461                WORKDIR "nested"
462                WRITE "inside.txt" "ok"
463            }
464            WRITE "outside.txt" "root"
465            "#
466        );
467        let steps = crate::parse_script(script).unwrap();
468
469        run_steps(&root, &steps).unwrap();
470
471        assert!(
472            exists(&root, "nested/inside.txt"),
473            "inside write should land in nested dir"
474        );
475        assert!(
476            exists(&root, "outside.txt"),
477            "workdir should reset after block exits"
478        );
479        assert!(
480            !exists(&root, "nested/outside.txt"),
481            "writes after block should not stay scoped"
482        );
483    }
484
485    #[test]
486    fn workspace_scope_restores_after_guard_block() {
487        let snapshot = GuardedPath::tempdir().unwrap();
488        let local = GuardedPath::tempdir().unwrap();
489        let snapshot_root = guard_root(&snapshot);
490        let local_root = guard_root(&local);
491
492        let script = indoc!(
493            r#"
494            ENV RUN="1"
495            [env:RUN] {
496                WORKSPACE LOCAL
497                WRITE "local_only.txt" "inside"
498            }
499            WRITE "snapshot_only.txt" "outside"
500            "#
501        );
502        let steps = crate::parse_script(script).unwrap();
503
504        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
505
506        assert!(
507            local_root.join("local_only.txt").unwrap().exists(),
508            "workspace switch inside block should affect local root"
509        );
510        assert!(
511            snapshot_root.join("snapshot_only.txt").unwrap().exists(),
512            "writes after block must target snapshot again"
513        );
514        assert!(
515            !local_root.join("snapshot_only.txt").unwrap().exists(),
516            "workspace should reset after guard block exits"
517        );
518    }
519
520    #[test]
521    fn guard_matches_profile_env() {
522        // Set PROFILE via script ENV; guards now only see script-level env.
523        let temp = GuardedPath::tempdir().unwrap();
524        let root = guard_root(&temp);
525
526        let profile = std::env::var(oxdock_env::PROFILE).unwrap_or_else(|_| "debug".to_string());
527        let script = format!(
528            indoc!(
529                r#"
530                ENV PROFILE={0}
531                [eq(env:PROFILE, {0})] WRITE "hit.txt" "yes"
532                [ne(env:PROFILE, {0})] WRITE "miss.txt" "no"
533                "#
534            ),
535            profile
536        );
537
538        let steps = crate::parse_script(&script).unwrap();
539        run_steps(&root, &steps).unwrap();
540
541        assert!(
542            exists(&root, "hit.txt"),
543            "PROFILE-matching guard should run"
544        );
545        assert!(
546            !exists(&root, "miss.txt"),
547            "PROFILE inequality guard should skip for current profile"
548        );
549    }
550
551    #[test]
552    fn multiple_guards_all_must_pass() {
553        let temp = GuardedPath::tempdir().unwrap();
554        let root = guard_root(&temp);
555
556        let key = oxdock_env::MULTI_GUARD_TEST_PASS;
557
558        let script = format!(
559            indoc!(
560                r#"
561                ENV {k}=ok
562                [env:{k},eq(env:{k}, ok)] WRITE "hit.txt" "yes"
563                WRITE "always.txt" "ok"
564                "#
565            ),
566            k = key
567        );
568        let steps = crate::parse_script(&script).unwrap();
569        run_steps(&root, &steps).unwrap();
570
571        assert!(
572            exists(&root, "hit.txt"),
573            "guarded step should run when all guards pass"
574        );
575        assert!(exists(&root, "always.txt"), "unguarded step should run");
576    }
577
578    #[test]
579    fn multiple_guards_skip_when_one_fails() {
580        let temp = GuardedPath::tempdir().unwrap();
581        let root = guard_root(&temp);
582
583        let key = oxdock_env::MULTI_GUARD_TEST_FAIL;
584
585        let script = format!(
586            indoc!(
587                r#"
588                ENV {k}=ok
589                [env:{k},ne(env:{k}, ok)] WRITE "miss.txt" "yes"
590                WRITE "always.txt" "ok"
591                "#
592            ),
593            k = key
594        );
595        let steps = crate::parse_script(&script).unwrap();
596        run_steps(&root, &steps).unwrap();
597
598        assert!(
599            !exists(&root, "miss.txt"),
600            "guarded step should skip when any guard fails"
601        );
602        assert!(exists(&root, "always.txt"), "unguarded step should run");
603    }
604
605    #[cfg(unix)]
606    #[cfg_attr(
607        miri,
608        ignore = "stdout streaming not supported for background command under miri"
609    )]
610    #[test]
611    fn async_exits_success_and_stops_pipeline() {
612        let temp = GuardedPath::tempdir().unwrap();
613        let root = guard_root(&temp);
614
615        // Background succeeds quickly; pipeline should complete without error.
616        let script = "ASYNC RUN \"sh -c 'sleep 0.05'\"";
617        let steps = crate::parse_script(script).unwrap();
618        let res = run_steps(&root, &steps);
619        assert!(res.is_ok(), "ASYNC success should allow clean exit");
620    }
621
622    #[cfg(unix)]
623    #[cfg_attr(
624        miri,
625        ignore = "stdout streaming not supported for background command under miri"
626    )]
627    #[test]
628    fn async_failure_bubbles_status() {
629        let temp = GuardedPath::tempdir().unwrap();
630        let root = guard_root(&temp);
631
632        let script = "ASYNC RUN \"sh -c 'sleep 0.05; exit 7'\"";
633        let steps = crate::parse_script(script).unwrap();
634        let err = run_steps(&root, &steps).unwrap_err();
635        let msg = err.to_string();
636        assert!(
637            msg.contains("ASYNC process exited with status") || msg.contains("exit status: 7"),
638            "should surface failing ASYNC exit code"
639        );
640    }
641
642    #[cfg(unix)]
643    #[cfg_attr(
644        miri,
645        ignore = "timing-sensitive background process test is unreliable under Miri"
646    )]
647    #[test]
648    fn async_multiple_stops_on_first_exit_and_does_not_block_steps() {
649        let temp = GuardedPath::tempdir().unwrap();
650        let root = guard_root(&temp);
651
652        let script = indoc! {
653            r#"
654            ASYNC RUN "sh -c 'sleep 0.2; echo one > one.txt'"
655            ASYNC RUN "sh -c 'sleep 0.5; echo two > two.txt'"
656            WRITE "done.txt" "ok"
657            "#
658        };
659
660        let steps = crate::parse_script(script).unwrap();
661        let start = Instant::now();
662        let res = run_steps(&root, &steps);
663        let elapsed = start.elapsed();
664
665        assert!(res.is_ok(), "ASYNC success should allow clean exit");
666        assert!(
667            exists(&root, "done.txt"),
668            "foreground step should run after spawning backgrounds"
669        );
670        assert!(
671            exists(&root, "one.txt"),
672            "first background should finish and emit output"
673        );
674        // With the new poll-all model, both children run to completion.
675        // The second background (~0.5s) should also finish.
676        assert!(
677            exists(&root, "two.txt"),
678            "second background should finish (poll-all waits for all)"
679        );
680
681        let upper = 0.8;
682        assert!(
683            elapsed.as_secs_f32() < upper && elapsed.as_secs_f32() > 0.15,
684            "should wait for both backgrounds (~0.5s); got {elapsed:?}"
685        );
686    }
687
688    #[cfg(unix)]
689    #[cfg_attr(
690        miri,
691        ignore = "timing-sensitive background process test is unreliable under Miri"
692    )]
693    #[test]
694    fn background_killed_when_unrelated_step_fails() {
695        let temp = GuardedPath::tempdir().unwrap();
696        let root = guard_root(&temp);
697
698        let script = indoc! {
699            r#"
700            ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
701            RUN "__oxdock_missing_command_xyz__"
702            "#
703        };
704
705        let steps = crate::parse_script(script).unwrap();
706        assert!(
707            run_steps(&root, &steps).is_err(),
708            "pipeline should fail on the missing command"
709        );
710        // The ASYNC handle is dropped mid-pipeline when the error propagates;
711        // its Drop safety net must kill the writer before it can emit the
712        // late artifact.
713        assert!(
714            !exists(&root, "late.txt"),
715            "abandoned background writer must be killed by Drop teardown"
716        );
717    }
718
719    #[cfg(unix)]
720    #[cfg_attr(
721        miri,
722        ignore = "stdout streaming not supported for background command under miri"
723    )]
724    #[test]
725    fn exit_terminates_backgrounds_and_returns_code() {
726        let temp = GuardedPath::tempdir().unwrap();
727        let root = guard_root(&temp);
728
729        let script = indoc! {
730            r#"
731            ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
732            EXIT 5
733            "#
734        };
735
736        let steps = crate::parse_script(script).unwrap();
737        let err = run_steps(&root, &steps).unwrap_err();
738        let msg = err.to_string();
739        assert!(msg.contains("EXIT requested with code 5"));
740        assert!(
741            !exists(&root, "late.txt"),
742            "background process should be killed when EXIT is hit"
743        );
744    }
745
746    #[test]
747    #[cfg_attr(
748        miri,
749        ignore = "EXIT joins real background threads; timing-sensitive under Miri"
750    )]
751    fn exit_inside_nested_block_reports_code_and_kills_backgrounds() {
752        let temp = GuardedPath::tempdir().unwrap();
753        let root = guard_root(&temp);
754
755        let script = indoc! {
756            r#"
757            ASYNC {
758                SLEEP 30s
759            }
760            [bool:true] {
761                EXIT 5
762            }
763            "#
764        };
765
766        let steps = crate::parse_script(script).unwrap();
767        let start = std::time::Instant::now();
768        let err = run_steps(&root, &steps).unwrap_err();
769        assert!(
770            err.to_string().contains("EXIT requested with code 5"),
771            "nested EXIT must report its code: {err}"
772        );
773        assert!(
774            start.elapsed() < std::time::Duration::from_secs(10),
775            "nested EXIT must kill the background SLEEP promptly"
776        );
777    }
778
779    #[cfg_attr(
780        miri,
781        ignore = "stdout streaming not supported for background command under miri"
782    )]
783    #[test]
784    fn env_applies_to_run_and_background() {
785        let temp = GuardedPath::tempdir().unwrap();
786        let root = guard_root(&temp);
787
788        #[allow(clippy::disallowed_macros)]
789        let script = if cfg!(windows) {
790            indoc! {
791                r#"
792                ENV FOO="bar"
793                RUN "echo %FOO% > run.txt"
794                ASYNC RUN "echo %FOO% > bg.txt"
795                "#
796            }
797        } else {
798            indoc! {
799                r#"
800                ENV FOO="bar"
801                RUN "sh -c 'printf %s \"$FOO\" > run.txt'"
802                ASYNC RUN "sh -c 'printf %s \"$FOO\" > bg.txt'"
803                "#
804            }
805        };
806
807        let steps = crate::parse_script(script).unwrap();
808        run_steps(&root, &steps).unwrap();
809
810        assert_eq!(read_trimmed(&root.join("run.txt").unwrap()), "bar");
811        assert_eq!(read_trimmed(&root.join("bg.txt").unwrap()), "bar");
812    }
813
814    #[test]
815    fn workspace_switches_between_snapshot_and_local() {
816        let snapshot = GuardedPath::tempdir().unwrap();
817        let local = GuardedPath::tempdir().unwrap();
818        let snapshot_root = guard_root(&snapshot);
819        let local_root = guard_root(&local);
820
821        let script = indoc! {
822            r#"
823            WRITE "snap.txt" "snap"
824            WORKSPACE LOCAL
825            WRITE "local.txt" "local"
826            WORKSPACE SNAPSHOT
827            WRITE "snap2.txt" "again"
828            "#
829        };
830
831        let steps = crate::parse_script(script).unwrap();
832        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
833
834        assert!(snapshot_root.join("snap.txt").unwrap().exists());
835        assert!(snapshot_root.join("snap2.txt").unwrap().exists());
836        assert!(local_root.join("local.txt").unwrap().exists());
837    }
838
839    #[test]
840    fn workspace_root_changes_where_slash_points() {
841        let snapshot = GuardedPath::tempdir().unwrap();
842        let local = GuardedPath::tempdir().unwrap();
843        let snapshot_root = guard_root(&snapshot);
844        let local_root = guard_root(&local);
845        let local_client = local_root.join("client").unwrap();
846        create_dirs(&local_client);
847
848        let script = indoc! {
849            r#"
850            WORKSPACE LOCAL
851            WORKDIR "/"
852            WRITE "localroot.txt" "one"
853            WORKDIR "client"
854            WRITE "client.txt" "two"
855            WORKSPACE SNAPSHOT
856            WORKDIR "/"
857            WRITE "snaproot.txt" "three"
858            "#
859        };
860
861        let steps = crate::parse_script(script).unwrap();
862        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
863
864        assert!(local_root.join("localroot.txt").unwrap().exists());
865        assert!(local_client.join("client.txt").unwrap().exists());
866        assert!(snapshot_root.join("snaproot.txt").unwrap().exists());
867    }
868
869    /// Serialized cache-dir pin for hermetic tests. `OXDOCK_CACHE_DIR` is
870    /// process-global; the lock plus drop-restore keeps parallel tests
871    /// isolated (mirrors `SerialCargoEnv` in `oxdock-process`).
872    struct SerialCacheDir {
873        _lock: std::sync::MutexGuard<'static, ()>,
874        prev: Option<String>,
875    }
876
877    static CACHE_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
878
879    impl SerialCacheDir {
880        fn pin(dir: &GuardedPath) -> Self {
881            let lock = CACHE_DIR_LOCK
882                .lock()
883                .unwrap_or_else(|poisoned| poisoned.into_inner());
884            let prev = std::env::var(oxdock_env::CACHE_DIR).ok();
885            // SAFETY: serialized by `CACHE_DIR_LOCK`, restored on drop.
886            unsafe {
887                std::env::set_var(
888                    oxdock_env::CACHE_DIR,
889                    dir.as_path().to_string_lossy().into_owned(),
890                );
891            }
892            Self { _lock: lock, prev }
893        }
894    }
895
896    impl Drop for SerialCacheDir {
897        fn drop(&mut self) {
898            unsafe {
899                match &self.prev {
900                    Some(v) => std::env::set_var(oxdock_env::CACHE_DIR, v),
901                    None => std::env::remove_var(oxdock_env::CACHE_DIR),
902                }
903            }
904        }
905    }
906
907    #[test]
908    fn workspace_cache_persists_across_runs() {
909        let pin = GuardedPath::tempdir().unwrap();
910        let pin_root = guard_root(&pin);
911        let _env = SerialCacheDir::pin(&pin_root);
912
913        let snapshot = GuardedPath::tempdir().unwrap();
914        let local = GuardedPath::tempdir().unwrap();
915        let snapshot_root = guard_root(&snapshot);
916        let local_root = guard_root(&local);
917
918        // First run writes through CACHE, then hops roots to prove the
919        // entry does not land in snapshot or local.
920        let first = indoc! {
921            r#"
922            WRITE "snap.txt" "snap"
923            WORKSPACE LOCAL
924            WRITE "local.txt" "local"
925            WORKSPACE CACHE
926            WRITE "cached.txt" "persistent"
927            WORKSPACE SNAPSHOT
928            "#
929        };
930        let steps = crate::parse_script(first).unwrap();
931        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
932
933        // Native only: the Miri synthetic backend keys all non-snapshot
934        // state by build root, so cross-root absence is vacuous there.
935        // Selection itself is covered by mock tests under Miri.
936        #[cfg(not(miri))]
937        assert!(!exists(&snapshot_root, "cached.txt"));
938        #[cfg(not(miri))]
939        assert!(!exists(&local_root, "cached.txt"));
940
941        // A fresh run with a fresh snapshot root still sees the entry:
942        // the cache survived while the snapshot did not. The Miri
943        // synthetic backend keys non-snapshot state by build root, so the
944        // second run reuses the local root there; natively it gets a
945        // fresh one.
946        let fresh_snapshot = GuardedPath::tempdir().unwrap();
947        let fresh_snapshot_root = guard_root(&fresh_snapshot);
948        #[cfg(not(miri))]
949        let fresh_local = GuardedPath::tempdir().unwrap();
950        #[cfg(not(miri))]
951        let fresh_local_root = guard_root(&fresh_local);
952        #[cfg(miri)]
953        let fresh_local_root = local_root.clone();
954
955        let second = indoc! {
956            r#"
957            WORKSPACE CACHE
958            LET $v: STRING = READ cached.txt
959            ASSERT_EQ $v "persistent"
960            "#
961        };
962        let steps = crate::parse_script(second).unwrap();
963        run_steps_with_context(&fresh_snapshot_root, &fresh_local_root, &steps).unwrap();
964    }
965
966    #[test]
967    fn workspace_cache_local_stays_in_project_tree() {
968        // Pin the OS cache aside: `--local` must never touch it.
969        let pin = GuardedPath::tempdir().unwrap();
970        let pin_root = guard_root(&pin);
971        let _env = SerialCacheDir::pin(&pin_root);
972
973        let snapshot = GuardedPath::tempdir().unwrap();
974        let local = GuardedPath::tempdir().unwrap();
975        let snapshot_root = guard_root(&snapshot);
976        let local_root = guard_root(&local);
977
978        let first = indoc! {
979            r#"
980            WORKSPACE CACHE --local
981            WRITE "local-cached.txt" "persistent"
982            LET $v: STRING = READ local-cached.txt
983            ASSERT_EQ $v "persistent"
984            "#
985        };
986        let steps = crate::parse_script(first).unwrap();
987        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
988
989        // Native only (see above): the Miri synthetic backend shares one
990        // namespace per build root, so location assertions are vacuous.
991        #[cfg(not(miri))]
992        assert!(exists(&local_root, ".cache/workspace/local-cached.txt"));
993        #[cfg(not(miri))]
994        assert!(!exists(&pin_root, "workspace/local-cached.txt"));
995
996        // A fresh snapshot against the same project tree reads it back:
997        // the local cache outlives the ephemeral snapshot.
998        let fresh_snapshot = GuardedPath::tempdir().unwrap();
999        let fresh_snapshot_root = guard_root(&fresh_snapshot);
1000        let second = indoc! {
1001            r#"
1002            WORKSPACE CACHE --local
1003            LET $v: STRING = READ local-cached.txt
1004            ASSERT_EQ $v "persistent"
1005            "#
1006        };
1007        let steps = crate::parse_script(second).unwrap();
1008        run_steps_with_context(&fresh_snapshot_root, &local_root, &steps).unwrap();
1009
1010        // A fresh project tree does not: the local cache lives and dies
1011        // with the tree, unlike the OS-native flavor.
1012        let other_local = GuardedPath::tempdir().unwrap();
1013        let other_local_root = guard_root(&other_local);
1014        let steps = crate::parse_script(second).unwrap();
1015        assert!(
1016            run_steps_with_context(&fresh_snapshot_root, &other_local_root, &steps).is_err(),
1017            "local cache must not leak across project trees"
1018        );
1019    }
1020
1021    #[test]
1022    fn workspace_system_reaches_outside_roots() {
1023        let snapshot = GuardedPath::tempdir().unwrap();
1024        let local = GuardedPath::tempdir().unwrap();
1025        let snapshot_root = guard_root(&snapshot);
1026        let local_root = guard_root(&local);
1027        let outside = GuardedPath::tempdir().unwrap();
1028        let outside_root = guard_root(&outside);
1029
1030        // From a snapshot-selected start, SYSTEM writes an outside file by
1031        // absolute path, reads it back, and writes a sibling next to it.
1032        // Seeding from inside the script keeps every backend (including
1033        // the Miri synthetic one, which keys state by guard root) in one
1034        // namespace. Forward slashes throughout: a raw Windows path would
1035        // lose its backslashes to DSL string escapes (`C:\Users` parses
1036        // as `C:Users`).
1037        let script = format!(
1038            indoc! {r#"
1039                WORKSPACE SYSTEM
1040                WRITE "{secret}" outside
1041                LET $v: STRING = READ "{secret}"
1042                ASSERT_EQ $v outside
1043                WRITE "{sibling}" done
1044                WORKSPACE SNAPSHOT
1045            "#},
1046            secret = to_forward_slashes(
1047                &outside_root
1048                    .join("secret.txt")
1049                    .unwrap()
1050                    .as_path()
1051                    .to_string_lossy()
1052            ),
1053            sibling = to_forward_slashes(
1054                &outside_root
1055                    .join("sibling.txt")
1056                    .unwrap()
1057                    .as_path()
1058                    .to_string_lossy()
1059            ),
1060        );
1061        let steps = crate::parse_script(&script).unwrap();
1062        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1063
1064        // Native only (see above): Miri shares one namespace per build root,
1065        // while the in-script ASSERT_EQ already verifies content everywhere.
1066        #[cfg(not(miri))]
1067        assert!(exists(&outside_root, "sibling.txt"));
1068        // Native only (see above): Miri shares one namespace per build root.
1069        #[cfg(not(miri))]
1070        assert!(!exists(&snapshot_root, "sibling.txt"));
1071        #[cfg(not(miri))]
1072        assert!(!exists(&local_root, "sibling.txt"));
1073    }
1074
1075    #[test]
1076    fn copy_from_workspace_targets() {
1077        let pin = GuardedPath::tempdir().unwrap();
1078        let pin_root = guard_root(&pin);
1079        let _env = SerialCacheDir::pin(&pin_root);
1080
1081        let snapshot = GuardedPath::tempdir().unwrap();
1082        let local = GuardedPath::tempdir().unwrap();
1083        let snapshot_root = guard_root(&snapshot);
1084        let local_root = guard_root(&local);
1085        let outside = GuardedPath::tempdir().unwrap();
1086        let outside_root = guard_root(&outside);
1087
1088        // SNAPSHOT, CACHE, LOCAL, and SYSTEM sources each resolve against
1089        // their own root and land in the snapshot cwd. The SYSTEM source
1090        // is seeded from inside the script so every backend observes one
1091        // namespace. Forward slashes throughout: raw Windows backslashes
1092        // would be consumed by DSL string escapes.
1093        let script = format!(
1094            indoc! {r#"
1095                WRITE snap-src.txt from-snap
1096                WORKSPACE CACHE
1097                WRITE cache-src.txt from-cache
1098                WORKSPACE SYSTEM
1099                WRITE "{secret}" outside
1100                WORKSPACE SNAPSHOT
1101                COPY --from-workspace SNAPSHOT snap-src.txt snap-copy.txt
1102                COPY --from-workspace CACHE cache-src.txt cache-copy.txt
1103                COPY --from-workspace SYSTEM "{secret}" sys-copy.txt
1104                COPY --from-workspace LOCAL "{local_src}" local-copy.txt
1105            "#},
1106            secret = to_forward_slashes(
1107                &outside_root
1108                    .join("secret.txt")
1109                    .unwrap()
1110                    .as_path()
1111                    .to_string_lossy()
1112            ),
1113            local_src = to_forward_slashes(
1114                &local_root
1115                    .join("local-src.txt")
1116                    .unwrap()
1117                    .as_path()
1118                    .to_string_lossy()
1119            ),
1120        );
1121        // Seed the LOCAL source through the build context side.
1122        let local_seeder = PathResolver::new(local_root.as_path(), local_root.as_path()).unwrap();
1123        local_seeder
1124            .write_file(&local_root.join("local-src.txt").unwrap(), b"from-local")
1125            .unwrap();
1126
1127        let steps = crate::parse_script(&script).unwrap();
1128        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1129
1130        assert!(exists(&snapshot_root, "snap-copy.txt"));
1131        assert!(exists(&snapshot_root, "cache-copy.txt"));
1132        assert!(exists(&snapshot_root, "sys-copy.txt"));
1133        assert!(exists(&snapshot_root, "local-copy.txt"));
1134
1135        // A pending snapshot has no source to copy from.
1136        let pending = indoc! {r#"
1137            COPY --from-workspace SNAPSHOT missing.txt out.txt
1138        "#};
1139        let steps = crate::parse_script(pending).unwrap();
1140        let fresh_snapshot = GuardedPath::tempdir().unwrap();
1141        let fresh_local = GuardedPath::tempdir().unwrap();
1142        assert!(
1143            run_steps_with_context(
1144                &guard_root(&fresh_snapshot),
1145                &guard_root(&fresh_local),
1146                &steps
1147            )
1148            .is_err(),
1149            "COPY from a pending snapshot must fail"
1150        );
1151    }
1152
1153    #[test]
1154    fn copy_destinations_mirror_docker() {
1155        // Docker destination semantics: a file copied onto a directory
1156        // (`.`, an existing dir, or a trailing-slash spell) lands inside
1157        // it under its basename; a directory source copies its contents.
1158        // Covers the `COPY file .` escape-check failure under cache roots.
1159        let pin = GuardedPath::tempdir().unwrap();
1160        let pin_root = guard_root(&pin);
1161        let _env = SerialCacheDir::pin(&pin_root);
1162
1163        let snapshot = GuardedPath::tempdir().unwrap();
1164        let local = GuardedPath::tempdir().unwrap();
1165        let snapshot_root = guard_root(&snapshot);
1166        let local_root = guard_root(&local);
1167
1168        let seeder = PathResolver::new(local_root.as_path(), local_root.as_path()).unwrap();
1169        seeder
1170            .write_file(&local_root.join("cargo.toml").unwrap(), b"manifest")
1171            .unwrap();
1172        seeder
1173            .create_dir_all(&local_root.join("sub").unwrap())
1174            .unwrap();
1175        seeder
1176            .write_file(&local_root.join("sub/a.txt").unwrap(), b"a")
1177            .unwrap();
1178
1179        let script = indoc! {r#"
1180            WORKSPACE CACHE --local
1181            COPY --from-workspace LOCAL cargo.toml .
1182            LET $a: STRING = READ cargo.toml
1183            ASSERT_EQ $a manifest
1184            COPY --from-workspace LOCAL cargo.toml renamed.txt
1185            LET $b: STRING = READ renamed.txt
1186            ASSERT_EQ $b manifest
1187            MKDIR subdir
1188            COPY --from-workspace LOCAL cargo.toml subdir
1189            COPY --from-workspace LOCAL cargo.toml subdir/
1190            LET $c: STRING = READ subdir/cargo.toml
1191            ASSERT_EQ $c manifest
1192            COPY --from-workspace LOCAL sub .
1193            LET $d: STRING = READ a.txt
1194            ASSERT_EQ $d a
1195            WORKSPACE SNAPSHOT
1196            COPY --from-workspace LOCAL cargo.toml .
1197            LET $e: STRING = READ cargo.toml
1198            ASSERT_EQ $e manifest
1199        "#};
1200        let steps = crate::parse_script(script).unwrap();
1201        run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1202
1203        // Native only: the Miri synthetic backend keys all non-snapshot
1204        // state by build root, so location assertions via `exists()` are
1205        // vacuous there. Content is verified in-script above on every
1206        // platform; selection itself is covered by mock tests under Miri.
1207        #[cfg(not(miri))]
1208        assert!(exists(&local_root, ".cache/workspace/cargo.toml"));
1209        #[cfg(not(miri))]
1210        assert!(exists(&local_root, ".cache/workspace/renamed.txt"));
1211        #[cfg(not(miri))]
1212        assert!(exists(&local_root, ".cache/workspace/subdir/cargo.toml"));
1213        #[cfg(not(miri))]
1214        assert!(exists(&local_root, ".cache/workspace/a.txt"));
1215        #[cfg(not(miri))]
1216        assert!(exists(&snapshot_root, "cargo.toml"));
1217    }
1218}