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