Skip to main content

oxdock_core/
lib.rs

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