1pub mod exec;
2pub use exec::*;
3
4#[cfg(test)]
5mod tests {
6 use super::*;
7 use indoc::indoc;
8 use oxdock_fs::{GuardedPath, GuardedTempDir, PathResolver};
9 use oxdock_parser::{Step, StepKind, parse_script};
10 #[cfg(unix)]
11 use std::time::Instant;
12
13 fn guard_root(temp: &GuardedTempDir) -> GuardedPath {
14 temp.as_guarded_path().clone()
15 }
16
17 fn read_trimmed(path: &GuardedPath) -> String {
18 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
19 resolver
20 .read_to_string(path)
21 .unwrap_or_default()
22 .trim()
23 .to_string()
24 }
25
26 fn create_dirs(path: &GuardedPath) {
27 let resolver = PathResolver::new(path.root(), path.root()).unwrap();
28 resolver.create_dir_all(path).unwrap();
29 }
30
31 fn exists(root: &GuardedPath, rel: &str) -> bool {
32 root.join(rel).map(|p| p.exists()).unwrap_or(false)
33 }
34
35 #[test]
36 fn run_sets_cargo_target_dir_to_fs_root() {
37 let temp = GuardedPath::tempdir().unwrap();
38 let root = guard_root(&temp);
39
40 #[allow(clippy::disallowed_macros)]
41 let cmd = if cfg!(windows) {
42 "echo %CARGO_TARGET_DIR% > seen.txt"
43 } else {
44 "printf %s \"$CARGO_TARGET_DIR\" > seen.txt"
45 };
46
47 let steps = vec![Step {
48 guard: None,
49 kind: StepKind::Run(cmd.to_string().into()),
50 scope_enter: 0,
51 scope_exit: 0,
52 }];
53
54 run_steps(&root, &steps).unwrap();
55
56 let seen = read_trimmed(&root.join("seen.txt").unwrap());
57 let expected = root.join(".cargo-target").unwrap();
58
59 assert_eq!(
60 seen.trim(),
61 expected.display().to_string(),
62 "CARGO_TARGET_DIR should be scoped"
63 );
64 }
65
66 #[test]
67 fn guard_skips_when_env_missing() {
68 let temp = GuardedPath::tempdir().unwrap();
69 let root = guard_root(&temp);
70
71 let guard_var = "OXDOCK_GUARD_TEST_TOKEN_UNSET";
72 let script = format!(
73 indoc!(
74 r#"
75 [env:{guard}] WRITE skipped.txt hi
76 WRITE kept.txt ok
77 "#
78 ),
79 guard = guard_var
80 );
81 let steps = parse_script(&script).unwrap();
82
83 run_steps(&root, &steps).unwrap();
84
85 assert!(
86 !exists(&root, "skipped.txt"),
87 "guarded WRITE should be skipped"
88 );
89 assert!(exists(&root, "kept.txt"), "unguarded WRITE should run");
90 }
91
92 #[test]
93 fn guard_sees_env_set_by_env_step() {
94 let temp = GuardedPath::tempdir().unwrap();
95 let root = guard_root(&temp);
96
97 let script = indoc!(
98 r#"
99 ENV FOO=1
100 [env:FOO] WRITE hit.txt yes
101 WRITE always.txt ok
102 "#
103 );
104 let steps = parse_script(script).unwrap();
105
106 run_steps(&root, &steps).unwrap();
107
108 assert!(
109 exists(&root, "hit.txt"),
110 "guarded WRITE should run after ENV sets variable"
111 );
112 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
113 }
114
115 #[test]
116 fn echo_runs_and_allows_subsequent_steps() {
117 let temp = GuardedPath::tempdir().unwrap();
118 let root = guard_root(&temp);
119
120 let script = indoc!(
121 r#"
122 ECHO Hello, world
123 WRITE always.txt ok
124 "#
125 );
126 let steps = parse_script(script).unwrap();
127
128 run_steps(&root, &steps).unwrap();
129
130 assert!(exists(&root, "always.txt"), "WRITE after ECHO should run");
131 }
132
133 #[test]
134 fn guard_on_previous_line_applies_to_next_command() {
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]
142 WRITE hit.txt yes
143 WRITE always.txt ok
144 "#
145 );
146 let steps = parse_script(script).unwrap();
147
148 run_steps(&root, &steps).unwrap();
149
150 assert!(
151 exists(&root, "hit.txt"),
152 "guarded WRITE on next line should run"
153 );
154 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
155 }
156
157 #[test]
158 fn guard_respects_platform_negation() {
159 let temp = GuardedPath::tempdir().unwrap();
160 let root = guard_root(&temp);
161
162 let script = indoc!(
163 r#"
164 [!unix] WRITE platform.txt hi
165 WRITE always.txt ok
166 "#
167 );
168 let steps = parse_script(script).unwrap();
169
170 run_steps(&root, &steps).unwrap();
171
172 #[allow(clippy::disallowed_macros)]
173 let expect_skipped = cfg!(unix);
174 assert_eq!(
175 exists(&root, "platform.txt"),
176 !expect_skipped,
177 "platform guard should skip on unix and run elsewhere"
178 );
179 assert!(exists(&root, "always.txt"), "unguarded WRITE should run");
180 }
181
182 #[test]
183 fn guard_block_env_scope_restores_after_exit() {
184 let temp = GuardedPath::tempdir().unwrap();
185 let root = guard_root(&temp);
186
187 let script = indoc!(
188 r#"
189 ENV RUN=1
190 [env:RUN] {
191 ENV INNER=1
192 WRITE scoped.txt hit
193 }
194 [env:INNER] WRITE leak.txt nope
195 "#
196 );
197 let steps = parse_script(script).unwrap();
198
199 run_steps(&root, &steps).unwrap();
200
201 assert!(exists(&root, "scoped.txt"), "block should run");
202 assert!(
203 !exists(&root, "leak.txt"),
204 "env set inside block must not leak outward"
205 );
206 }
207
208 #[test]
209 fn guard_block_workdir_scope_restores_after_exit() {
210 let temp = GuardedPath::tempdir().unwrap();
211 let root = guard_root(&temp);
212
213 let script = indoc!(
214 r#"
215 MKDIR nested
216 ENV RUN=1
217 [env:RUN] {
218 WORKDIR nested
219 WRITE inside.txt ok
220 }
221 WRITE outside.txt root
222 "#
223 );
224 let steps = parse_script(script).unwrap();
225
226 run_steps(&root, &steps).unwrap();
227
228 assert!(
229 exists(&root, "nested/inside.txt"),
230 "inside write should land in nested dir"
231 );
232 assert!(
233 exists(&root, "outside.txt"),
234 "workdir should reset after block exits"
235 );
236 assert!(
237 !exists(&root, "nested/outside.txt"),
238 "writes after block should not stay scoped"
239 );
240 }
241
242 #[test]
243 fn workspace_scope_restores_after_guard_block() {
244 let snapshot = GuardedPath::tempdir().unwrap();
245 let local = GuardedPath::tempdir().unwrap();
246 let snapshot_root = guard_root(&snapshot);
247 let local_root = guard_root(&local);
248
249 let script = indoc!(
250 r#"
251 ENV RUN=1
252 [env:RUN] {
253 WORKSPACE LOCAL
254 WRITE local_only.txt inside
255 }
256 WRITE snapshot_only.txt outside
257 "#
258 );
259 let steps = parse_script(script).unwrap();
260
261 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
262
263 assert!(
264 local_root.join("local_only.txt").unwrap().exists(),
265 "workspace switch inside block should affect local root"
266 );
267 assert!(
268 snapshot_root.join("snapshot_only.txt").unwrap().exists(),
269 "writes after block must target snapshot again"
270 );
271 assert!(
272 !local_root.join("snapshot_only.txt").unwrap().exists(),
273 "workspace should reset after guard block exits"
274 );
275 }
276
277 #[test]
278 fn guard_matches_profile_env() {
279 let temp = GuardedPath::tempdir().unwrap();
281 let root = guard_root(&temp);
282
283 let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
284 let _env_guard = oxdock_sys_test_utils::TestEnvGuard::set("PROFILE", &profile);
285 let script = format!(
286 indoc!(
287 r#"
288 [env:PROFILE=={0}] WRITE hit.txt yes
289 [env:PROFILE!={0}] WRITE miss.txt no
290 "#
291 ),
292 profile
293 );
294
295 let steps = parse_script(&script).unwrap();
296 run_steps(&root, &steps).unwrap();
297
298 assert!(
299 exists(&root, "hit.txt"),
300 "PROFILE-matching guard should run"
301 );
302 assert!(
303 !exists(&root, "miss.txt"),
304 "PROFILE inequality guard should skip for current profile"
305 );
306 }
307
308 #[test]
309 fn multiple_guards_all_must_pass() {
310 let temp = GuardedPath::tempdir().unwrap();
311 let root = guard_root(&temp);
312
313 let key = "OXDOCK_MULTI_GUARD_TEST_PASS";
314 let _env_guard = oxdock_sys_test_utils::TestEnvGuard::set(key, "ok");
315
316 let script = format!(
317 indoc!(
318 r#"
319 [env:{k},env:{k}==ok] WRITE hit.txt yes
320 WRITE always.txt ok
321 "#
322 ),
323 k = key
324 );
325 let steps = parse_script(&script).unwrap();
326 run_steps(&root, &steps).unwrap();
327
328 assert!(
329 exists(&root, "hit.txt"),
330 "guarded step should run when all guards pass"
331 );
332 assert!(exists(&root, "always.txt"), "unguarded step should run");
333 }
334
335 #[test]
336 fn multiple_guards_skip_when_one_fails() {
337 let temp = GuardedPath::tempdir().unwrap();
338 let root = guard_root(&temp);
339
340 let key = "OXDOCK_MULTI_GUARD_TEST_FAIL";
341 let _env_guard = oxdock_sys_test_utils::TestEnvGuard::set(key, "ok");
342
343 let script = format!(
344 indoc!(
345 r#"
346 [env:{k},env:{k}!=ok] WRITE miss.txt yes
347 WRITE always.txt ok
348 "#
349 ),
350 k = key
351 );
352 let steps = parse_script(&script).unwrap();
353 run_steps(&root, &steps).unwrap();
354
355 assert!(
356 !exists(&root, "miss.txt"),
357 "guarded step should skip when any guard fails"
358 );
359 assert!(exists(&root, "always.txt"), "unguarded step should run");
360 }
361
362 #[cfg(unix)]
363 #[test]
364 fn run_bg_exits_success_and_stops_pipeline() {
365 let temp = GuardedPath::tempdir().unwrap();
366 let root = guard_root(&temp);
367
368 let script = "RUN_BG sh -c 'sleep 0.05'";
370 let steps = parse_script(script).unwrap();
371 let res = run_steps(&root, &steps);
372 assert!(res.is_ok(), "RUN_BG success should allow clean exit");
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn run_bg_failure_bubbles_status() {
378 let temp = GuardedPath::tempdir().unwrap();
379 let root = guard_root(&temp);
380
381 let script = "RUN_BG sh -c 'sleep 0.05; exit 7'";
382 let steps = parse_script(script).unwrap();
383 let err = run_steps(&root, &steps).unwrap_err();
384 let msg = err.to_string();
385 assert!(
386 msg.contains("RUN_BG exited with status") || msg.contains("exit status: 7"),
387 "should surface failing RUN_BG exit code"
388 );
389 }
390
391 #[cfg(unix)]
392 #[cfg_attr(
393 miri,
394 ignore = "timing-sensitive background process test is unreliable under Miri"
395 )]
396 #[test]
397 fn run_bg_multiple_stops_on_first_exit_and_does_not_block_steps() {
398 let temp = GuardedPath::tempdir().unwrap();
399 let root = guard_root(&temp);
400
401 let script = indoc! {
402 r#"
403 RUN_BG sh -c 'sleep 0.2; echo one > one.txt'
404 RUN_BG sh -c 'sleep 0.5; echo two > two.txt'
405 WRITE done.txt ok
406 "#
407 };
408
409 let steps = parse_script(script).unwrap();
410 let start = Instant::now();
411 let res = run_steps(&root, &steps);
412 let elapsed = start.elapsed();
413
414 assert!(res.is_ok(), "RUN_BG success should allow clean exit");
415 assert!(
416 exists(&root, "done.txt"),
417 "foreground step should run after spawning backgrounds"
418 );
419 assert!(
420 exists(&root, "one.txt"),
421 "first background should finish and emit output"
422 );
423 assert!(
424 !exists(&root, "two.txt"),
425 "second background should be terminated once the first exits"
426 );
427
428 let upper = 0.45;
429 assert!(
430 elapsed.as_secs_f32() < upper && elapsed.as_secs_f32() > 0.15,
431 "should wait roughly for first background (~0.2s) but not the second (~0.5s); got {elapsed:?}"
432 );
433 }
434
435 #[cfg(unix)]
436 #[test]
437 fn exit_terminates_backgrounds_and_returns_code() {
438 let temp = GuardedPath::tempdir().unwrap();
439 let root = guard_root(&temp);
440
441 let script = indoc! {
442 r#"
443 RUN_BG sh -c 'sleep 1; echo late > late.txt'
444 EXIT 5
445 "#
446 };
447
448 let steps = parse_script(script).unwrap();
449 let err = run_steps(&root, &steps).unwrap_err();
450 let msg = err.to_string();
451 assert!(msg.contains("EXIT requested with code 5"));
452 assert!(
453 !exists(&root, "late.txt"),
454 "background process should be killed when EXIT is hit"
455 );
456 }
457
458 #[test]
459 fn env_applies_to_run_and_background() {
460 let temp = GuardedPath::tempdir().unwrap();
461 let root = guard_root(&temp);
462
463 #[allow(clippy::disallowed_macros)]
464 let script = if cfg!(windows) {
465 indoc! {
466 r#"
467 ENV FOO=bar
468 RUN echo %FOO% > run.txt
469 RUN_BG echo %FOO% > bg.txt
470 "#
471 }
472 } else {
473 indoc! {
474 r#"
475 ENV FOO=bar
476 RUN sh -c 'printf %s "$FOO" > run.txt'
477 RUN_BG sh -c 'printf %s "$FOO" > bg.txt'
478 "#
479 }
480 };
481
482 let steps = parse_script(script).unwrap();
483 run_steps(&root, &steps).unwrap();
484
485 assert_eq!(read_trimmed(&root.join("run.txt").unwrap()), "bar");
486 assert_eq!(read_trimmed(&root.join("bg.txt").unwrap()), "bar");
487 }
488
489 #[test]
490 fn workspace_switches_between_snapshot_and_local() {
491 let snapshot = GuardedPath::tempdir().unwrap();
492 let local = GuardedPath::tempdir().unwrap();
493 let snapshot_root = guard_root(&snapshot);
494 let local_root = guard_root(&local);
495
496 let script = indoc! {
497 r#"
498 WRITE snap.txt snap
499 WORKSPACE LOCAL
500 WRITE local.txt local
501 WORKSPACE SNAPSHOT
502 WRITE snap2.txt again
503 "#
504 };
505
506 let steps = parse_script(script).unwrap();
507 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
508
509 assert!(snapshot_root.join("snap.txt").unwrap().exists());
510 assert!(snapshot_root.join("snap2.txt").unwrap().exists());
511 assert!(local_root.join("local.txt").unwrap().exists());
512 }
513
514 #[test]
515 fn workspace_root_changes_where_slash_points() {
516 let snapshot = GuardedPath::tempdir().unwrap();
517 let local = GuardedPath::tempdir().unwrap();
518 let snapshot_root = guard_root(&snapshot);
519 let local_root = guard_root(&local);
520 let local_client = local_root.join("client").unwrap();
521 create_dirs(&local_client);
522
523 let script = indoc! {
524 r#"
525 WORKSPACE LOCAL
526 WORKDIR /
527 WRITE localroot.txt one
528 WORKDIR client
529 WRITE client.txt two
530 WORKSPACE SNAPSHOT
531 WORKDIR /
532 WRITE snaproot.txt three
533 "#
534 };
535
536 let steps = parse_script(script).unwrap();
537 run_steps_with_context(&snapshot_root, &local_root, &steps).unwrap();
538
539 assert!(local_root.join("localroot.txt").unwrap().exists());
540 assert!(local_client.join("client.txt").unwrap().exists());
541 assert!(snapshot_root.join("snaproot.txt").unwrap().exists());
542 }
543}