oxdock_core/
lib.rs

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        // Cargo sets PROFILE during builds/tests; verify guards see it.
280        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        // Background succeeds quickly; pipeline should complete without error.
369        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}