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 stdin_piped(&mut self) -> &mut Self {
83        self.inner.stdin(Stdio::piped());
84        self
85    }
86
87    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
88        let path = dir.as_ref();
89        self.inner.current_dir(path);
90        self.cwd = Some(path.to_path_buf());
91        self
92    }
93
94    pub fn status(&mut self) -> Result<ExitStatus> {
95        #[cfg(miri)]
96        {
97            let snap = self.snapshot();
98            synthetic_status(&snap)
99        }
100
101        #[cfg(not(miri))]
102        {
103            let desc = format!("{:?}", self.inner);
104            let status = self
105                .inner
106                .status()
107                .with_context(|| format!("failed to run {desc}"))?;
108            Ok(status)
109        }
110    }
111
112    pub fn output(&mut self) -> Result<CommandOutput> {
113        #[cfg(miri)]
114        {
115            let snap = self.snapshot();
116            synthetic_output(&snap)
117        }
118
119        #[cfg(not(miri))]
120        {
121            let desc = format!("{:?}", self.inner);
122            let out = self
123                .inner
124                .output()
125                .with_context(|| format!("failed to run {desc}"))?;
126            Ok(CommandOutput::from(out))
127        }
128    }
129
130    pub fn spawn(&mut self) -> Result<ChildHandle> {
131        #[cfg(miri)]
132        {
133            bail!("spawn is not supported under miri synthetic process backend")
134        }
135
136        #[cfg(not(miri))]
137        {
138            let desc = format!("{:?}", self.inner);
139            let child = self
140                .inner
141                .spawn()
142                .with_context(|| format!("failed to spawn {desc}"))?;
143            Ok(ChildHandle::new(child, None, Vec::new()))
144        }
145    }
146
147    /// Return a lightweight snapshot of the command configuration for testing.
148    pub fn snapshot(&self) -> CommandSnapshot {
149        CommandSnapshot {
150            program: self.program.clone(),
151            args: self.args.clone(),
152            envs: self.envs.clone(),
153            cwd: self.cwd.clone(),
154        }
155    }
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
159#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
160pub struct CommandSnapshot {
161    pub program: OsString,
162    pub args: Vec<OsString>,
163    pub envs: Vec<(OsString, OsString)>,
164    pub cwd: Option<PathBuf>,
165}
166
167pub struct CommandOutput {
168    pub status: ExitStatus,
169    pub stdout: Vec<u8>,
170    pub stderr: Vec<u8>,
171}
172
173impl CommandOutput {
174    pub fn success(&self) -> bool {
175        self.status.success()
176    }
177}
178
179#[allow(clippy::disallowed_types)]
180impl From<StdOutput> for CommandOutput {
181    fn from(value: StdOutput) -> Self {
182        Self {
183            status: value.status,
184            stdout: value.stdout,
185            stderr: value.stderr,
186        }
187    }
188}
189
190#[cfg(miri)]
191use crate::synthetic::{synthetic_output, synthetic_status};
192
193#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use oxdock_fs::GuardedPath;
198
199    #[test]
200    fn command_builder_snapshot_tracks_configuration() {
201        let temp = GuardedPath::tempdir().expect("tempdir");
202        let dir = temp.as_guarded_path().display().to_string();
203
204        let mut builder = CommandBuilder::new("prog");
205        builder.arg("a").args(["b", "c"]);
206        builder.env("K", "V");
207        builder.env("K", "V2"); // re-set replaces the earlier entry
208        builder.env("GONE", "x");
209        builder.env_remove("GONE");
210        builder.current_dir(&dir);
211
212        let snap = builder.snapshot();
213        assert_eq!(snap.program, OsString::from("prog"));
214        assert_eq!(
215            snap.args,
216            vec![
217                OsString::from("a"),
218                OsString::from("b"),
219                OsString::from("c")
220            ]
221        );
222        assert!(
223            snap.envs
224                .contains(&(OsString::from("K"), OsString::from("V2")))
225        );
226        assert!(
227            !snap.envs.iter().any(|(k, _)| k == "GONE"),
228            "env_remove must drop tracked entries"
229        );
230        assert_eq!(snap.cwd.as_deref(), Some(std::path::Path::new(&dir)));
231    }
232}