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
49pub 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 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 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 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 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 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 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}