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