Skip to main content

nils_test_support/
cmd.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Output, Stdio};
4
5/// Output captured from a command invocation.
6#[derive(Debug)]
7pub struct CmdOutput {
8    pub code: i32,
9    pub stdout: Vec<u8>,
10    pub stderr: Vec<u8>,
11}
12
13impl CmdOutput {
14    pub fn success(&self) -> bool {
15        self.code == 0
16    }
17
18    pub fn stdout_text(&self) -> String {
19        String::from_utf8_lossy(&self.stdout).to_string()
20    }
21
22    pub fn stderr_text(&self) -> String {
23        String::from_utf8_lossy(&self.stderr).to_string()
24    }
25
26    /// Convert to `std::process::Output` for integration with assertion APIs
27    /// that expect process output semantics.
28    pub fn into_output(self) -> Output {
29        Output {
30            status: exit_status_from_code(self.code),
31            stdout: self.stdout,
32            stderr: self.stderr,
33        }
34    }
35}
36
37#[cfg(unix)]
38fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
39    use std::os::unix::process::ExitStatusExt;
40    let raw = if code >= 0 { code << 8 } else { 1 << 8 };
41    std::process::ExitStatus::from_raw(raw)
42}
43
44#[cfg(windows)]
45fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
46    use std::os::windows::process::ExitStatusExt;
47    let raw = if code >= 0 { code as u32 } else { 1 };
48    std::process::ExitStatus::from_raw(raw)
49}
50
51#[derive(Debug, Clone)]
52pub struct CmdOptions {
53    pub cwd: Option<PathBuf>,
54    pub envs: Vec<(String, String)>,
55    pub env_remove: Vec<String>,
56    pub stdin: Option<Vec<u8>>,
57    pub stdin_null: bool,
58}
59
60impl Default for CmdOptions {
61    fn default() -> Self {
62        Self {
63            cwd: None,
64            envs: Vec::new(),
65            env_remove: Vec::new(),
66            stdin: None,
67            stdin_null: true,
68        }
69    }
70}
71
72impl CmdOptions {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    pub fn with_env_remove_prefix(mut self, prefix: &str) -> Self {
78        for (key, _) in std::env::vars_os() {
79            let key = key.to_string_lossy();
80            if key.starts_with(prefix) {
81                self = self.with_env_remove(&key);
82            }
83        }
84        self
85    }
86
87    pub fn with_path_prepend(self, dir: &Path) -> Self {
88        let base = self
89            .envs
90            .iter()
91            .rev()
92            .find(|(key, _)| key == "PATH")
93            .map(|(_, value)| value.clone())
94            .or_else(|| std::env::var_os("PATH").map(|value| value.to_string_lossy().to_string()))
95            .unwrap_or_default();
96
97        let mut paths: Vec<PathBuf> = std::env::split_paths(std::ffi::OsStr::new(&base)).collect();
98        paths.insert(0, dir.to_path_buf());
99        let joined = std::env::join_paths(paths).expect("join paths");
100        let joined = joined.to_string_lossy().to_string();
101        self.with_env("PATH", &joined)
102    }
103
104    pub fn with_cwd(mut self, dir: &Path) -> Self {
105        self.cwd = Some(dir.to_path_buf());
106        self
107    }
108
109    pub fn with_env(mut self, key: &str, value: &str) -> Self {
110        self.envs.push((key.to_string(), value.to_string()));
111        self
112    }
113
114    pub fn with_env_remove(mut self, key: &str) -> Self {
115        self.env_remove.push(key.to_string());
116        self
117    }
118
119    pub fn with_stdin_bytes(mut self, bytes: &[u8]) -> Self {
120        self.stdin = Some(bytes.to_vec());
121        self
122    }
123
124    pub fn with_stdin_str(mut self, input: &str) -> Self {
125        self.stdin = Some(input.as_bytes().to_vec());
126        self
127    }
128
129    pub fn inherit_stdin(mut self) -> Self {
130        self.stdin_null = false;
131        self
132    }
133}
134
135/// Run a binary with arguments, capturing `code`, `stdout`, and `stderr`.
136///
137/// - `envs` overrides or adds environment variables (existing vars are preserved).
138/// - `stdin` (when `Some`) is piped into the process before waiting for output.
139pub fn run(bin: &Path, args: &[&str], envs: &[(&str, &str)], stdin: Option<&[u8]>) -> CmdOutput {
140    let mut options = CmdOptions::default();
141    for (key, value) in envs {
142        options = options.with_env(key, value);
143    }
144    if let Some(input) = stdin {
145        options = options.with_stdin_bytes(input);
146    }
147    run_with(bin, args, &options)
148}
149
150/// Run a binary in a specific directory.
151pub fn run_in_dir(
152    dir: &Path,
153    bin: &Path,
154    args: &[&str],
155    envs: &[(&str, &str)],
156    stdin: Option<&[u8]>,
157) -> CmdOutput {
158    let mut options = CmdOptions::default().with_cwd(dir);
159    for (key, value) in envs {
160        options = options.with_env(key, value);
161    }
162    if let Some(input) = stdin {
163        options = options.with_stdin_bytes(input);
164    }
165    run_with(bin, args, &options)
166}
167
168/// Build command options with cwd + env pairs for common integration test usage.
169pub fn options_in_dir_with_envs(dir: &Path, envs: &[(&str, &str)]) -> CmdOptions {
170    let mut options = CmdOptions::default().with_cwd(dir);
171    for (key, value) in envs {
172        options = options.with_env(key, value);
173    }
174    options
175}
176
177/// Resolve a workspace binary by name and run it with explicit options.
178pub fn run_resolved(bin_name: &str, args: &[&str], options: &CmdOptions) -> CmdOutput {
179    let bin = crate::bin::resolve(bin_name);
180    run_with(&bin, args, options)
181}
182
183/// Resolve and run a workspace binary in a specific directory.
184pub fn run_resolved_in_dir(
185    bin_name: &str,
186    dir: &Path,
187    args: &[&str],
188    envs: &[(&str, &str)],
189    stdin: Option<&[u8]>,
190) -> CmdOutput {
191    let mut options = options_in_dir_with_envs(dir, envs);
192    if let Some(input) = stdin {
193        options = options.with_stdin_bytes(input);
194    }
195    run_resolved(bin_name, args, &options)
196}
197
198pub fn run_with(bin: &Path, args: &[&str], options: &CmdOptions) -> CmdOutput {
199    run_impl(bin, args, options, None)
200}
201
202pub fn run_in_dir_with(dir: &Path, bin: &Path, args: &[&str], options: &CmdOptions) -> CmdOutput {
203    run_impl(bin, args, options, Some(dir))
204}
205
206fn run_impl(bin: &Path, args: &[&str], options: &CmdOptions, dir: Option<&Path>) -> CmdOutput {
207    let mut cmd = Command::new(bin);
208    if let Some(dir) = dir {
209        cmd.current_dir(dir);
210    } else if let Some(cwd) = options.cwd.as_deref() {
211        cmd.current_dir(cwd);
212    }
213
214    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
215
216    for key in &options.env_remove {
217        cmd.env_remove(key);
218    }
219    for (key, value) in &options.envs {
220        cmd.env(key, value);
221    }
222
223    let output = match options.stdin.as_ref() {
224        Some(input) => {
225            cmd.stdin(Stdio::piped());
226            let mut child = cmd.spawn().expect("spawn command");
227            if let Some(mut writer) = child.stdin.take() {
228                writer.write_all(input).expect("write stdin");
229            }
230            child.wait_with_output().expect("wait command")
231        }
232        None => {
233            if options.stdin_null {
234                cmd.stdin(Stdio::null());
235            }
236            cmd.output().expect("run command")
237        }
238    };
239
240    CmdOutput {
241        code: output.status.code().unwrap_or(-1),
242        stdout: output.stdout,
243        stderr: output.stderr,
244    }
245}