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