oxdock_process/
lib.rs

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