oxdock_process/
lib.rs

1#[cfg(feature = "mock-process")]
2mod mock;
3pub mod serial_cargo_env;
4mod shell;
5
6use anyhow::{Context, Result, bail};
7use oxdock_fs::{GuardedPath, PolicyPath};
8use shell::shell_cmd;
9pub use shell::{ShellLauncher, shell_program};
10use std::collections::HashMap;
11#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
12use std::fs::File;
13#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
14use std::path::{Path, PathBuf};
15#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
16use std::process::{Child, Command as ProcessCommand, ExitStatus, Output as StdOutput, Stdio};
17use std::{
18    ffi::{OsStr, OsString},
19    iter::IntoIterator,
20};
21
22#[cfg(miri)]
23use oxdock_fs::PathResolver;
24
25#[cfg(feature = "mock-process")]
26pub use mock::{MockHandle, MockProcessManager, MockRunCall, MockSpawnCall};
27
28/// Context passed to process managers describing the current execution
29/// environment. Clones are cheap and explicit so background handles can own
30/// their working roots without juggling lifetimes.
31#[derive(Clone, Debug)]
32pub struct CommandContext {
33    cwd: PolicyPath,
34    envs: HashMap<String, String>,
35    cargo_target_dir: GuardedPath,
36    workspace_root: GuardedPath,
37    build_context: GuardedPath,
38}
39
40impl CommandContext {
41    #[allow(clippy::too_many_arguments)]
42    pub fn new(
43        cwd: &PolicyPath,
44        envs: &HashMap<String, String>,
45        cargo_target_dir: &GuardedPath,
46        workspace_root: &GuardedPath,
47        build_context: &GuardedPath,
48    ) -> Self {
49        Self {
50            cwd: cwd.clone(),
51            envs: envs.clone(),
52            cargo_target_dir: cargo_target_dir.clone(),
53            workspace_root: workspace_root.clone(),
54            build_context: build_context.clone(),
55        }
56    }
57
58    pub fn cwd(&self) -> &PolicyPath {
59        &self.cwd
60    }
61
62    pub fn envs(&self) -> &HashMap<String, String> {
63        &self.envs
64    }
65
66    pub fn cargo_target_dir(&self) -> &GuardedPath {
67        &self.cargo_target_dir
68    }
69
70    pub fn workspace_root(&self) -> &GuardedPath {
71        &self.workspace_root
72    }
73
74    pub fn build_context(&self) -> &GuardedPath {
75        &self.build_context
76    }
77}
78
79/// Handle for background processes spawned by a [`ProcessManager`].
80pub trait BackgroundHandle {
81    fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
82    fn kill(&mut self) -> Result<()>;
83    fn wait(&mut self) -> Result<ExitStatus>;
84}
85
86/// Abstraction for running shell commands both in the foreground and
87/// background. `oxdock-core` relies on this trait to decouple the executor
88/// from `std::process::Command`, which in turn enables Miri-friendly test
89/// doubles.
90pub trait ProcessManager: Clone {
91    type Handle: BackgroundHandle;
92
93    fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()>;
94    fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>>;
95    fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle>;
96}
97
98/// Default process manager that shells out using the system shell.
99#[derive(Clone, Default)]
100#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
101pub struct ShellProcessManager;
102
103impl ProcessManager for ShellProcessManager {
104    type Handle = ChildHandle;
105
106    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
107    fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()> {
108        let mut command = shell_cmd(script);
109        apply_ctx(&mut command, ctx);
110        run_cmd(&mut command)
111    }
112
113    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
114    fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>> {
115        let mut command = shell_cmd(script);
116        apply_ctx(&mut command, ctx);
117        command.stdout(Stdio::piped());
118        let output = command
119            .output()
120            .with_context(|| format!("failed to run {:?}", command))?;
121        if !output.status.success() {
122            bail!("command {:?} failed with status {}", command, output.status);
123        }
124        Ok(output.stdout)
125    }
126
127    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
128    fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle> {
129        let mut command = shell_cmd(script);
130        apply_ctx(&mut command, ctx);
131        let child = command
132            .spawn()
133            .with_context(|| format!("failed to spawn {:?}", command))?;
134        Ok(ChildHandle { child })
135    }
136}
137
138#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
139fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
140    // Use command_path to strip Windows verbatim prefixes (\\?\) before passing to Command.
141    // While Rust's std::process::Command handles verbatim paths in current_dir correctly,
142    // environment variables are passed as-is. If we pass a verbatim path in CARGO_TARGET_DIR,
143    // tools that don't understand it (or shell scripts echoing it) might misbehave or produce
144    // unexpected output. Normalizing here ensures consistency.
145    let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
146        PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
147        PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
148    };
149    command.current_dir(cwd_path);
150    command.envs(ctx.envs());
151    command.env(
152        "CARGO_TARGET_DIR",
153        oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
154    );
155}
156
157#[derive(Debug)]
158#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
159pub struct ChildHandle {
160    child: Child,
161}
162
163impl BackgroundHandle for ChildHandle {
164    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
165        Ok(self.child.try_wait()?)
166    }
167
168    fn kill(&mut self) -> Result<()> {
169        if self.child.try_wait()?.is_none() {
170            let _ = self.child.kill();
171        }
172        Ok(())
173    }
174
175    fn wait(&mut self) -> Result<ExitStatus> {
176        Ok(self.child.wait()?)
177    }
178}
179
180// Synthetic process manager for Miri. Commands are interpreted with a tiny
181// shell that supports the patterns exercised in tests: sleep, printf/echo with
182// env interpolation, redirection, and exit codes. IO is routed through the
183// workspace filesystem so we never touch the host.
184#[cfg(miri)]
185#[derive(Clone, Default)]
186pub struct SyntheticProcessManager;
187
188#[cfg(miri)]
189#[derive(Clone)]
190pub struct SyntheticBgHandle {
191    ctx: CommandContext,
192    actions: Vec<Action>,
193    remaining: std::time::Duration,
194    last_polled: std::time::Instant,
195    status: ExitStatus,
196    applied: bool,
197    killed: bool,
198}
199
200#[cfg(miri)]
201#[derive(Clone)]
202enum Action {
203    WriteFile { target: GuardedPath, data: Vec<u8> },
204}
205
206#[cfg(miri)]
207impl BackgroundHandle for SyntheticBgHandle {
208    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
209        if self.killed {
210            self.applied = true;
211            return Ok(Some(self.status));
212        }
213        if self.applied {
214            return Ok(Some(self.status));
215        }
216        let now = std::time::Instant::now();
217        let elapsed = now.saturating_duration_since(self.last_polled);
218        const MAX_ADVANCE: std::time::Duration = std::time::Duration::from_millis(15);
219        let advance = elapsed.min(MAX_ADVANCE).min(self.remaining);
220        self.remaining = self.remaining.saturating_sub(advance);
221        self.last_polled = now;
222
223        if self.remaining.is_zero() {
224            apply_actions(&self.ctx, &self.actions)?;
225            self.applied = true;
226            Ok(Some(self.status))
227        } else {
228            Ok(None)
229        }
230    }
231
232    fn kill(&mut self) -> Result<()> {
233        self.killed = true;
234        Ok(())
235    }
236
237    fn wait(&mut self) -> Result<ExitStatus> {
238        if self.killed {
239            self.applied = true;
240            return Ok(self.status);
241        }
242        if !self.applied {
243            if !self.remaining.is_zero() {
244                std::thread::sleep(self.remaining);
245            }
246            apply_actions(&self.ctx, &self.actions)?;
247            self.applied = true;
248        }
249        Ok(self.status)
250    }
251}
252
253#[cfg(miri)]
254impl ProcessManager for SyntheticProcessManager {
255    type Handle = SyntheticBgHandle;
256
257    fn run(&mut self, ctx: &CommandContext, script: &str) -> Result<()> {
258        let (_out, status) = execute_sync(ctx, script, false)?;
259        if !status.success() {
260            bail!("command {:?} failed with status {}", script, status);
261        }
262        Ok(())
263    }
264
265    fn run_capture(&mut self, ctx: &CommandContext, script: &str) -> Result<Vec<u8>> {
266        let (out, status) = execute_sync(ctx, script, true)?;
267        if !status.success() {
268            bail!("command {:?} failed with status {}", script, status);
269        }
270        Ok(out)
271    }
272
273    fn spawn_bg(&mut self, ctx: &CommandContext, script: &str) -> Result<Self::Handle> {
274        let plan = plan_background(ctx, script)?;
275        Ok(plan)
276    }
277}
278
279#[cfg(miri)]
280fn execute_sync(
281    ctx: &CommandContext,
282    script: &str,
283    capture: bool,
284) -> Result<(Vec<u8>, ExitStatus)> {
285    let mut stdout = Vec::new();
286    let mut status = exit_status_from_code(0);
287    let resolver = PathResolver::new(
288        ctx.workspace_root().as_path(),
289        ctx.build_context().as_path(),
290    )?;
291
292    let script = normalize_shell(script);
293    for raw in script.split(';') {
294        let cmd = raw.trim();
295        if cmd.is_empty() {
296            continue;
297        }
298        let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, capture)?;
299        if sleep_dur > std::time::Duration::ZERO {
300            std::thread::sleep(sleep_dur);
301        }
302        if let Some(action) = action {
303            match action {
304                CommandAction::Write { target, data } => {
305                    if let Some(parent) = target.as_path().parent() {
306                        let parent_guard = GuardedPath::new(target.root(), parent)?;
307                        resolver.create_dir_all(&parent_guard)?;
308                    }
309                    resolver.write_file(&target, &data)?;
310                }
311                CommandAction::Stdout { data } => {
312                    stdout.extend_from_slice(&data);
313                }
314            }
315        }
316        if let Some(code) = exit_code {
317            status = exit_status_from_code(code);
318            break;
319        }
320    }
321
322    Ok((stdout, status))
323}
324
325#[cfg(miri)]
326fn plan_background(ctx: &CommandContext, script: &str) -> Result<SyntheticBgHandle> {
327    let resolver = PathResolver::new(
328        ctx.workspace_root().as_path(),
329        ctx.build_context().as_path(),
330    )?;
331    let mut actions: Vec<Action> = Vec::new();
332    let mut ready = std::time::Duration::ZERO;
333    let mut status = exit_status_from_code(0);
334
335    let script = normalize_shell(script);
336    for raw in script.split(';') {
337        let cmd = raw.trim();
338        if cmd.is_empty() {
339            continue;
340        }
341        let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, false)?;
342        ready += sleep_dur;
343        if let Some(CommandAction::Write { target, data }) = action {
344            actions.push(Action::WriteFile { target, data });
345        }
346        if let Some(code) = exit_code {
347            status = exit_status_from_code(code);
348            break;
349        }
350    }
351
352    let min_ready = std::time::Duration::from_millis(50);
353    ready = ready.max(min_ready);
354
355    let handle = SyntheticBgHandle {
356        ctx: ctx.clone(),
357        actions,
358        remaining: ready,
359        last_polled: std::time::Instant::now(),
360        status,
361        applied: false,
362        killed: false,
363    };
364    Ok(handle)
365}
366
367#[cfg(miri)]
368enum CommandAction {
369    Write { target: GuardedPath, data: Vec<u8> },
370    Stdout { data: Vec<u8> },
371}
372
373#[cfg(miri)]
374fn parse_command(
375    cmd: &str,
376    ctx: &CommandContext,
377    resolver: &PathResolver,
378    capture: bool,
379) -> Result<(Option<CommandAction>, std::time::Duration, Option<i32>)> {
380    let (core, redirect) = split_redirect(cmd);
381    let tokens: Vec<&str> = core.split_whitespace().collect();
382    if tokens.is_empty() {
383        return Ok((None, std::time::Duration::ZERO, None));
384    }
385
386    match tokens[0] {
387        "sleep" => {
388            let dur = tokens
389                .get(1)
390                .and_then(|s| s.parse::<f64>().ok())
391                .unwrap_or(0.0);
392            let duration = std::time::Duration::from_secs_f64(dur);
393            Ok((None, duration, None))
394        }
395        "exit" => {
396            let code = tokens
397                .get(1)
398                .and_then(|s| s.parse::<i32>().ok())
399                .unwrap_or(0);
400            Ok((None, std::time::Duration::ZERO, Some(code)))
401        }
402        "printf" => {
403            let body = extract_body(&core, "printf %s");
404            let expanded = expand_env(&body, ctx);
405            let data = expanded.into_bytes();
406            if let Some(path_str) = redirect {
407                let target = resolve_write(resolver, ctx, &path_str)?;
408                Ok((
409                    Some(CommandAction::Write { target, data }),
410                    std::time::Duration::ZERO,
411                    None,
412                ))
413            } else if capture {
414                Ok((
415                    Some(CommandAction::Stdout { data }),
416                    std::time::Duration::ZERO,
417                    None,
418                ))
419            } else {
420                Ok((None, std::time::Duration::ZERO, None))
421            }
422        }
423        "echo" => {
424            let body = core.strip_prefix("echo").unwrap_or("").trim();
425            let expanded = expand_env(body, ctx);
426            let mut data = expanded.into_bytes();
427            data.push(b'\n');
428            if let Some(path_str) = redirect {
429                let target = resolve_write(resolver, ctx, &path_str)?;
430                Ok((
431                    Some(CommandAction::Write { target, data }),
432                    std::time::Duration::ZERO,
433                    None,
434                ))
435            } else if capture {
436                Ok((
437                    Some(CommandAction::Stdout { data }),
438                    std::time::Duration::ZERO,
439                    None,
440                ))
441            } else {
442                Ok((None, std::time::Duration::ZERO, None))
443            }
444        }
445        _ => {
446            // Fallback: treat as no-op success so Miri tests can proceed.
447            Ok((None, std::time::Duration::ZERO, None))
448        }
449    }
450}
451
452#[cfg(miri)]
453fn resolve_write(resolver: &PathResolver, ctx: &CommandContext, path: &str) -> Result<GuardedPath> {
454    match ctx.cwd() {
455        PolicyPath::Guarded(p) => resolver.resolve_write(p, path),
456        PolicyPath::Unguarded(_) => bail!("unguarded writes not supported in Miri"),
457    }
458}
459
460#[cfg(miri)]
461fn split_redirect(cmd: &str) -> (String, Option<String>) {
462    if let Some(idx) = cmd.find('>') {
463        let (left, right) = cmd.split_at(idx);
464        let path = right.trim_start_matches('>').trim();
465        (left.trim().to_string(), Some(path.to_string()))
466    } else {
467        (cmd.trim().to_string(), None)
468    }
469}
470
471#[cfg(miri)]
472fn extract_body(cmd: &str, prefix: &str) -> String {
473    cmd.strip_prefix(prefix)
474        .unwrap_or(cmd)
475        .trim()
476        .trim_matches('"')
477        .to_string()
478}
479
480#[cfg(miri)]
481fn expand_env(input: &str, ctx: &CommandContext) -> String {
482    let mut out = String::new();
483    let mut chars = input.chars().peekable();
484    while let Some(c) = chars.next() {
485        if c == '$' {
486            if let Some(&'{') = chars.peek() {
487                chars.next();
488                let mut name = String::new();
489                while let Some(&ch) = chars.peek() {
490                    chars.next();
491                    if ch == '}' {
492                        break;
493                    }
494                    name.push(ch);
495                }
496                out.push_str(&env_lookup(&name, ctx));
497            } else {
498                let mut name = String::new();
499                while let Some(&ch) = chars.peek() {
500                    if ch.is_ascii_alphanumeric() || ch == '_' {
501                        name.push(ch);
502                        chars.next();
503                    } else {
504                        break;
505                    }
506                }
507                if name.is_empty() {
508                    out.push('$');
509                } else {
510                    out.push_str(&env_lookup(&name, ctx));
511                }
512            }
513        } else if c == '%' {
514            // Windows-style %VAR%
515            let mut name = String::new();
516            while let Some(&ch) = chars.peek() {
517                chars.next();
518                if ch == '%' {
519                    break;
520                }
521                name.push(ch);
522            }
523            if name.is_empty() {
524                out.push('%');
525            } else {
526                out.push_str(&env_lookup(&name, ctx));
527            }
528        } else {
529            out.push(c);
530        }
531    }
532    out
533}
534
535#[cfg(miri)]
536fn env_lookup(name: &str, ctx: &CommandContext) -> String {
537    if name == "CARGO_TARGET_DIR" {
538        return ctx.cargo_target_dir().display().to_string();
539    }
540    ctx.envs()
541        .get(name)
542        .cloned()
543        .or_else(|| std::env::var(name).ok())
544        .unwrap_or_default()
545}
546
547#[cfg(miri)]
548fn normalize_shell(script: &str) -> String {
549    let trimmed = script.trim();
550    if let Some(rest) = trimmed.strip_prefix("sh -c ") {
551        return rest.trim_matches(&['"', '\''] as &[_]).to_string();
552    }
553    if let Some(rest) = trimmed.strip_prefix("cmd /C ") {
554        return rest.trim_matches(&['"', '\''] as &[_]).to_string();
555    }
556    trimmed.to_string()
557}
558
559#[cfg(miri)]
560fn apply_actions(ctx: &CommandContext, actions: &[Action]) -> Result<()> {
561    let resolver = PathResolver::new(
562        ctx.workspace_root().as_path(),
563        ctx.build_context().as_path(),
564    )?;
565    for action in actions {
566        match action {
567            Action::WriteFile { target, data } => {
568                if let Some(parent) = target.as_path().parent() {
569                    let parent_guard = GuardedPath::new(target.root(), parent)?;
570                    resolver.create_dir_all(&parent_guard)?;
571                }
572                resolver.write_file(target, data)?;
573            }
574        }
575    }
576    Ok(())
577}
578
579#[cfg(miri)]
580fn exit_status_from_code(code: i32) -> ExitStatus {
581    #[cfg(unix)]
582    {
583        use std::os::unix::process::ExitStatusExt;
584        ExitStatusExt::from_raw(code << 8)
585    }
586    #[cfg(windows)]
587    {
588        use std::os::windows::process::ExitStatusExt;
589        ExitStatusExt::from_raw(code as u32)
590    }
591}
592
593#[cfg(not(miri))]
594pub type DefaultProcessManager = ShellProcessManager;
595
596#[cfg(miri)]
597pub type DefaultProcessManager = SyntheticProcessManager;
598
599pub fn default_process_manager() -> DefaultProcessManager {
600    DefaultProcessManager::default()
601}
602
603#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
604fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> {
605    let status = cmd
606        .status()
607        .with_context(|| format!("failed to run {:?}", cmd))?;
608    if !status.success() {
609        bail!("command {:?} failed with status {}", cmd, status);
610    }
611    Ok(())
612}
613
614/// Builder wrapper that centralizes direct usages of `std::process::Command`.
615#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
616pub struct CommandBuilder {
617    inner: ProcessCommand,
618    program: OsString,
619    args: Vec<OsString>,
620    cwd: Option<PathBuf>,
621}
622
623#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
624impl CommandBuilder {
625    pub fn new(program: impl AsRef<OsStr>) -> Self {
626        let prog = program.as_ref().to_os_string();
627        Self {
628            inner: ProcessCommand::new(&prog),
629            program: prog,
630            args: Vec::new(),
631            cwd: None,
632        }
633    }
634
635    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
636        let val = arg.as_ref().to_os_string();
637        self.inner.arg(&val);
638        self.args.push(val);
639        self
640    }
641
642    pub fn args<S, I>(&mut self, args: I) -> &mut Self
643    where
644        S: AsRef<OsStr>,
645        I: IntoIterator<Item = S>,
646    {
647        for arg in args {
648            self.arg(arg);
649        }
650        self
651    }
652
653    pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
654        self.inner.env(key, value);
655        self
656    }
657
658    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
659        self.inner.env_remove(key);
660        self
661    }
662
663    pub fn stdin_file(&mut self, file: File) -> &mut Self {
664        self.inner.stdin(Stdio::from(file));
665        self
666    }
667
668    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
669        let path = dir.as_ref();
670        self.inner.current_dir(path);
671        self.cwd = Some(path.to_path_buf());
672        self
673    }
674
675    pub fn status(&mut self) -> Result<ExitStatus> {
676        #[cfg(miri)]
677        {
678            let snap = self.snapshot();
679            synthetic_status(&snap)
680        }
681
682        #[cfg(not(miri))]
683        {
684            let desc = format!("{:?}", self.inner);
685            let status = self
686                .inner
687                .status()
688                .with_context(|| format!("failed to run {desc}"))?;
689            Ok(status)
690        }
691    }
692
693    pub fn output(&mut self) -> Result<CommandOutput> {
694        #[cfg(miri)]
695        {
696            let snap = self.snapshot();
697            synthetic_output(&snap)
698        }
699
700        #[cfg(not(miri))]
701        {
702            let desc = format!("{:?}", self.inner);
703            let out = self
704                .inner
705                .output()
706                .with_context(|| format!("failed to run {desc}"))?;
707            Ok(CommandOutput::from(out))
708        }
709    }
710
711    pub fn spawn(&mut self) -> Result<ChildHandle> {
712        #[cfg(miri)]
713        {
714            bail!("spawn is not supported under miri synthetic process backend")
715        }
716
717        #[cfg(not(miri))]
718        {
719            let desc = format!("{:?}", self.inner);
720            let child = self
721                .inner
722                .spawn()
723                .with_context(|| format!("failed to spawn {desc}"))?;
724            Ok(ChildHandle { child })
725        }
726    }
727
728    /// Return a lightweight snapshot of the command configuration for testing.
729    pub fn snapshot(&self) -> CommandSnapshot {
730        CommandSnapshot {
731            program: self.program.clone(),
732            args: self.args.clone(),
733            cwd: self.cwd.clone(),
734        }
735    }
736}
737
738#[derive(Clone, Debug, PartialEq, Eq)]
739#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
740pub struct CommandSnapshot {
741    pub program: OsString,
742    pub args: Vec<OsString>,
743    pub cwd: Option<PathBuf>,
744}
745
746pub struct CommandOutput {
747    pub status: ExitStatus,
748    pub stdout: Vec<u8>,
749    pub stderr: Vec<u8>,
750}
751
752impl CommandOutput {
753    pub fn success(&self) -> bool {
754        self.status.success()
755    }
756}
757
758#[allow(clippy::disallowed_types)]
759impl From<StdOutput> for CommandOutput {
760    fn from(value: StdOutput) -> Self {
761        Self {
762            status: value.status,
763            stdout: value.stdout,
764            stderr: value.stderr,
765        }
766    }
767}
768
769#[cfg(miri)]
770fn synthetic_status(snapshot: &CommandSnapshot) -> Result<ExitStatus> {
771    Ok(synthetic_output(snapshot)?.status)
772}
773
774#[cfg(miri)]
775fn synthetic_output(snapshot: &CommandSnapshot) -> Result<CommandOutput> {
776    let program = snapshot.program.to_string_lossy().to_string();
777    let args: Vec<String> = snapshot
778        .args
779        .iter()
780        .map(|a| a.to_string_lossy().to_string())
781        .collect();
782
783    if program == "git" {
784        return simulate_git(&args);
785    }
786    if program == "cargo" {
787        return simulate_cargo(&args);
788    }
789
790    Ok(CommandOutput {
791        status: exit_status_from_code(0),
792        stdout: Vec::new(),
793        stderr: Vec::new(),
794    })
795}
796
797#[cfg(miri)]
798fn simulate_git(args: &[String]) -> Result<CommandOutput> {
799    let mut iter = args.iter();
800    if matches!(iter.next(), Some(arg) if arg == "-C") {
801        let _ = iter.next();
802    }
803    let remaining: Vec<String> = iter.map(|s| s.to_string()).collect();
804
805    if remaining.len() >= 2 && remaining[0] == "rev-parse" && remaining[1] == "HEAD" {
806        return Ok(CommandOutput {
807            status: exit_status_from_code(0),
808            stdout: b"HEAD\n".to_vec(),
809            stderr: Vec::new(),
810        });
811    }
812
813    // Default success for init/add/commit and other read-only queries.
814    Ok(CommandOutput {
815        status: exit_status_from_code(0),
816        stdout: Vec::new(),
817        stderr: Vec::new(),
818    })
819}
820
821#[cfg(miri)]
822fn simulate_cargo(args: &[String]) -> Result<CommandOutput> {
823    // Heuristic: manifests containing "build_exit_fail" should fail to mimic fixture.
824    let mut status = exit_status_from_code(0);
825    if args.iter().any(|a| a.contains("build_exit_fail")) {
826        status = exit_status_from_code(1);
827    }
828    Ok(CommandOutput {
829        status,
830        stdout: Vec::new(),
831        stderr: Vec::new(),
832    })
833}