1use anyhow::{Context, Result, anyhow, bail};
2use std::collections::HashMap;
3use std::io::{self, Write};
4use std::process::ExitStatus;
5
6use oxdock_fs::{EntryKind, GuardedPath, PathResolver, WorkspaceFs};
7use oxdock_parser::{Step, StepKind, WorkspaceTarget};
8use oxdock_process::{
9 BackgroundHandle, BuiltinEnv, CommandContext, ProcessManager, default_process_manager,
10 expand_command_env,
11};
12
13struct ExecState<P: ProcessManager> {
14 fs: Box<dyn WorkspaceFs>,
15 cargo_target_dir: GuardedPath,
16 cwd: GuardedPath,
17 envs: HashMap<String, String>,
18 bg_children: Vec<P::Handle>,
19 scope_stack: Vec<ScopeSnapshot>,
20}
21
22struct ScopeSnapshot {
23 cwd: GuardedPath,
24 root: GuardedPath,
25 envs: HashMap<String, String>,
26}
27
28impl<P: ProcessManager> ExecState<P> {
29 fn command_ctx(&self) -> CommandContext {
30 CommandContext::new(
31 &self.cwd.clone().into(),
32 &self.envs,
33 &self.cargo_target_dir,
34 self.fs.root(),
35 self.fs.build_context(),
36 )
37 }
38}
39
40pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
41 run_steps_with_context(fs_root, fs_root, steps)
42}
43
44pub fn run_steps_with_context(
45 fs_root: &GuardedPath,
46 build_context: &GuardedPath,
47 steps: &[Step],
48) -> Result<()> {
49 run_steps_with_context_result(fs_root, build_context, steps).map(|_| ())
50}
51
52pub fn run_steps_with_context_result(
54 fs_root: &GuardedPath,
55 build_context: &GuardedPath,
56 steps: &[Step],
57) -> Result<GuardedPath> {
58 match run_steps_inner(fs_root, build_context, steps) {
59 Ok(final_cwd) => Ok(final_cwd),
60 Err(err) => {
61 let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
63 let mut primary = chain
64 .first()
65 .cloned()
66 .unwrap_or_else(|| "unknown error".into());
67 let rest = if chain.len() > 1 {
68 let first_cause = chain[1].clone();
69 primary = format!("{primary} ({first_cause})");
70 if chain.len() > 2 {
71 let causes = chain
72 .iter()
73 .skip(2)
74 .map(|s| s.as_str())
75 .collect::<Vec<_>>()
76 .join("\n ");
77 format!("\ncauses:\n {}", causes)
78 } else {
79 String::new()
80 }
81 } else {
82 String::new()
83 };
84 let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
85 let tree = describe_dir(&fs, fs_root, 2, 24);
86 let snapshot = format!(
87 "filesystem snapshot (root {}):\n{}",
88 fs_root.display(),
89 tree
90 );
91 let msg = format!("{}{}\n{}", primary, rest, snapshot);
92 Err(anyhow::anyhow!(msg))
93 }
94 }
95}
96
97fn run_steps_inner(
98 fs_root: &GuardedPath,
99 build_context: &GuardedPath,
100 steps: &[Step],
101) -> Result<GuardedPath> {
102 let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
103 resolver.set_workspace_root(build_context.clone());
104 run_steps_with_fs(Box::new(resolver), steps)
105}
106
107pub fn run_steps_with_fs(fs: Box<dyn WorkspaceFs>, steps: &[Step]) -> Result<GuardedPath> {
108 run_steps_with_manager(fs, steps, default_process_manager())
109}
110
111fn run_steps_with_manager<P: ProcessManager>(
112 fs: Box<dyn WorkspaceFs>,
113 steps: &[Step],
114 process: P,
115) -> Result<GuardedPath> {
116 let fs_root = fs.root().clone();
117 let cwd = fs.root().clone();
118 let build_context = fs.build_context().clone();
119 let envs = BuiltinEnv::collect(&build_context).into_envs();
120 let mut state = ExecState {
121 fs,
122 cargo_target_dir: fs_root.join(".cargo-target")?,
123 cwd,
124 envs,
125 bg_children: Vec::new(),
126 scope_stack: Vec::new(),
127 };
128
129 let mut stdout = io::stdout();
130 let mut proc_mgr = process;
131 execute_steps(&mut state, &mut proc_mgr, steps, false, &mut stdout)?;
132
133 Ok(state.cwd)
134}
135
136fn execute_steps<P: ProcessManager>(
137 state: &mut ExecState<P>,
138 process: &mut P,
139 steps: &[Step],
140 capture_output: bool,
141 out: &mut dyn Write,
142) -> Result<()> {
143 let fs_root = state.fs.root().clone();
144 let build_context = state.fs.build_context().clone();
145
146 let check_bg = |bg: &mut Vec<P::Handle>| -> Result<Option<ExitStatus>> {
147 let mut finished: Option<ExitStatus> = None;
148 for child in bg.iter_mut() {
149 if let Some(status) = child.try_wait()? {
150 finished = Some(status);
151 break;
152 }
153 }
154 if let Some(status) = finished {
155 for child in bg.iter_mut() {
157 if child.try_wait()?.is_none() {
158 let _ = child.kill();
159 let _ = child.wait();
160 }
161 }
162 bg.clear();
163 return Ok(Some(status));
164 }
165 Ok(None)
166 };
167
168 for (idx, step) in steps.iter().enumerate() {
169 if step.scope_enter > 0 {
170 for _ in 0..step.scope_enter {
171 state.scope_stack.push(ScopeSnapshot {
172 cwd: state.cwd.clone(),
173 root: state.fs.root().clone(),
174 envs: state.envs.clone(),
175 });
176 }
177 }
178
179 let should_run = oxdock_parser::guards_allow_any(&step.guards, &state.envs);
180 let step_result = if !should_run {
181 Ok(())
182 } else {
183 (|| {
184 match &step.kind {
185 StepKind::Workdir(path) => {
186 state.cwd = state
187 .fs
188 .resolve_workdir(&state.cwd, path)
189 .with_context(|| format!("step {}: WORKDIR {}", idx + 1, path))?;
190 }
191 StepKind::Workspace(target) => match target {
192 WorkspaceTarget::Snapshot => {
193 state.fs.set_root(fs_root.clone());
194 state.cwd = state.fs.root().clone();
195 }
196 WorkspaceTarget::Local => {
197 state.fs.set_root(build_context.clone());
198 state.cwd = state.fs.root().clone();
199 }
200 },
201 StepKind::Env { key, value } => {
202 let ctx = state.command_ctx();
203 let rendered = expand_command_env(value, &ctx);
204 state.envs.insert(key.clone(), rendered);
205 }
206 StepKind::Run(cmd) => {
207 let ctx = state.command_ctx();
208 let rendered = expand_command_env(cmd, &ctx);
209 if capture_output {
210 let output = process
211 .run_capture(&ctx, &rendered)
212 .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?;
213 out.write_all(&output)?;
214 } else {
215 process
216 .run(&ctx, &rendered)
217 .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?;
218 }
219 }
220 StepKind::Echo(msg) => {
221 let ctx = state.command_ctx();
222 let rendered = expand_command_env(msg, &ctx);
223 writeln!(out, "{}", rendered)?;
224 }
225 StepKind::RunBg(cmd) => {
226 if capture_output {
227 bail!("RUN_BG is not supported inside CAPTURE");
228 }
229 let ctx = state.command_ctx();
230 let rendered = expand_command_env(cmd, &ctx);
231 let child = process
232 .spawn_bg(&ctx, &rendered)
233 .with_context(|| format!("step {}: RUN_BG {}", idx + 1, rendered))?;
234 state.bg_children.push(child);
235 }
236 StepKind::Copy { from, to } => {
237 let from_abs = state
238 .fs
239 .resolve_copy_source(from)
240 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
241 let to_abs = state
242 .fs
243 .resolve_write(&state.cwd, to)
244 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
245 copy_entry(state.fs.as_ref(), &from_abs, &to_abs)
246 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
247 }
248 StepKind::CopyGit { rev, from, to } => {
249 let to_abs = state.fs.resolve_write(&state.cwd, to).with_context(|| {
250 format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
251 })?;
252 state
253 .fs
254 .copy_from_git(rev, from, &to_abs)
255 .with_context(|| {
256 format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
257 })?;
258 }
259
260 StepKind::Symlink { from, to } => {
261 let to_abs = state.fs.resolve_write(&state.cwd, to).with_context(|| {
262 format!("step {}: SYMLINK {} {}", idx + 1, from, to)
263 })?;
264 let from_abs = state.fs.resolve_copy_source(from).with_context(|| {
265 format!("step {}: SYMLINK {} {}", idx + 1, from, to)
266 })?;
267 state.fs.symlink(&from_abs, &to_abs).with_context(|| {
268 format!("step {}: SYMLINK {} {}", idx + 1, from, to)
269 })?;
270 }
271 StepKind::Mkdir(path) => {
272 let target = state
273 .fs
274 .resolve_write(&state.cwd, path)
275 .with_context(|| format!("step {}: MKDIR {}", idx + 1, path))?;
276 state.fs.create_dir_all(&target).with_context(|| {
277 format!("failed to create dir {}", target.display())
278 })?;
279 }
280 StepKind::Ls(path_opt) => {
281 let dir = if let Some(p) = path_opt.as_deref() {
282 state
283 .fs
284 .resolve_read(&state.cwd, p)
285 .with_context(|| format!("step {}: LS {}", idx + 1, p))?
286 } else {
287 state.cwd.clone()
288 };
289 let mut entries = state
290 .fs
291 .read_dir_entries(&dir)
292 .with_context(|| format!("failed to read dir {}", dir.display()))?;
293 entries.sort_by_key(|a| a.file_name());
294 writeln!(out, "{}:", dir.display())?;
295 for entry in entries {
296 writeln!(out, "{}", entry.file_name().to_string_lossy())?;
297 }
298 }
299 StepKind::Cwd => {
300 let real =
302 canonical_cwd(state.fs.as_ref(), &state.cwd).with_context(|| {
303 format!(
304 "step {}: CWD failed to canonicalize {}",
305 idx + 1,
306 state.cwd.display()
307 )
308 })?;
309 writeln!(out, "{}", real)?;
310 }
311 StepKind::Cat(path) => {
312 let target = state
313 .fs
314 .resolve_read(&state.cwd, path)
315 .with_context(|| format!("step {}: CAT {}", idx + 1, path))?;
316 let data = state
317 .fs
318 .read_file(&target)
319 .with_context(|| format!("failed to read {}", target.display()))?;
320 out.write_all(&data).with_context(|| {
321 format!("failed to write {} to stdout", target.display())
322 })?;
323 }
324 StepKind::Write { path, contents } => {
325 let target = state
326 .fs
327 .resolve_write(&state.cwd, path)
328 .with_context(|| format!("step {}: WRITE {}", idx + 1, path))?;
329 state.fs.ensure_parent_dir(&target).with_context(|| {
330 format!("failed to create parent for {}", target.display())
331 })?;
332 let ctx = state.command_ctx();
333 let rendered = expand_command_env(contents, &ctx);
334 state
335 .fs
336 .write_file(&target, rendered.as_bytes())
337 .with_context(|| format!("failed to write {}", target.display()))?;
338 }
339 StepKind::Capture { path, cmd } => {
340 let target = state
341 .fs
342 .resolve_write(&state.cwd, path)
343 .with_context(|| format!("step {}: CAPTURE {}", idx + 1, path))?;
344 state.fs.ensure_parent_dir(&target).with_context(|| {
345 format!("failed to create parent for {}", target.display())
346 })?;
347 let steps = oxdock_parser::parse_script(cmd)
348 .with_context(|| format!("step {}: CAPTURE parse failed", idx + 1))?;
349 if steps.len() != 1 {
350 bail!("CAPTURE expects exactly one instruction");
351 }
352 let mut sub_state = ExecState {
353 fs: {
354 let mut resolver = PathResolver::new(
355 state.fs.root().as_path(),
356 state.fs.build_context().as_path(),
357 )?;
358 resolver.set_workspace_root(state.fs.build_context().clone());
359 Box::new(resolver)
360 },
361 cargo_target_dir: state.cargo_target_dir.clone(),
362 cwd: state.cwd.clone(),
363 envs: state.envs.clone(),
364 bg_children: Vec::new(),
365 scope_stack: Vec::new(),
366 };
367 let mut sub_process = process.clone();
368 let mut buf: Vec<u8> = Vec::new();
369 execute_steps(&mut sub_state, &mut sub_process, &steps, true, &mut buf)?;
370 state
371 .fs
372 .write_file(&target, &buf)
373 .with_context(|| format!("failed to write {}", target.display()))?;
374 }
375 StepKind::Exit(code) => {
376 for child in state.bg_children.iter_mut() {
377 if child.try_wait()?.is_none() {
378 let _ = child.kill();
379 let _ = child.wait();
380 }
381 }
382 state.bg_children.clear();
383 bail!("EXIT requested with code {}", code);
384 }
385 }
386 Ok(())
387 })()
388 };
389
390 let restore_result = restore_scopes(state, step.scope_exit);
391 step_result?;
392 restore_result?;
393 if let Some(status) = check_bg(&mut state.bg_children)? {
394 if status.success() {
395 return Ok(());
396 } else {
397 bail!("RUN_BG exited with status {}", status);
398 }
399 }
400 }
401
402 if !state.bg_children.is_empty() {
403 let mut first = state.bg_children.remove(0);
404 let status = first.wait()?;
405 for child in state.bg_children.iter_mut() {
406 if child.try_wait()?.is_none() {
407 let _ = child.kill();
408 let _ = child.wait();
409 }
410 }
411 state.bg_children.clear();
412 if status.success() {
413 return Ok(());
414 } else {
415 bail!("RUN_BG exited with status {}", status);
416 }
417 }
418
419 Ok(())
420}
421
422fn restore_scopes<P: ProcessManager>(state: &mut ExecState<P>, count: usize) -> Result<()> {
423 for _ in 0..count {
424 let snapshot = state
425 .scope_stack
426 .pop()
427 .ok_or_else(|| anyhow!("scope stack underflow during pop"))?;
428 state.fs.set_root(snapshot.root.clone());
429 state.cwd = snapshot.cwd;
430 state.envs = snapshot.envs;
431 }
432 Ok(())
433}
434
435fn copy_entry(fs: &dyn WorkspaceFs, src: &GuardedPath, dst: &GuardedPath) -> Result<()> {
436 match fs.entry_kind(src)? {
437 EntryKind::Dir => {
438 fs.copy_dir_recursive(src, dst)?;
439 }
440 EntryKind::File => {
441 fs.ensure_parent_dir(dst)?;
442 fs.copy_file(src, dst)?;
443 }
444 }
445 Ok(())
446}
447
448fn canonical_cwd(fs: &dyn WorkspaceFs, cwd: &GuardedPath) -> Result<String> {
449 Ok(fs.canonicalize(cwd)?.display().to_string())
450}
451
452fn describe_dir(
453 fs: &dyn WorkspaceFs,
454 root: &GuardedPath,
455 max_depth: usize,
456 max_entries: usize,
457) -> String {
458 fn helper(
459 fs: &dyn WorkspaceFs,
460 guard_root: &GuardedPath,
461 path: &GuardedPath,
462 depth: usize,
463 max_depth: usize,
464 left: &mut usize,
465 out: &mut String,
466 ) {
467 if *left == 0 {
468 return;
469 }
470 let indent = " ".repeat(depth);
471 if depth > 0 {
472 out.push_str(&format!(
473 "{}{}\n",
474 indent,
475 path.as_path()
476 .file_name()
477 .unwrap_or_default()
478 .to_string_lossy()
479 ));
480 }
481 if depth >= max_depth {
482 return;
483 }
484 let entries = match fs.read_dir_entries(path) {
485 Ok(e) => e,
486 Err(_) => return,
487 };
488 let mut names: Vec<_> = entries.into_iter().collect();
489 names.sort_by_key(|a| a.file_name());
490 for entry in names {
491 if *left == 0 {
492 return;
493 }
494 *left -= 1;
495 let file_type = match entry.file_type() {
496 Ok(ft) => ft,
497 Err(_) => continue,
498 };
499 let p = entry.path();
500 let guarded_child = match GuardedPath::new(guard_root.root(), &p) {
501 Ok(child) => child,
502 Err(_) => continue,
503 };
504 if file_type.is_dir() {
505 helper(
506 fs,
507 guard_root,
508 &guarded_child,
509 depth + 1,
510 max_depth,
511 left,
512 out,
513 );
514 } else {
515 out.push_str(&format!(
516 "{} {}\n",
517 indent,
518 entry.file_name().to_string_lossy()
519 ));
520 }
521 }
522 }
523
524 let mut out = String::new();
525 let mut left = max_entries;
526 helper(fs, root, root, 0, max_depth, &mut left, &mut out);
527 out
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use oxdock_fs::{GuardedPath, MockFs};
534 use oxdock_parser::Guard;
535 use oxdock_process::{MockProcessManager, MockRunCall};
536 use std::collections::HashMap;
537
538 #[test]
539 fn run_records_env_and_cwd() {
540 let root = GuardedPath::new_root_from_str(".").unwrap();
541 let steps = vec![
542 Step {
543 guards: Vec::new(),
544 kind: StepKind::Env {
545 key: "FOO".into(),
546 value: "bar".into(),
547 },
548 scope_enter: 0,
549 scope_exit: 0,
550 },
551 Step {
552 guards: Vec::new(),
553 kind: StepKind::Run("echo hi".into()),
554 scope_enter: 0,
555 scope_exit: 0,
556 },
557 ];
558 let mock = MockProcessManager::default();
559 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
560 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
561 let runs = mock.recorded_runs();
562 assert_eq!(runs.len(), 1);
563 let MockRunCall {
564 script,
565 cwd,
566 envs,
567 cargo_target_dir,
568 } = &runs[0];
569 assert_eq!(script, "echo hi");
570 assert_eq!(cwd, root.as_path());
571 assert_eq!(
572 cargo_target_dir,
573 &root.join(".cargo-target").unwrap().to_path_buf()
574 );
575 assert_eq!(envs.get("FOO"), Some(&"bar".into()));
576 }
577
578 #[test]
579 fn run_expands_env_values() {
580 let root = GuardedPath::new_root_from_str(".").unwrap();
581 let steps = vec![
582 Step {
583 guards: Vec::new(),
584 kind: StepKind::Env {
585 key: "FOO".into(),
586 value: "bar".into(),
587 },
588 scope_enter: 0,
589 scope_exit: 0,
590 },
591 Step {
592 guards: Vec::new(),
593 kind: StepKind::Run("echo ${FOO}".into()),
594 scope_enter: 0,
595 scope_exit: 0,
596 },
597 ];
598 let mock = MockProcessManager::default();
599 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
600 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
601 let runs = mock.recorded_runs();
602 assert_eq!(runs.len(), 1);
603 assert_eq!(runs[0].script, "echo bar");
604 }
605
606 #[test]
607 fn run_bg_completion_short_circuits_pipeline() {
608 let root = GuardedPath::new_root_from_str(".").unwrap();
609 let steps = vec![
610 Step {
611 guards: Vec::new(),
612 kind: StepKind::RunBg("sleep".into()),
613 scope_enter: 0,
614 scope_exit: 0,
615 },
616 Step {
617 guards: Vec::new(),
618 kind: StepKind::Run("echo after".into()),
619 scope_enter: 0,
620 scope_exit: 0,
621 },
622 ];
623 let mock = MockProcessManager::default();
624 mock.push_bg_plan(0, success_status());
625 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
626 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
627 assert!(
628 mock.recorded_runs().is_empty(),
629 "foreground run should not execute when RUN_BG completes early"
630 );
631 let spawns = mock.spawn_log();
632 let spawned: Vec<_> = spawns.iter().map(|c| c.script.as_str()).collect();
633 assert_eq!(spawned, vec!["sleep"]);
634 }
635
636 #[test]
637 fn exit_kills_background_processes() {
638 let root = GuardedPath::new_root_from_str(".").unwrap();
639 let steps = vec![
640 Step {
641 guards: Vec::new(),
642 kind: StepKind::RunBg("bg-task".into()),
643 scope_enter: 0,
644 scope_exit: 0,
645 },
646 Step {
647 guards: Vec::new(),
648 kind: StepKind::Exit(5),
649 scope_enter: 0,
650 scope_exit: 0,
651 },
652 ];
653 let mock = MockProcessManager::default();
654 mock.push_bg_plan(usize::MAX, success_status());
655 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
656 let err = run_steps_with_manager(fs, &steps, mock.clone()).unwrap_err();
657 assert!(
658 err.to_string().contains("EXIT requested with code 5"),
659 "unexpected error: {err}"
660 );
661 assert_eq!(mock.killed(), vec!["bg-task"]);
662 }
663
664 #[test]
665 fn symlink_errors_report_underlying_cause() {
666 let temp = GuardedPath::tempdir().unwrap();
667 let root = temp.as_guarded_path().clone();
668 let steps = vec![
669 Step {
670 guards: Vec::new(),
671 kind: StepKind::Mkdir("client".into()),
672 scope_enter: 0,
673 scope_exit: 0,
674 },
675 Step {
676 guards: Vec::new(),
677 kind: StepKind::Symlink {
678 from: "client".into(),
679 to: "client".into(),
680 },
681 scope_enter: 0,
682 scope_exit: 0,
683 },
684 ];
685 let err = run_steps(&root, &steps).unwrap_err();
686 let msg = err.to_string();
687 assert!(
688 msg.contains("step 2: SYMLINK client client"),
689 "error should include step context: {msg}"
690 );
691 assert!(
692 msg.contains("SYMLINK destination already exists"),
693 "error should surface underlying cause: {msg}"
694 );
695 }
696
697 #[test]
698 fn guarded_run_waits_for_env_to_be_set() {
699 let root = GuardedPath::new_root_from_str(".").unwrap();
700 let guard = Guard::EnvEquals {
701 key: "READY".into(),
702 value: "1".into(),
703 invert: false,
704 };
705 let steps = vec![
706 Step {
707 guards: vec![vec![guard.clone()]],
708 kind: StepKind::Run("echo first".into()),
709 scope_enter: 0,
710 scope_exit: 0,
711 },
712 Step {
713 guards: Vec::new(),
714 kind: StepKind::Env {
715 key: "READY".into(),
716 value: "1".into(),
717 },
718 scope_enter: 0,
719 scope_exit: 0,
720 },
721 Step {
722 guards: vec![vec![guard]],
723 kind: StepKind::Run("echo second".into()),
724 scope_enter: 0,
725 scope_exit: 0,
726 },
727 ];
728 let mock = MockProcessManager::default();
729 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
730 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
731 let runs = mock.recorded_runs();
732 assert_eq!(runs.len(), 1);
733 assert_eq!(runs[0].script, "echo second");
734 }
735
736 #[test]
737 fn guard_groups_allow_any_matching_branch() {
738 let root = GuardedPath::new_root_from_str(".").unwrap();
739 let guard_alpha = Guard::EnvEquals {
740 key: "MODE".into(),
741 value: "alpha".into(),
742 invert: false,
743 };
744 let guard_beta = Guard::EnvEquals {
745 key: "MODE".into(),
746 value: "beta".into(),
747 invert: false,
748 };
749 let steps = vec![
750 Step {
751 guards: Vec::new(),
752 kind: StepKind::Env {
753 key: "MODE".into(),
754 value: "beta".into(),
755 },
756 scope_enter: 0,
757 scope_exit: 0,
758 },
759 Step {
760 guards: vec![vec![guard_alpha], vec![guard_beta]],
761 kind: StepKind::Run("echo guarded".into()),
762 scope_enter: 0,
763 scope_exit: 0,
764 },
765 ];
766 let mock = MockProcessManager::default();
767 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
768 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
769 let runs = mock.recorded_runs();
770 assert_eq!(runs.len(), 1);
771 assert_eq!(runs[0].script, "echo guarded");
772 }
773
774 #[test]
775 fn capture_rejects_multiple_instructions() {
776 let root = GuardedPath::new_root_from_str(".").unwrap();
777 let capture = Step {
778 guards: Vec::new(),
779 kind: StepKind::Capture {
780 path: "out.txt".into(),
781 cmd: "WRITE one 1; WRITE two 2".into(),
782 },
783 scope_enter: 0,
784 scope_exit: 0,
785 };
786 let mock = MockProcessManager::default();
787 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
788 let err = run_steps_with_manager(fs, &[capture], mock).unwrap_err();
789 assert!(
790 err.to_string()
791 .contains("CAPTURE expects exactly one instruction"),
792 "unexpected error: {err}"
793 );
794 }
795
796 fn success_status() -> ExitStatus {
797 exit_status_from_code(0)
798 }
799
800 #[cfg(unix)]
801 fn exit_status_from_code(code: i32) -> ExitStatus {
802 use std::os::unix::process::ExitStatusExt;
803 ExitStatusExt::from_raw(code << 8)
804 }
805
806 #[cfg(windows)]
807 fn exit_status_from_code(code: i32) -> ExitStatus {
808 use std::os::windows::process::ExitStatusExt;
809 ExitStatusExt::from_raw(code as u32)
810 }
811
812 fn create_exec_state(fs: MockFs) -> ExecState<MockProcessManager> {
813 let cargo = fs.root().join(".cargo-target").unwrap();
814 ExecState {
815 fs: Box::new(fs.clone()),
816 cargo_target_dir: cargo,
817 cwd: fs.root().clone(),
818 envs: HashMap::new(),
819 bg_children: Vec::new(),
820 scope_stack: Vec::new(),
821 }
822 }
823
824 fn run_with_mock_fs(steps: &[Step]) -> (GuardedPath, HashMap<String, Vec<u8>>) {
825 let fs = MockFs::new();
826 let mut state = create_exec_state(fs.clone());
827 let mut proc = MockProcessManager::default();
828 let mut sink = Vec::new();
829 execute_steps(&mut state, &mut proc, steps, false, &mut sink).unwrap();
830 (state.cwd, fs.snapshot())
831 }
832
833 #[test]
834 fn mock_fs_handles_workdir_and_write() {
835 let steps = vec![
836 Step {
837 guards: Vec::new(),
838 kind: StepKind::Mkdir("app".into()),
839 scope_enter: 0,
840 scope_exit: 0,
841 },
842 Step {
843 guards: Vec::new(),
844 kind: StepKind::Workdir("app".into()),
845 scope_enter: 0,
846 scope_exit: 0,
847 },
848 Step {
849 guards: Vec::new(),
850 kind: StepKind::Write {
851 path: "out.txt".into(),
852 contents: "hi".into(),
853 },
854 scope_enter: 0,
855 scope_exit: 0,
856 },
857 Step {
858 guards: Vec::new(),
859 kind: StepKind::Cat("out.txt".into()),
860 scope_enter: 0,
861 scope_exit: 0,
862 },
863 ];
864 let (_cwd, files) = run_with_mock_fs(&steps);
865 let written = files
866 .iter()
867 .find(|(k, _)| k.ends_with("app/out.txt"))
868 .map(|(_, v)| String::from_utf8_lossy(v).to_string());
869 assert_eq!(written, Some("hi".into()));
870 }
871
872 #[test]
873 fn write_interpolates_env_values() {
874 let steps = vec![
875 Step {
876 guards: Vec::new(),
877 kind: StepKind::Env {
878 key: "FOO".into(),
879 value: "bar".into(),
880 },
881 scope_enter: 0,
882 scope_exit: 0,
883 },
884 Step {
885 guards: Vec::new(),
886 kind: StepKind::Env {
887 key: "BAZ".into(),
888 value: "${FOO}-baz".into(),
889 },
890 scope_enter: 0,
891 scope_exit: 0,
892 },
893 Step {
894 guards: Vec::new(),
895 kind: StepKind::Write {
896 path: "out.txt".into(),
897 contents: "val ${BAZ}".into(),
898 },
899 scope_enter: 0,
900 scope_exit: 0,
901 },
902 ];
903 let (_cwd, files) = run_with_mock_fs(&steps);
904 let written = files
905 .iter()
906 .find(|(k, _)| k.ends_with("out.txt"))
907 .map(|(_, v)| String::from_utf8_lossy(v).to_string());
908 assert_eq!(written, Some("val bar-baz".into()));
909 }
910
911 #[test]
912 fn final_cwd_tracks_last_workdir() {
913 let steps = vec![
914 Step {
915 guards: Vec::new(),
916 kind: StepKind::Write {
917 path: "temp.txt".into(),
918 contents: "123".into(),
919 },
920 scope_enter: 0,
921 scope_exit: 0,
922 },
923 Step {
924 guards: Vec::new(),
925 kind: StepKind::Workdir("sub".into()),
926 scope_enter: 0,
927 scope_exit: 0,
928 },
929 ];
930 let (cwd, snapshot) = run_with_mock_fs(&steps);
931 assert!(
932 cwd.as_path().ends_with("sub"),
933 "expected final cwd to match last WORKDIR, got {}",
934 cwd.display()
935 );
936 let keys: Vec<_> = snapshot.keys().cloned().collect();
937 assert!(
938 keys.iter().any(|path| path.ends_with("temp.txt")),
939 "WRITE should produce temp file, snapshot: {:?}",
940 keys
941 );
942 }
943
944 #[test]
945 fn mock_fs_normalizes_backslash_workdir() {
946 let steps = vec![
947 Step {
948 guards: Vec::new(),
949 kind: StepKind::Mkdir("win\\nested".into()),
950 scope_enter: 0,
951 scope_exit: 0,
952 },
953 Step {
954 guards: Vec::new(),
955 kind: StepKind::Workdir("win\\nested".into()),
956 scope_enter: 0,
957 scope_exit: 0,
958 },
959 Step {
960 guards: Vec::new(),
961 kind: StepKind::Write {
962 path: "inner.txt".into(),
963 contents: "ok".into(),
964 },
965 scope_enter: 0,
966 scope_exit: 0,
967 },
968 ];
969 let (cwd, snapshot) = run_with_mock_fs(&steps);
970 let cwd_display = cwd.display().to_string();
971 assert!(
972 cwd_display.ends_with("win\\nested") || cwd_display.ends_with("win/nested"),
973 "expected cwd to normalize backslashes, got {cwd_display}"
974 );
975 assert!(
976 snapshot
977 .keys()
978 .any(|path| path.ends_with("win/nested/inner.txt")),
979 "expected file under normalized path, snapshot: {:?}",
980 snapshot.keys()
981 );
982 }
983
984 #[cfg(windows)]
985 #[test]
986 fn mock_fs_rejects_absolute_windows_paths() {
987 let steps = vec![Step {
988 guards: Vec::new(),
989 kind: StepKind::Workdir(r"C:\outside".into()),
990 scope_enter: 0,
991 scope_exit: 0,
992 }];
993 let fs = MockFs::new();
994 let mut state = create_exec_state(fs);
995 let mut proc = MockProcessManager::default();
996 let mut sink = Vec::new();
997 let err = execute_steps(&mut state, &mut proc, &steps, false, &mut sink).unwrap_err();
998 let msg = format!("{err:#}");
999 assert!(
1000 msg.contains("escapes allowed root"),
1001 "unexpected error for absolute Windows path: {msg}"
1002 );
1003 }
1004}