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