1extern crate self as oxdock_core;
2
3pub mod exec;
4pub mod pipeline;
5pub use exec::*;
6pub use oxdock_parser::{
7 Arg, ArgType, CommandMeta, CommandSpec, StepKind, all_metadata, all_structural_metadata,
8 lower_command,
9};
10pub use oxdock_process::ProcessManager;
11
12define_pipeline! {
13 StepKind::Run(..) => exec::dispatch_run,
14 StepKind::RunExec { .. } => exec::dispatch_run_exec,
15 StepKind::AsyncBlock { .. } => exec::dispatch_async_block,
16 StepKind::Echo(..) => exec::dispatch_echo,
17 StepKind::Workdir(..) => exec::dispatch_workdir,
18 StepKind::Workspace(..) => exec::dispatch_workspace,
19 StepKind::Env { .. } => exec::dispatch_env,
20 StepKind::Copy { .. } => exec::dispatch_copy,
21 StepKind::CopyGit { .. } => exec::dispatch_copy_git,
22 StepKind::Symlink { .. } => exec::dispatch_symlink,
23 StepKind::Mkdir(..) => exec::dispatch_mkdir,
24 StepKind::Ls(..) => exec::dispatch_ls,
25 StepKind::Cwd => exec::dispatch_cwd,
26 StepKind::Read(..) => exec::dispatch_read,
27 StepKind::ReadLine { .. } => exec::dispatch_read_line,
28 StepKind::Write { .. } => exec::dispatch_write,
29 StepKind::Append { .. } => exec::dispatch_append,
30 StepKind::Expand { .. } => exec::dispatch_expand,
31 StepKind::AssertEq { .. } => exec::dispatch_assert_eq,
32 StepKind::AssertContains { .. } => exec::dispatch_assert_contains,
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::ListAppend { .. } => exec::dispatch_push_into_step,
44 StepKind::FuncDef { .. } => exec::dispatch_func_def,
45 StepKind::Call { .. } => exec::dispatch_call,
46 StepKind::Return { .. } => exec::dispatch_return,
47 StepKind::While { .. } => exec::dispatch_while_loop,
48 StepKind::Break => exec::dispatch_break,
49 StepKind::Continue => exec::dispatch_continue,
50}
51
52pub fn parse_script(input: &str) -> anyhow::Result<Vec<oxdock_parser::Step>> {
59 parse_script_with_modules(input, std_module_table())
60}
61
62pub fn parse_script_with_modules(
69 input: &str,
70 modules: oxdock_parser::ModuleTable,
71) -> anyhow::Result<Vec<oxdock_parser::Step>> {
72 let mut reserved = builtin_function_names();
73 reserved.extend(modules.reserved_base_names());
74 Ok(oxdock_parser::parse_script_with_modules(
75 input,
76 lower_command,
77 reserved,
78 modules,
79 )?)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use indoc::indoc;
86 use oxdock_fs::{
87 GuardedPath, GuardedTempDir, PathResolver, env as oxdock_env, to_forward_slashes,
88 };
89 use oxdock_parser::{Step, StepKind};
90 #[cfg(unix)]
91 use std::time::Instant;
92
93 fn guard_root(temp: &GuardedTempDir) -> GuardedPath {
94 temp.as_guarded_path().clone()
95 }
96
97 fn read_trimmed(path: &GuardedPath) -> String {
98 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
99 resolver
100 .read_to_string(path)
101 .unwrap_or_default()
102 .trim()
103 .to_string()
104 }
105
106 fn create_dirs(path: &GuardedPath) {
107 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
108 resolver.create_dir_all(path).unwrap();
109 }
110
111 fn exists(root: &GuardedPath, rel: &str) -> bool {
112 root.join(rel).map(|p| p.exists()).unwrap_or(false)
113 }
114
115 #[test]
116 fn run_isolates_cargo_target_dir_from_workspace() {
117 let temp = GuardedPath::tempdir().unwrap();
118 let root = guard_root(&temp);
119
120 #[allow(clippy::disallowed_macros)]
121 let cmd = if cfg!(windows) {
122 "echo %CARGO_TARGET_DIR% > seen.txt"
123 } else {
124 "printf %s \"$CARGO_TARGET_DIR\" > seen.txt"
125 };
126
127 let steps = vec![Step {
128 guard: None,
129 kind: StepKind::Run(cmd.to_string().into()),
130 scope_enter: 0,
131 scope_exit: 0,
132 }];
133
134 run_steps(&root, &steps).unwrap();
135
136 let seen = read_trimmed(&root.join("seen.txt").unwrap());
137 let legacy = root.join(".cargo-target").unwrap();
138
139 assert_ne!(
140 seen.trim(),
141 legacy.display().to_string(),
142 "CARGO_TARGET_DIR must not target the workspace tree"
143 );
144 assert!(
145 seen.trim().contains("oxdock-cargo-"),
146 "CARGO_TARGET_DIR must point at the isolated scratch location, got {seen:?}"
147 );
148 }
149
150 #[test]
151 fn lazy_snapshot_run_materializes_but_local_run_does_not() {
152 let temp = GuardedPath::tempdir().unwrap();
153 let root = guard_root(&temp);
154
155 let steps = parse_script("RUN echo hi").unwrap();
157 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
158 assert!(
159 output.snapshot.is_materialized(),
160 "snapshot-rooted RUN must materialize the snapshot"
161 );
162
163 let steps = parse_script(indoc! {r#"
165 WORKSPACE LOCAL
166 RUN echo hi
167 "#})
168 .unwrap();
169 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
170 assert!(
171 !output.snapshot.is_materialized(),
172 "LOCAL-rooted RUN must never materialize the snapshot"
173 );
174 }
175
176 #[test]
177 fn lazy_empty_run_creates_nothing() {
178 let temp = GuardedPath::tempdir().unwrap();
179 let root = guard_root(&temp);
180
181 let output = run_steps_with_lazy_snapshot(&root, &[], ExecIo::new()).unwrap();
182 assert!(!output.snapshot.is_materialized());
183 assert!(output.snapshot.get().is_none());
184 }
185
186 #[test]
187 fn lazy_local_echo_assert_failure_never_materializes_snapshot() {
188 let temp = GuardedPath::tempdir().unwrap();
189 let root = guard_root(&temp);
190
191 let script = indoc!(
192 r#"
193 WORKSPACE LOCAL
194 ECHO playground pid is 41067
195 ASSERT_CONTAINS stdout "playground pid i!!"
196 "#
197 );
198 let steps = parse_script(script).unwrap();
199
200 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
204 resolver.set_workspace_root(root.clone());
205 let snapshot = resolver.snapshot_handle();
206 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
207 let result = run_steps_with_manager(
208 fs,
209 &steps,
210 oxdock_process::default_process_manager(),
211 ExecIo::new(),
212 );
213 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
214 let err = result.err().unwrap();
215 assert!(!snapshot.is_materialized());
216 assert!(snapshot.get().is_none());
217
218 let enriched = enrich_lazy_error(&snapshot, &root, err);
219 let msg = enriched.to_string();
220 assert!(
221 msg.contains("did not contain 'playground pid i!!'"),
222 "{msg}"
223 );
224 assert!(msg.contains("never materialized"), "{msg}");
225 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
226 }
227
228 #[test]
229 fn lazy_local_echo_assert_success_leaves_snapshot_pending() {
230 let temp = GuardedPath::tempdir().unwrap();
231 let root = guard_root(&temp);
232
233 let script = indoc!(
234 r#"
235 WORKSPACE LOCAL
236 ECHO playground pid is 41067
237 ASSERT_CONTAINS stdout "playground pid is 41067"
238 "#
239 );
240 let steps = parse_script(script).unwrap();
241
242 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
243 assert!(!output.snapshot.is_materialized());
244 assert!(output.snapshot.get().is_none());
245 }
246
247 #[test]
248 fn lazy_default_mode_echo_assert_failure_keeps_snapshot_pending() {
249 let temp = GuardedPath::tempdir().unwrap();
250 let root = guard_root(&temp);
251
252 let script = indoc!(
256 r#"
257 ECHO playground pid is 41067
258 ASSERT_CONTAINS stdout "playground pid i!!"
259 "#
260 );
261 let steps = parse_script(script).unwrap();
262
263 let mut resolver = PathResolver::new_lazy(root.clone()).unwrap();
267 resolver.set_workspace_root(root.clone());
268 let snapshot = resolver.snapshot_handle();
269 let fs: Box<dyn oxdock_fs::WorkspaceFs> = Box::new(resolver);
270 let result = run_steps_with_manager(
271 fs,
272 &steps,
273 oxdock_process::default_process_manager(),
274 ExecIo::new(),
275 );
276 assert!(result.is_err(), "mismatched ASSERT_CONTAINS must fail");
277 let err = result.err().unwrap();
278 assert!(!snapshot.is_materialized());
279 assert!(snapshot.get().is_none());
280
281 let enriched = enrich_lazy_error(&snapshot, &root, err);
282 let msg = enriched.to_string();
283 assert!(
284 msg.contains("did not contain 'playground pid i!!'"),
285 "{msg}"
286 );
287 assert!(msg.contains("never materialized"), "{msg}");
288 assert!(!msg.contains("filesystem snapshot (root"), "{msg}");
289 }
290
291 #[test]
292 fn lazy_default_mode_write_materializes_snapshot() {
293 let temp = GuardedPath::tempdir().unwrap();
294 let root = guard_root(&temp);
295
296 let steps = parse_script("WRITE \"snap.txt\" \"snap\"").unwrap();
299
300 let output = run_steps_with_lazy_snapshot(&root, &steps, ExecIo::new()).unwrap();
301 assert!(output.snapshot.is_materialized());
302 let snapshot_root = output.snapshot.get().expect("snapshot must be published");
303 assert!(
304 exists(snapshot_root, "snap.txt"),
305 "WRITE must land inside the materialized snapshot"
306 );
307 }
308
309 #[test]
310 fn guard_skips_when_env_missing() {
311 let temp = GuardedPath::tempdir().unwrap();
312 let root = guard_root(&temp);
313
314 let guard_var = oxdock_env::GUARD_TEST_TOKEN_UNSET;
315 let script = format!(
316 indoc!(
317 r#"
318 [env:{guard}] WRITE "skipped.txt" "hi"
319 WRITE "kept.txt" "ok"
320 "#
321 ),
322 guard = guard_var
323 );
324 let steps = crate::parse_script(&script).unwrap();
325
326 run_steps(&root, &steps).unwrap();
327
328 assert!(
329 !exists(&root, "skipped.txt"),
330 "guarded WRITE should be skipped"
331 );
332 assert!(exists(&root, "kept.txt"), "unguarded WRITE should run");
333 }
334
335 #[test]
336 fn guard_sees_env_set_by_env_step() {
337 let temp = GuardedPath::tempdir().unwrap();
338 let root = guard_root(&temp);
339
340 let script = indoc!(
341 r#"
342 ENV FOO="1"
343 [env:FOO] WRITE "hit.txt" "yes"
344 WRITE "always.txt" "ok"
345 "#
346 );
347 let steps = crate::parse_script(script).unwrap();
348
349 run_steps(&root, &steps).unwrap();
350
351 assert!(
352 exists(&root, "hit.txt"),
353 "guarded WRITE should run after ENV sets variable"
354 );
355 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
356 }
357
358 #[test]
359 fn echo_runs_and_allows_subsequent_steps() {
360 let temp = GuardedPath::tempdir().unwrap();
361 let root = guard_root(&temp);
362
363 let script = indoc!(
364 r#"
365 ECHO "Hello, world"
366 WRITE "always.txt" "ok"
367 "#
368 );
369 let steps = crate::parse_script(script).unwrap();
370
371 run_steps(&root, &steps).unwrap();
372
373 assert!(exists(&root, "always.txt"), "WRITE after ECHO should run");
374 }
375
376 #[test]
377 fn guard_on_previous_line_applies_to_next_command() {
378 let temp = GuardedPath::tempdir().unwrap();
379 let root = guard_root(&temp);
380
381 let script = indoc!(
382 r#"
383 ENV FOO="1"
384 [env:FOO]
385 WRITE "hit.txt" "yes"
386 WRITE "always.txt" "ok"
387 "#
388 );
389 let steps = crate::parse_script(script).unwrap();
390
391 run_steps(&root, &steps).unwrap();
392
393 assert!(
394 exists(&root, "hit.txt"),
395 "guarded WRITE on next line should run"
396 );
397 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
398 }
399
400 #[test]
401 fn guard_respects_platform_negation() {
402 let temp = GuardedPath::tempdir().unwrap();
403 let root = guard_root(&temp);
404
405 let script = indoc!(
406 r#"
407 [not(unix)] WRITE "platform.txt" "hi"
408 WRITE "always.txt" "ok"
409 "#
410 );
411 let steps = crate::parse_script(script).unwrap();
412
413 run_steps(&root, &steps).unwrap();
414
415 #[allow(clippy::disallowed_macros)]
416 let expect_skipped = cfg!(unix);
417 assert_eq!(
418 exists(&root, "platform.txt"),
419 !expect_skipped,
420 "platform guard should skip on unix and run elsewhere"
421 );
422 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
423 }
424
425 #[test]
426 fn guard_block_env_scope_restores_after_exit() {
427 let temp = GuardedPath::tempdir().unwrap();
428 let root = guard_root(&temp);
429
430 let script = indoc!(
431 r#"
432 ENV RUN="1"
433 [env:RUN] {
434 ENV INNER="1"
435 WRITE "scoped.txt" "hit"
436 }
437 [env:INNER] WRITE "leak.txt" "nope"
438 "#
439 );
440 let steps = crate::parse_script(script).unwrap();
441
442 run_steps(&root, &steps).unwrap();
443
444 assert!(exists(&root, "scoped.txt"), "block should run");
445 assert!(
446 !exists(&root, "leak.txt"),
447 "env set inside block must not leak outward"
448 );
449 }
450
451 #[test]
452 fn guard_block_workdir_scope_restores_after_exit() {
453 let temp = GuardedPath::tempdir().unwrap();
454 let root = guard_root(&temp);
455
456 let script = indoc!(
457 r#"
458 MKDIR "nested"
459 ENV RUN="1"
460 [env:RUN] {
461 WORKDIR "nested"
462 WRITE "inside.txt" "ok"
463 }
464 WRITE "outside.txt" "root"
465 "#
466 );
467 let steps = crate::parse_script(script).unwrap();
468
469 run_steps(&root, &steps).unwrap();
470
471 assert!(
472 exists(&root, "nested/inside.txt"),
473 "inside write should land in nested dir"
474 );
475 assert!(
476 exists(&root, "outside.txt"),
477 "workdir should reset after block exits"
478 );
479 assert!(
480 !exists(&root, "nested/outside.txt"),
481 "writes after block should not stay scoped"
482 );
483 }
484
485 #[test]
486 fn workspace_scope_restores_after_guard_block() {
487 let snapshot = GuardedPath::tempdir().unwrap();
488 let local = GuardedPath::tempdir().unwrap();
489 let snapshot_root = guard_root(&snapshot);
490 let local_root = guard_root(&local);
491
492 let script = indoc!(
493 r#"
494 ENV RUN="1"
495 [env:RUN] {
496 WORKSPACE LOCAL
497 WRITE "local_only.txt" "inside"
498 }
499 WRITE "snapshot_only.txt" "outside"
500 "#
501 );
502 let steps = crate::parse_script(script).unwrap();
503
504 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
505
506 assert!(
507 local_root.join("local_only.txt").unwrap().exists(),
508 "workspace switch inside block should affect local root"
509 );
510 assert!(
511 snapshot_root.join("snapshot_only.txt").unwrap().exists(),
512 "writes after block must target snapshot again"
513 );
514 assert!(
515 !local_root.join("snapshot_only.txt").unwrap().exists(),
516 "workspace should reset after guard block exits"
517 );
518 }
519
520 #[test]
521 fn guard_matches_profile_env() {
522 let temp = GuardedPath::tempdir().unwrap();
524 let root = guard_root(&temp);
525
526 let profile = std::env::var(oxdock_env::PROFILE).unwrap_or_else(|_| "debug".to_string());
527 let script = format!(
528 indoc!(
529 r#"
530 ENV PROFILE={0}
531 [eq(env:PROFILE, {0})] WRITE "hit.txt" "yes"
532 [ne(env:PROFILE, {0})] WRITE "miss.txt" "no"
533 "#
534 ),
535 profile
536 );
537
538 let steps = crate::parse_script(&script).unwrap();
539 run_steps(&root, &steps).unwrap();
540
541 assert!(
542 exists(&root, "hit.txt"),
543 "PROFILE-matching guard should run"
544 );
545 assert!(
546 !exists(&root, "miss.txt"),
547 "PROFILE inequality guard should skip for current profile"
548 );
549 }
550
551 #[test]
552 fn multiple_guards_all_must_pass() {
553 let temp = GuardedPath::tempdir().unwrap();
554 let root = guard_root(&temp);
555
556 let key = oxdock_env::MULTI_GUARD_TEST_PASS;
557
558 let script = format!(
559 indoc!(
560 r#"
561 ENV {k}=ok
562 [env:{k},eq(env:{k}, ok)] WRITE "hit.txt" "yes"
563 WRITE "always.txt" "ok"
564 "#
565 ),
566 k = key
567 );
568 let steps = crate::parse_script(&script).unwrap();
569 run_steps(&root, &steps).unwrap();
570
571 assert!(
572 exists(&root, "hit.txt"),
573 "guarded step should run when all guards pass"
574 );
575 assert!(exists(&root, "always.txt"), "unguarded step should run");
576 }
577
578 #[test]
579 fn multiple_guards_skip_when_one_fails() {
580 let temp = GuardedPath::tempdir().unwrap();
581 let root = guard_root(&temp);
582
583 let key = oxdock_env::MULTI_GUARD_TEST_FAIL;
584
585 let script = format!(
586 indoc!(
587 r#"
588 ENV {k}=ok
589 [env:{k},ne(env:{k}, ok)] WRITE "miss.txt" "yes"
590 WRITE "always.txt" "ok"
591 "#
592 ),
593 k = key
594 );
595 let steps = crate::parse_script(&script).unwrap();
596 run_steps(&root, &steps).unwrap();
597
598 assert!(
599 !exists(&root, "miss.txt"),
600 "guarded step should skip when any guard fails"
601 );
602 assert!(exists(&root, "always.txt"), "unguarded step should run");
603 }
604
605 #[cfg(unix)]
606 #[cfg_attr(
607 miri,
608 ignore = "stdout streaming not supported for background command under miri"
609 )]
610 #[test]
611 fn async_exits_success_and_stops_pipeline() {
612 let temp = GuardedPath::tempdir().unwrap();
613 let root = guard_root(&temp);
614
615 let script = "ASYNC RUN \"sh -c 'sleep 0.05'\"";
617 let steps = crate::parse_script(script).unwrap();
618 let res = run_steps(&root, &steps);
619 assert!(res.is_ok(), "ASYNC success should allow clean exit");
620 }
621
622 #[cfg(unix)]
623 #[cfg_attr(
624 miri,
625 ignore = "stdout streaming not supported for background command under miri"
626 )]
627 #[test]
628 fn async_failure_bubbles_status() {
629 let temp = GuardedPath::tempdir().unwrap();
630 let root = guard_root(&temp);
631
632 let script = "ASYNC RUN \"sh -c 'sleep 0.05; exit 7'\"";
633 let steps = crate::parse_script(script).unwrap();
634 let err = run_steps(&root, &steps).unwrap_err();
635 let msg = err.to_string();
636 assert!(
637 msg.contains("ASYNC process exited with status") || msg.contains("exit status: 7"),
638 "should surface failing ASYNC exit code"
639 );
640 }
641
642 #[cfg(unix)]
643 #[cfg_attr(
644 miri,
645 ignore = "timing-sensitive background process test is unreliable under Miri"
646 )]
647 #[test]
648 fn async_multiple_stops_on_first_exit_and_does_not_block_steps() {
649 let temp = GuardedPath::tempdir().unwrap();
650 let root = guard_root(&temp);
651
652 let script = indoc! {
653 r#"
654 ASYNC RUN "sh -c 'sleep 0.2; echo one > one.txt'"
655 ASYNC RUN "sh -c 'sleep 0.5; echo two > two.txt'"
656 WRITE "done.txt" "ok"
657 "#
658 };
659
660 let steps = crate::parse_script(script).unwrap();
661 let start = Instant::now();
662 let res = run_steps(&root, &steps);
663 let elapsed = start.elapsed();
664
665 assert!(res.is_ok(), "ASYNC success should allow clean exit");
666 assert!(
667 exists(&root, "done.txt"),
668 "foreground step should run after spawning backgrounds"
669 );
670 assert!(
671 exists(&root, "one.txt"),
672 "first background should finish and emit output"
673 );
674 assert!(
677 exists(&root, "two.txt"),
678 "second background should finish (poll-all waits for all)"
679 );
680
681 let upper = 0.8;
682 assert!(
683 elapsed.as_secs_f32() < upper && elapsed.as_secs_f32() > 0.15,
684 "should wait for both backgrounds (~0.5s); got {elapsed:?}"
685 );
686 }
687
688 #[cfg(unix)]
689 #[cfg_attr(
690 miri,
691 ignore = "timing-sensitive background process test is unreliable under Miri"
692 )]
693 #[test]
694 fn background_killed_when_unrelated_step_fails() {
695 let temp = GuardedPath::tempdir().unwrap();
696 let root = guard_root(&temp);
697
698 let script = indoc! {
699 r#"
700 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
701 RUN "__oxdock_missing_command_xyz__"
702 "#
703 };
704
705 let steps = crate::parse_script(script).unwrap();
706 assert!(
707 run_steps(&root, &steps).is_err(),
708 "pipeline should fail on the missing command"
709 );
710 assert!(
714 !exists(&root, "late.txt"),
715 "abandoned background writer must be killed by Drop teardown"
716 );
717 }
718
719 #[cfg(unix)]
720 #[cfg_attr(
721 miri,
722 ignore = "stdout streaming not supported for background command under miri"
723 )]
724 #[test]
725 fn exit_terminates_backgrounds_and_returns_code() {
726 let temp = GuardedPath::tempdir().unwrap();
727 let root = guard_root(&temp);
728
729 let script = indoc! {
730 r#"
731 ASYNC RUN "sh -c 'sleep 1; echo late > late.txt'"
732 EXIT 5
733 "#
734 };
735
736 let steps = crate::parse_script(script).unwrap();
737 let err = run_steps(&root, &steps).unwrap_err();
738 let msg = err.to_string();
739 assert!(msg.contains("EXIT requested with code 5"));
740 assert!(
741 !exists(&root, "late.txt"),
742 "background process should be killed when EXIT is hit"
743 );
744 }
745
746 #[test]
747 #[cfg_attr(
748 miri,
749 ignore = "EXIT joins real background threads; timing-sensitive under Miri"
750 )]
751 fn exit_inside_nested_block_reports_code_and_kills_backgrounds() {
752 let temp = GuardedPath::tempdir().unwrap();
753 let root = guard_root(&temp);
754
755 let script = indoc! {
756 r#"
757 ASYNC {
758 SLEEP 30s
759 }
760 [bool:true] {
761 EXIT 5
762 }
763 "#
764 };
765
766 let steps = crate::parse_script(script).unwrap();
767 let start = std::time::Instant::now();
768 let err = run_steps(&root, &steps).unwrap_err();
769 assert!(
770 err.to_string().contains("EXIT requested with code 5"),
771 "nested EXIT must report its code: {err}"
772 );
773 assert!(
774 start.elapsed() < std::time::Duration::from_secs(10),
775 "nested EXIT must kill the background SLEEP promptly"
776 );
777 }
778
779 #[cfg_attr(
780 miri,
781 ignore = "stdout streaming not supported for background command under miri"
782 )]
783 #[test]
784 fn env_applies_to_run_and_background() {
785 let temp = GuardedPath::tempdir().unwrap();
786 let root = guard_root(&temp);
787
788 #[allow(clippy::disallowed_macros)]
789 let script = if cfg!(windows) {
790 indoc! {
791 r#"
792 ENV FOO="bar"
793 RUN "echo %FOO% > run.txt"
794 ASYNC RUN "echo %FOO% > bg.txt"
795 "#
796 }
797 } else {
798 indoc! {
799 r#"
800 ENV FOO="bar"
801 RUN "sh -c 'printf %s \"$FOO\" > run.txt'"
802 ASYNC RUN "sh -c 'printf %s \"$FOO\" > bg.txt'"
803 "#
804 }
805 };
806
807 let steps = crate::parse_script(script).unwrap();
808 run_steps(&root, &steps).unwrap();
809
810 assert_eq!(read_trimmed(&root.join("run.txt").unwrap()), "bar");
811 assert_eq!(read_trimmed(&root.join("bg.txt").unwrap()), "bar");
812 }
813
814 #[test]
815 fn workspace_switches_between_snapshot_and_local() {
816 let snapshot = GuardedPath::tempdir().unwrap();
817 let local = GuardedPath::tempdir().unwrap();
818 let snapshot_root = guard_root(&snapshot);
819 let local_root = guard_root(&local);
820
821 let script = indoc! {
822 r#"
823 WRITE "snap.txt" "snap"
824 WORKSPACE LOCAL
825 WRITE "local.txt" "local"
826 WORKSPACE SNAPSHOT
827 WRITE "snap2.txt" "again"
828 "#
829 };
830
831 let steps = crate::parse_script(script).unwrap();
832 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
833
834 assert!(snapshot_root.join("snap.txt").unwrap().exists());
835 assert!(snapshot_root.join("snap2.txt").unwrap().exists());
836 assert!(local_root.join("local.txt").unwrap().exists());
837 }
838
839 #[test]
840 fn workspace_root_changes_where_slash_points() {
841 let snapshot = GuardedPath::tempdir().unwrap();
842 let local = GuardedPath::tempdir().unwrap();
843 let snapshot_root = guard_root(&snapshot);
844 let local_root = guard_root(&local);
845 let local_client = local_root.join("client").unwrap();
846 create_dirs(&local_client);
847
848 let script = indoc! {
849 r#"
850 WORKSPACE LOCAL
851 WORKDIR "/"
852 WRITE "localroot.txt" "one"
853 WORKDIR "client"
854 WRITE "client.txt" "two"
855 WORKSPACE SNAPSHOT
856 WORKDIR "/"
857 WRITE "snaproot.txt" "three"
858 "#
859 };
860
861 let steps = crate::parse_script(script).unwrap();
862 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
863
864 assert!(local_root.join("localroot.txt").unwrap().exists());
865 assert!(local_client.join("client.txt").unwrap().exists());
866 assert!(snapshot_root.join("snaproot.txt").unwrap().exists());
867 }
868
869 struct SerialCacheDir {
873 _lock: std::sync::MutexGuard<'static, ()>,
874 prev: Option<String>,
875 }
876
877 static CACHE_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
878
879 impl SerialCacheDir {
880 fn pin(dir: &GuardedPath) -> Self {
881 let lock = CACHE_DIR_LOCK
882 .lock()
883 .unwrap_or_else(|poisoned| poisoned.into_inner());
884 let prev = std::env::var(oxdock_env::CACHE_DIR).ok();
885 unsafe {
887 std::env::set_var(
888 oxdock_env::CACHE_DIR,
889 dir.as_path().to_string_lossy().into_owned(),
890 );
891 }
892 Self { _lock: lock, prev }
893 }
894 }
895
896 impl Drop for SerialCacheDir {
897 fn drop(&mut self) {
898 unsafe {
899 match &self.prev {
900 Some(v) => std::env::set_var(oxdock_env::CACHE_DIR, v),
901 None => std::env::remove_var(oxdock_env::CACHE_DIR),
902 }
903 }
904 }
905 }
906
907 #[test]
908 fn workspace_cache_persists_across_runs() {
909 let pin = GuardedPath::tempdir().unwrap();
910 let pin_root = guard_root(&pin);
911 let _env = SerialCacheDir::pin(&pin_root);
912
913 let snapshot = GuardedPath::tempdir().unwrap();
914 let local = GuardedPath::tempdir().unwrap();
915 let snapshot_root = guard_root(&snapshot);
916 let local_root = guard_root(&local);
917
918 let first = indoc! {
921 r#"
922 WRITE "snap.txt" "snap"
923 WORKSPACE LOCAL
924 WRITE "local.txt" "local"
925 WORKSPACE CACHE
926 WRITE "cached.txt" "persistent"
927 WORKSPACE SNAPSHOT
928 "#
929 };
930 let steps = crate::parse_script(first).unwrap();
931 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
932
933 #[cfg(not(miri))]
937 assert!(!exists(&snapshot_root, "cached.txt"));
938 #[cfg(not(miri))]
939 assert!(!exists(&local_root, "cached.txt"));
940
941 let fresh_snapshot = GuardedPath::tempdir().unwrap();
947 let fresh_snapshot_root = guard_root(&fresh_snapshot);
948 #[cfg(not(miri))]
949 let fresh_local = GuardedPath::tempdir().unwrap();
950 #[cfg(not(miri))]
951 let fresh_local_root = guard_root(&fresh_local);
952 #[cfg(miri)]
953 let fresh_local_root = local_root.clone();
954
955 let second = indoc! {
956 r#"
957 WORKSPACE CACHE
958 LET $v: STRING = READ cached.txt
959 ASSERT_EQ $v "persistent"
960 "#
961 };
962 let steps = crate::parse_script(second).unwrap();
963 run_steps_with_context(&fresh_snapshot_root, &fresh_local_root, &steps).unwrap();
964 }
965
966 #[test]
967 fn workspace_cache_local_stays_in_project_tree() {
968 let pin = GuardedPath::tempdir().unwrap();
970 let pin_root = guard_root(&pin);
971 let _env = SerialCacheDir::pin(&pin_root);
972
973 let snapshot = GuardedPath::tempdir().unwrap();
974 let local = GuardedPath::tempdir().unwrap();
975 let snapshot_root = guard_root(&snapshot);
976 let local_root = guard_root(&local);
977
978 let first = indoc! {
979 r#"
980 WORKSPACE CACHE --local
981 WRITE "local-cached.txt" "persistent"
982 LET $v: STRING = READ local-cached.txt
983 ASSERT_EQ $v "persistent"
984 "#
985 };
986 let steps = crate::parse_script(first).unwrap();
987 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
988
989 #[cfg(not(miri))]
992 assert!(exists(&local_root, ".cache/workspace/local-cached.txt"));
993 #[cfg(not(miri))]
994 assert!(!exists(&pin_root, "workspace/local-cached.txt"));
995
996 let fresh_snapshot = GuardedPath::tempdir().unwrap();
999 let fresh_snapshot_root = guard_root(&fresh_snapshot);
1000 let second = indoc! {
1001 r#"
1002 WORKSPACE CACHE --local
1003 LET $v: STRING = READ local-cached.txt
1004 ASSERT_EQ $v "persistent"
1005 "#
1006 };
1007 let steps = crate::parse_script(second).unwrap();
1008 run_steps_with_context(&fresh_snapshot_root, &local_root, &steps).unwrap();
1009
1010 let other_local = GuardedPath::tempdir().unwrap();
1013 let other_local_root = guard_root(&other_local);
1014 let steps = crate::parse_script(second).unwrap();
1015 assert!(
1016 run_steps_with_context(&fresh_snapshot_root, &other_local_root, &steps).is_err(),
1017 "local cache must not leak across project trees"
1018 );
1019 }
1020
1021 #[test]
1022 fn workspace_system_reaches_outside_roots() {
1023 let snapshot = GuardedPath::tempdir().unwrap();
1024 let local = GuardedPath::tempdir().unwrap();
1025 let snapshot_root = guard_root(&snapshot);
1026 let local_root = guard_root(&local);
1027 let outside = GuardedPath::tempdir().unwrap();
1028 let outside_root = guard_root(&outside);
1029
1030 let script = format!(
1038 indoc! {r#"
1039 WORKSPACE SYSTEM
1040 WRITE "{secret}" outside
1041 LET $v: STRING = READ "{secret}"
1042 ASSERT_EQ $v outside
1043 WRITE "{sibling}" done
1044 WORKSPACE SNAPSHOT
1045 "#},
1046 secret = to_forward_slashes(
1047 &outside_root
1048 .join("secret.txt")
1049 .unwrap()
1050 .as_path()
1051 .to_string_lossy()
1052 ),
1053 sibling = to_forward_slashes(
1054 &outside_root
1055 .join("sibling.txt")
1056 .unwrap()
1057 .as_path()
1058 .to_string_lossy()
1059 ),
1060 );
1061 let steps = crate::parse_script(&script).unwrap();
1062 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1063
1064 #[cfg(not(miri))]
1067 assert!(exists(&outside_root, "sibling.txt"));
1068 #[cfg(not(miri))]
1070 assert!(!exists(&snapshot_root, "sibling.txt"));
1071 #[cfg(not(miri))]
1072 assert!(!exists(&local_root, "sibling.txt"));
1073 }
1074
1075 #[test]
1076 fn copy_from_workspace_targets() {
1077 let pin = GuardedPath::tempdir().unwrap();
1078 let pin_root = guard_root(&pin);
1079 let _env = SerialCacheDir::pin(&pin_root);
1080
1081 let snapshot = GuardedPath::tempdir().unwrap();
1082 let local = GuardedPath::tempdir().unwrap();
1083 let snapshot_root = guard_root(&snapshot);
1084 let local_root = guard_root(&local);
1085 let outside = GuardedPath::tempdir().unwrap();
1086 let outside_root = guard_root(&outside);
1087
1088 let script = format!(
1094 indoc! {r#"
1095 WRITE snap-src.txt from-snap
1096 WORKSPACE CACHE
1097 WRITE cache-src.txt from-cache
1098 WORKSPACE SYSTEM
1099 WRITE "{secret}" outside
1100 WORKSPACE SNAPSHOT
1101 COPY --from-workspace SNAPSHOT snap-src.txt snap-copy.txt
1102 COPY --from-workspace CACHE cache-src.txt cache-copy.txt
1103 COPY --from-workspace SYSTEM "{secret}" sys-copy.txt
1104 COPY --from-workspace LOCAL "{local_src}" local-copy.txt
1105 "#},
1106 secret = to_forward_slashes(
1107 &outside_root
1108 .join("secret.txt")
1109 .unwrap()
1110 .as_path()
1111 .to_string_lossy()
1112 ),
1113 local_src = to_forward_slashes(
1114 &local_root
1115 .join("local-src.txt")
1116 .unwrap()
1117 .as_path()
1118 .to_string_lossy()
1119 ),
1120 );
1121 let local_seeder = PathResolver::new(local_root.as_path(), local_root.as_path()).unwrap();
1123 local_seeder
1124 .write_file(&local_root.join("local-src.txt").unwrap(), b"from-local")
1125 .unwrap();
1126
1127 let steps = crate::parse_script(&script).unwrap();
1128 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1129
1130 assert!(exists(&snapshot_root, "snap-copy.txt"));
1131 assert!(exists(&snapshot_root, "cache-copy.txt"));
1132 assert!(exists(&snapshot_root, "sys-copy.txt"));
1133 assert!(exists(&snapshot_root, "local-copy.txt"));
1134
1135 let pending = indoc! {r#"
1137 COPY --from-workspace SNAPSHOT missing.txt out.txt
1138 "#};
1139 let steps = crate::parse_script(pending).unwrap();
1140 let fresh_snapshot = GuardedPath::tempdir().unwrap();
1141 let fresh_local = GuardedPath::tempdir().unwrap();
1142 assert!(
1143 run_steps_with_context(
1144 &guard_root(&fresh_snapshot),
1145 &guard_root(&fresh_local),
1146 &steps
1147 )
1148 .is_err(),
1149 "COPY from a pending snapshot must fail"
1150 );
1151 }
1152
1153 #[test]
1154 fn copy_destinations_mirror_docker() {
1155 let pin = GuardedPath::tempdir().unwrap();
1160 let pin_root = guard_root(&pin);
1161 let _env = SerialCacheDir::pin(&pin_root);
1162
1163 let snapshot = GuardedPath::tempdir().unwrap();
1164 let local = GuardedPath::tempdir().unwrap();
1165 let snapshot_root = guard_root(&snapshot);
1166 let local_root = guard_root(&local);
1167
1168 let seeder = PathResolver::new(local_root.as_path(), local_root.as_path()).unwrap();
1169 seeder
1170 .write_file(&local_root.join("cargo.toml").unwrap(), b"manifest")
1171 .unwrap();
1172 seeder
1173 .create_dir_all(&local_root.join("sub").unwrap())
1174 .unwrap();
1175 seeder
1176 .write_file(&local_root.join("sub/a.txt").unwrap(), b"a")
1177 .unwrap();
1178
1179 let script = indoc! {r#"
1180 WORKSPACE CACHE --local
1181 COPY --from-workspace LOCAL cargo.toml .
1182 LET $a: STRING = READ cargo.toml
1183 ASSERT_EQ $a manifest
1184 COPY --from-workspace LOCAL cargo.toml renamed.txt
1185 LET $b: STRING = READ renamed.txt
1186 ASSERT_EQ $b manifest
1187 MKDIR subdir
1188 COPY --from-workspace LOCAL cargo.toml subdir
1189 COPY --from-workspace LOCAL cargo.toml subdir/
1190 LET $c: STRING = READ subdir/cargo.toml
1191 ASSERT_EQ $c manifest
1192 COPY --from-workspace LOCAL sub .
1193 LET $d: STRING = READ a.txt
1194 ASSERT_EQ $d a
1195 WORKSPACE SNAPSHOT
1196 COPY --from-workspace LOCAL cargo.toml .
1197 LET $e: STRING = READ cargo.toml
1198 ASSERT_EQ $e manifest
1199 "#};
1200 let steps = crate::parse_script(script).unwrap();
1201 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
1202
1203 #[cfg(not(miri))]
1208 assert!(exists(&local_root, ".cache/workspace/cargo.toml"));
1209 #[cfg(not(miri))]
1210 assert!(exists(&local_root, ".cache/workspace/renamed.txt"));
1211 #[cfg(not(miri))]
1212 assert!(exists(&local_root, ".cache/workspace/subdir/cargo.toml"));
1213 #[cfg(not(miri))]
1214 assert!(exists(&local_root, ".cache/workspace/a.txt"));
1215 #[cfg(not(miri))]
1216 assert!(exists(&snapshot_root, "cargo.toml"));
1217 }
1218}