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