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