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