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