Skip to main content

oxdock_process/
builder.rs

1use std::{
2    ffi::{OsStr, OsString},
3    iter::IntoIterator,
4};
5
6#[cfg(not(miri))]
7use anyhow::Context;
8use anyhow::Result;
9#[cfg(miri)]
10use anyhow::bail;
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::{Command as ProcessCommand, ExitStatus, Output as StdOutput, Stdio};
17
18use crate::child::ChildHandle;
19
20/// Builder wrapper that centralizes direct usages of `std::process::Command`.
21#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
22pub struct CommandBuilder {
23    inner: ProcessCommand,
24    program: OsString,
25    args: Vec<OsString>,
26    envs: Vec<(OsString, OsString)>,
27    cwd: Option<PathBuf>,
28}
29
30#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
31impl CommandBuilder {
32    pub fn new(program: impl AsRef<OsStr>) -> Self {
33        let prog = program.as_ref().to_os_string();
34        Self {
35            inner: ProcessCommand::new(&prog),
36            program: prog,
37            args: Vec::new(),
38            envs: Vec::new(),
39            cwd: None,
40        }
41    }
42
43    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
44        let val = arg.as_ref().to_os_string();
45        self.inner.arg(&val);
46        self.args.push(val);
47        self
48    }
49
50    pub fn args<S, I>(&mut self, args: I) -> &mut Self
51    where
52        S: AsRef<OsStr>,
53        I: IntoIterator<Item = S>,
54    {
55        for arg in args {
56            self.arg(arg);
57        }
58        self
59    }
60
61    pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
62        let key = key.as_ref().to_os_string();
63        let value = value.as_ref().to_os_string();
64        self.inner.env(&key, &value);
65        self.envs.retain(|(k, _)| k != &key);
66        self.envs.push((key, value));
67        self
68    }
69
70    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
71        let key = key.as_ref();
72        self.inner.env_remove(key);
73        self.envs.retain(|(k, _)| k != key);
74        self
75    }
76
77    pub fn stdin_file(&mut self, file: File) -> &mut Self {
78        self.inner.stdin(Stdio::from(file));
79        self
80    }
81
82    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
83        let path = dir.as_ref();
84        self.inner.current_dir(path);
85        self.cwd = Some(path.to_path_buf());
86        self
87    }
88
89    pub fn status(&mut self) -> Result<ExitStatus> {
90        #[cfg(miri)]
91        {
92            let snap = self.snapshot();
93            synthetic_status(&snap)
94        }
95
96        #[cfg(not(miri))]
97        {
98            let desc = format!("{:?}", self.inner);
99            let status = self
100                .inner
101                .status()
102                .with_context(|| format!("failed to run {desc}"))?;
103            Ok(status)
104        }
105    }
106
107    pub fn output(&mut self) -> Result<CommandOutput> {
108        #[cfg(miri)]
109        {
110            let snap = self.snapshot();
111            synthetic_output(&snap)
112        }
113
114        #[cfg(not(miri))]
115        {
116            let desc = format!("{:?}", self.inner);
117            let out = self
118                .inner
119                .output()
120                .with_context(|| format!("failed to run {desc}"))?;
121            Ok(CommandOutput::from(out))
122        }
123    }
124
125    pub fn spawn(&mut self) -> Result<ChildHandle> {
126        #[cfg(miri)]
127        {
128            bail!("spawn is not supported under miri synthetic process backend")
129        }
130
131        #[cfg(not(miri))]
132        {
133            let desc = format!("{:?}", self.inner);
134            let child = self
135                .inner
136                .spawn()
137                .with_context(|| format!("failed to spawn {desc}"))?;
138            Ok(ChildHandle::new(child, Vec::new()))
139        }
140    }
141
142    /// Return a lightweight snapshot of the command configuration for testing.
143    pub fn snapshot(&self) -> CommandSnapshot {
144        CommandSnapshot {
145            program: self.program.clone(),
146            args: self.args.clone(),
147            envs: self.envs.clone(),
148            cwd: self.cwd.clone(),
149        }
150    }
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
154#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
155pub struct CommandSnapshot {
156    pub program: OsString,
157    pub args: Vec<OsString>,
158    pub envs: Vec<(OsString, OsString)>,
159    pub cwd: Option<PathBuf>,
160}
161
162pub struct CommandOutput {
163    pub status: ExitStatus,
164    pub stdout: Vec<u8>,
165    pub stderr: Vec<u8>,
166}
167
168impl CommandOutput {
169    pub fn success(&self) -> bool {
170        self.status.success()
171    }
172}
173
174#[allow(clippy::disallowed_types)]
175impl From<StdOutput> for CommandOutput {
176    fn from(value: StdOutput) -> Self {
177        Self {
178            status: value.status,
179            stdout: value.stdout,
180            stderr: value.stderr,
181        }
182    }
183}
184
185#[cfg(miri)]
186use crate::synthetic::{synthetic_output, synthetic_status};
187
188#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use oxdock_fs::GuardedPath;
193
194    #[test]
195    fn command_builder_snapshot_tracks_configuration() {
196        let temp = GuardedPath::tempdir().expect("tempdir");
197        let dir = temp.as_guarded_path().display().to_string();
198
199        let mut builder = CommandBuilder::new("prog");
200        builder.arg("a").args(["b", "c"]);
201        builder.env("K", "V");
202        builder.env("K", "V2"); // re-set replaces the earlier entry
203        builder.env("GONE", "x");
204        builder.env_remove("GONE");
205        builder.current_dir(&dir);
206
207        let snap = builder.snapshot();
208        assert_eq!(snap.program, OsString::from("prog"));
209        assert_eq!(
210            snap.args,
211            vec![
212                OsString::from("a"),
213                OsString::from("b"),
214                OsString::from("c")
215            ]
216        );
217        assert!(
218            snap.envs
219                .contains(&(OsString::from("K"), OsString::from("V2")))
220        );
221        assert!(
222            !snap.envs.iter().any(|(k, _)| k == "GONE"),
223            "env_remove must drop tracked entries"
224        );
225        assert_eq!(snap.cwd.as_deref(), Some(std::path::Path::new(&dir)));
226    }
227}