subplotlib/steplibrary/runcmd.rs
1//! Step library for running subprocesses as part of scenarios
2
3use regex::RegexBuilder;
4
5pub use super::datadir::Datadir;
6pub use crate::prelude::*;
7
8use std::collections::HashMap;
9use std::env::{self, JoinPathsError};
10use std::ffi::{OsStr, OsString};
11use std::fmt::Debug;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16/// The Runcmd context gives a step function access to the ability to run
17/// subprocesses as part of a scenario. These subprocesses are run with
18/// various environment variables set, and we record the stdout/stderr
19/// of the most recent-to-run command for testing purposes.
20#[derive(Default)]
21pub struct Runcmd {
22 env: HashMap<OsString, OsString>,
23 // push to "prepend", order reversed when added to env
24 paths: Vec<OsString>,
25 // The following are the result of any executed command
26 exitcode: Option<i32>,
27 stdout: Vec<u8>,
28 stderr: Vec<u8>,
29}
30
31impl Debug for Runcmd {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 f.debug_struct("Runcmd")
34 .field("env", &self.env)
35 .field("paths", &self.paths)
36 .field("exitcode", &self.exitcode)
37 .field("stdout", &String::from_utf8_lossy(&self.stdout))
38 .field("stderr", &String::from_utf8_lossy(&self.stderr))
39 .finish()
40 }
41}
42
43// Note, this prefix requires that the injection env vars must have
44// names which are valid unicode (and ideally ASCII)
45const ENV_INJECTION_PREFIX: &str = "SUBPLOT_ENV_";
46
47#[cfg(not(windows))]
48static DEFAULT_PATHS: &[&str] = &["/usr/bin", "/bin"];
49
50// Note, this comes from https://www.computerhope.com/issues/ch000549.htm#defaultpath
51#[cfg(windows)]
52static DEFAULT_PATHS: &[&str] = &[
53 r"%SystemRoot%\system32",
54 r"%SystemRoot%",
55 r"%SystemRoot%\System32\Wbem",
56];
57
58// This us used internally to force CWD for running commands
59lazy_static! {
60 static ref USE_CWD: PathBuf = PathBuf::from("\0USE_CWD");
61}
62
63impl ContextElement for Runcmd {
64 fn scenario_starts(&mut self) -> StepResult {
65 self.env.drain();
66 self.paths.drain(..);
67 self.env.insert("SHELL".into(), "/bin/sh".into());
68 self.env.insert(
69 "PATH".into(),
70 env::var_os("PATH")
71 .map(Ok)
72 .unwrap_or_else(|| env::join_paths(DEFAULT_PATHS.iter()))?,
73 );
74
75 // Having assembled the 'default' environment, override it with injected
76 // content from the calling environment.
77 for (k, v) in env::vars_os() {
78 if let Some(k) = k.to_str() {
79 if let Some(k) = k.strip_prefix(ENV_INJECTION_PREFIX) {
80 self.env.insert(k.into(), v);
81 }
82 }
83 }
84 Ok(())
85 }
86}
87
88impl Runcmd {
89 /// Prepend the given location to the run path
90 pub fn prepend_to_path<S: Into<OsString>>(&mut self, element: S) {
91 self.paths.push(element.into());
92 }
93
94 /// Retrieve the last run command's stdout as a string.
95 ///
96 /// This does a lossy conversion from utf8 so should always succeed.
97 pub fn stdout_as_string(&self) -> String {
98 String::from_utf8_lossy(&self.stdout).into_owned()
99 }
100
101 /// Retrieve the last run command's stderr as a string.
102 ///
103 /// This does a lossy conversion from utf8 so should always succeed.
104 pub fn stderr_as_string(&self) -> String {
105 String::from_utf8_lossy(&self.stderr).into_owned()
106 }
107
108 /// Set an env var in the Runcmd context
109 ///
110 /// This sets an environment variable into the Runcmd context for use
111 /// during execution
112 pub fn setenv<K: Into<OsString>, V: Into<OsString>>(&mut self, key: K, value: V) {
113 self.env.insert(key.into(), value.into());
114 }
115
116 /// Get an env var from the Runcmd context
117 ///
118 /// This retrieves a set environment variable from the Runcmd context
119 pub fn getenv<K: AsRef<OsStr>>(&self, key: K) -> Option<&OsStr> {
120 self.env.get(key.as_ref()).map(OsString::as_os_str)
121 }
122
123 /// Unset an env var in the Runcmd context
124 ///
125 /// This removes an environment variable (if set) from the Runcmd context
126 /// and returns whether or not it was removed.
127 pub fn unsetenv<K: AsRef<OsStr>>(&mut self, key: K) -> bool {
128 self.env.remove(key.as_ref()).is_some()
129 }
130
131 /// Join the `PATH` environment variable and the `paths` attribute
132 /// together properly.
133 ///
134 /// This prepends the paths (in reverse order) to the `PATH` environment
135 /// variable and then returns it.
136 ///
137 /// If there is no `PATH` in the stored environment then the resultant
138 /// path will be entirely made up of the pushed path elements
139 ///
140 /// ```
141 /// # use subplotlib::steplibrary::runcmd::Runcmd;
142 ///
143 /// let mut rc = Runcmd::default();
144 ///
145 /// assert_eq!(rc.join_paths().unwrap(), "");
146 ///
147 /// rc.setenv("PATH", "one");
148 /// assert_eq!(rc.join_paths().unwrap(), "one");
149 ///
150 /// rc.prepend_to_path("two");
151 /// assert_eq!(rc.join_paths().unwrap(), "two:one");
152 ///
153 /// rc.unsetenv("PATH");
154 /// assert_eq!(rc.join_paths().unwrap(), "two");
155 ///
156 /// rc.prepend_to_path("three");
157 /// assert_eq!(rc.join_paths().unwrap(), "three:two");
158 ///
159 /// rc.setenv("PATH", "one");
160 /// assert_eq!(rc.join_paths().unwrap(), "three:two:one");
161 /// ```
162 ///
163 pub fn join_paths(&self) -> Result<OsString, JoinPathsError> {
164 let curpath = self
165 .env
166 .get(OsStr::new("PATH"))
167 .map(|s| s.as_os_str())
168 .unwrap_or_else(|| OsStr::new(""));
169 env::join_paths(
170 self.paths
171 .iter()
172 .rev()
173 .map(PathBuf::from)
174 .chain(env::split_paths(curpath).filter(|p| p != Path::new(""))),
175 )
176 }
177}
178
179/// Ensure the given data file is available as a script in the data dir
180///
181/// # `given helper script {script} for runcmd`
182///
183/// ## Note
184///
185/// Currently this does not make the script file executable, so you will
186/// need to invoke it by means of an interpreter
187#[step]
188pub fn helper_script(context: &Datadir, script: SubplotDataFile) {
189 context
190 .open_write(script.name())?
191 .write_all(script.data())?;
192}
193
194/// Ensure that the base source directory is in the `PATH` for subsequent
195/// commands being run.
196///
197/// # `given srcdir is in the PATH`
198///
199/// This inserts the `CARGO_MANIFEST_DIR` into the `PATH` environment
200/// variable at the front.
201#[step]
202pub fn helper_srcdir_path(context: &mut Runcmd) {
203 context.prepend_to_path(env!("CARGO_MANIFEST_DIR"));
204}
205
206/// Run the given command, ensuring it succeeds
207///
208/// # `when I run {argv0}{args:text}`
209///
210/// This will run the given command, with the given arguments,
211/// in the "current" directory (from where the tests were invoked)
212/// Once the command completes, this will check that it exited with
213/// a zero code (success).
214#[step]
215#[context(Datadir)]
216#[context(Runcmd)]
217pub fn run(context: &ScenarioContext, argv0: &str, args: &str) {
218 try_to_run::call(context, argv0, args)?;
219 exit_code_is::call(context, 0)?;
220}
221
222/// Run the given command in the given subpath of the data directory,
223/// ensuring that it succeeds.
224///
225/// # `when I run, in {dirname}, {argv0}{args:text}`
226///
227/// Like `run`, this will execute the given command, but this time it
228/// will set the working directory to the given subpath of the data dir.
229/// Once the command completes, this will check that it exited with
230/// a zero code (success)
231#[step]
232#[context(Datadir)]
233#[context(Runcmd)]
234pub fn run_in(context: &ScenarioContext, dirname: &Path, argv0: &str, args: &str) {
235 try_to_run_in::call(context, dirname, argv0, args)?;
236 exit_code_is::call(context, 0)?;
237}
238
239/// Run the given command
240///
241/// # `when I try to run {argv0}{args:text}`
242///
243/// This will run the given command, with the given arguments,
244/// in the "current" directory (from where the tests were invoked)
245#[step]
246#[context(Datadir)]
247#[context(Runcmd)]
248pub fn try_to_run(context: &ScenarioContext, argv0: &str, args: &str) {
249 try_to_run_in::call(context, &USE_CWD, argv0, args)?;
250}
251
252/// Run the given command in the given subpath of the data directory
253///
254/// # `when I try to run, in {dirname}, {argv0}{args:text}`
255///
256/// Like `try_to_run`, this will execute the given command, but this time it
257/// will set the working directory to the given subpath of the data dir.
258#[step]
259#[context(Datadir)]
260#[context(Runcmd)]
261pub fn try_to_run_in(context: &ScenarioContext, dirname: &Path, argv0: &str, args: &str) {
262 // This is the core of runcmd and is how we handle things
263 let argv0: PathBuf = if argv0.starts_with('.') {
264 context.with(
265 |datadir: &Datadir| datadir.canonicalise_filename(argv0),
266 false,
267 )?
268 } else {
269 argv0.into()
270 };
271 let mut datadir = context.with(
272 |datadir: &Datadir| Ok(datadir.base_path().to_path_buf()),
273 false,
274 )?;
275 if dirname != USE_CWD.as_path() {
276 datadir = datadir.join(dirname);
277 }
278 let mut proc = Command::new(&argv0);
279 let args = shell_words::split(args)?;
280 proc.args(&args);
281 proc.current_dir(&datadir);
282
283 println!(
284 "Running `{}` with args {:?}\nRunning in {}",
285 argv0.display(),
286 args,
287 datadir.display()
288 );
289 proc.env("HOME", &datadir);
290 proc.env("TMPDIR", &datadir);
291
292 context.with(
293 |runcmd: &Runcmd| {
294 for (k, v) in runcmd
295 .env
296 .iter()
297 .filter(|(k, _)| k.to_str() != Some("PATH"))
298 {
299 println!("ENV: {} = {}", k.to_string_lossy(), v.to_string_lossy());
300 proc.env(k, v);
301 }
302 Ok(())
303 },
304 false,
305 )?;
306
307 let path = context.with(|runcmd: &Runcmd| Ok(runcmd.join_paths()?), false)?;
308 proc.env("PATH", &path);
309 println!("PATH: {}", path.to_string_lossy());
310 proc.stdin(Stdio::null())
311 .stdout(Stdio::piped())
312 .stderr(Stdio::piped());
313 let mut output = proc.output()?;
314 context.with_mut(
315 |runcmd: &mut Runcmd| {
316 std::mem::swap(&mut runcmd.stdout, &mut output.stdout);
317 std::mem::swap(&mut runcmd.stderr, &mut output.stderr);
318 runcmd.exitcode = output.status.code();
319 println!("Exit code: {}", runcmd.exitcode.unwrap_or(-1));
320 println!(
321 "Stdout:\n{}\nStderr:\n{}\n",
322 runcmd.stdout_as_string(),
323 runcmd.stderr_as_string()
324 );
325 Ok(())
326 },
327 false,
328 )?;
329}
330
331/// Check that an executed command returns a specific exit code
332///
333/// # `then exit code is {exit}`
334///
335/// Check that the exit code of the previously run command matches
336/// the given value. Typically zero is success.
337#[step]
338pub fn exit_code_is(context: &Runcmd, exit: i32) {
339 if context.exitcode != Some(exit) {
340 throw!(format!(
341 "expected exit code {}, but had {:?}",
342 exit, context.exitcode
343 ));
344 }
345}
346
347/// Check that an executed command returns a specific exit code
348///
349/// # `then exit code is not {exit}`
350///
351/// Check that the exit code of the previously run command does not
352/// matche the given value.
353#[step]
354pub fn exit_code_is_not(context: &Runcmd, exit: i32) {
355 if context.exitcode.is_none() || context.exitcode == Some(exit) {
356 throw!(format!("Expected exit code to not equal {exit}"));
357 }
358}
359
360/// Check that an executed command succeeded
361///
362/// # `then command is successful`
363///
364/// This is equivalent to `then exit code is 0`
365#[step]
366#[context(Runcmd)]
367pub fn exit_code_is_zero(context: &ScenarioContext) {
368 exit_code_is::call(context, 0)?;
369}
370
371/// Check that an executed command did not succeed
372///
373/// # `then command fails`
374///
375/// This is equivalent to `then exit code is not 0`
376#[step]
377#[context(Runcmd)]
378pub fn exit_code_is_nonzero(context: &ScenarioContext) {
379 exit_code_is_not::call(context, 0)?;
380}
381
382enum Stream {
383 Stdout,
384 Stderr,
385}
386enum MatchKind {
387 Exact,
388 Contains,
389 Regex,
390}
391
392#[throws(StepError)]
393fn check_matches(runcmd: &Runcmd, which: Stream, how: MatchKind, against: &str) -> bool {
394 let stream = match which {
395 Stream::Stdout => &runcmd.stdout,
396 Stream::Stderr => &runcmd.stderr,
397 };
398 match how {
399 MatchKind::Exact => stream.as_slice() == against.as_bytes(),
400 MatchKind::Contains => stream
401 .windows(against.len())
402 .any(|window| window == against.as_bytes()),
403 MatchKind::Regex => {
404 let stream = String::from_utf8_lossy(stream);
405 let regex = RegexBuilder::new(against).multi_line(true).build()?;
406 regex.is_match(&stream)
407 }
408 }
409}
410
411/// Check that the stdout of the command matches exactly
412///
413/// # `then stdout is exactly "{text:text}"`
414///
415/// This will check exactly that the stdout of the command matches the given
416/// text. This assumes the command outputs valid utf-8 and decodes it as such.
417#[step]
418pub fn stdout_is(runcmd: &Runcmd, text: &str) {
419 if !check_matches(runcmd, Stream::Stdout, MatchKind::Exact, text)? {
420 throw!(format!("stdout is not {text:?}"));
421 }
422}
423
424/// Check that the stdout of the command is exactly not a given value
425///
426/// # `then stdout isn't exactly "{text:text}"`
427///
428/// This will check exactly that the stdout of the command does not match the given
429/// text. This assumes the command outputs valid utf-8 and decodes it as such.
430#[step]
431pub fn stdout_isnt(runcmd: &Runcmd, text: &str) {
432 if check_matches(runcmd, Stream::Stdout, MatchKind::Exact, text)? {
433 throw!(format!("stdout is exactly {text:?}"));
434 }
435}
436
437/// Check that the stderr of the command matches exactly
438///
439/// # `then stderr is exactly "{text:text}"`
440///
441/// This will check exactly that the stderr of the command matches the given
442/// text. This assumes the command outputs valid utf-8 and decodes it as such.
443#[step]
444pub fn stderr_is(runcmd: &Runcmd, text: &str) {
445 if !check_matches(runcmd, Stream::Stderr, MatchKind::Exact, text)? {
446 throw!(format!("stderr is not {text:?}"));
447 }
448}
449
450/// Check that the stderr of the command is exactly not a given value
451///
452/// # `then stderr isn't exactly "{text:text}"`
453///
454/// This will check exactly that the stderr of the command does not match the given
455/// text. This assumes the command outputs valid utf-8 and decodes it as such.
456#[step]
457pub fn stderr_isnt(runcmd: &Runcmd, text: &str) {
458 if check_matches(runcmd, Stream::Stderr, MatchKind::Exact, text)? {
459 throw!(format!("stderr is exactly {text:?}"));
460 }
461}
462
463/// Check that the stdout of the command contains a given string
464///
465/// # `then stdout contains "{text:text}"`
466///
467/// This will check that the stdout of the command contains the given substring. This
468/// assumes the command outputs valid utf-8 and decodes it as such.
469#[step]
470pub fn stdout_contains(runcmd: &Runcmd, text: &str) {
471 if !check_matches(runcmd, Stream::Stdout, MatchKind::Contains, text)? {
472 throw!(format!("stdout does not contain {text:?}"));
473 }
474}
475
476/// Check that the stdout of the command does not contain a given string
477///
478/// # `then stdout doesn't contain "{text:text}"`
479///
480/// This will check that the stdout of the command does not contain the given substring. This
481/// assumes the command outputs valid utf-8 and decodes it as such.
482#[step]
483pub fn stdout_doesnt_contain(runcmd: &Runcmd, text: &str) {
484 if check_matches(runcmd, Stream::Stdout, MatchKind::Contains, text)? {
485 throw!(format!("stdout contains {text:?}"));
486 }
487}
488
489/// Check that the stderr of the command contains a given string
490///
491/// # `then stderr contains "{text:text}"`
492///
493/// This will check that the stderr of the command contains the given substring. This
494/// assumes the command outputs valid utf-8 and decodes it as such.
495#[step]
496pub fn stderr_contains(runcmd: &Runcmd, text: &str) {
497 if !check_matches(runcmd, Stream::Stderr, MatchKind::Contains, text)? {
498 throw!(format!("stderr does not contain {text:?}"));
499 }
500}
501
502/// Check that the stderr of the command does not contain a given string
503///
504/// # `then stderr doesn't contain "{text:text}"`
505///
506/// This will check that the stderr of the command does not contain the given substring. This
507/// assumes the command outputs valid utf-8 and decodes it as such.
508#[step]
509pub fn stderr_doesnt_contain(runcmd: &Runcmd, text: &str) {
510 if check_matches(runcmd, Stream::Stderr, MatchKind::Contains, text)? {
511 throw!(format!("stderr contains {text:?}"));
512 }
513}
514
515/// Check that the stdout of the command matches a given regular expression
516///
517/// # `then stdout matches regex {regex:text}`
518///
519/// This will check that the stdout of the command matches the given regular expression.
520/// This will fail if the regular expression is bad, or if the command did not output
521/// valid utf-8 to be decoded.
522#[step]
523pub fn stdout_matches_regex(runcmd: &Runcmd, regex: &str) {
524 if !check_matches(runcmd, Stream::Stdout, MatchKind::Regex, regex)? {
525 throw!(format!("stdout does not match {regex:?}"));
526 }
527}
528
529/// Check that the stdout of the command does not match a given regular expression
530///
531/// # `then stdout doesn't match regex {regex:text}`
532///
533/// This will check that the stdout of the command fails to match the given regular expression.
534/// This will fail if the regular expression is bad, or if the command did not output
535/// valid utf-8 to be decoded.
536#[step]
537pub fn stdout_doesnt_match_regex(runcmd: &Runcmd, regex: &str) {
538 if check_matches(runcmd, Stream::Stdout, MatchKind::Regex, regex)? {
539 throw!(format!("stdout matches {regex:?}"));
540 }
541}
542
543/// Check that the stderr of the command matches a given regular expression
544///
545/// # `then stderr matches regex {regex:text}`
546///
547/// This will check that the stderr of the command matches the given regular expression.
548/// This will fail if the regular expression is bad, or if the command did not output
549/// valid utf-8 to be decoded.
550#[step]
551pub fn stderr_matches_regex(runcmd: &Runcmd, regex: &str) {
552 if !check_matches(runcmd, Stream::Stderr, MatchKind::Regex, regex)? {
553 throw!(format!("stderr does not match {regex:?}"));
554 }
555}
556
557/// Check that the stderr of the command does not match a given regular expression
558///
559/// # `then stderr doesn't match regex {regex:text}`
560///
561/// This will check that the stderr of the command fails to match the given regular expression.
562/// This will fail if the regular expression is bad, or if the command did not output
563/// valid utf-8 to be decoded.
564#[step]
565pub fn stderr_doesnt_match_regex(runcmd: &Runcmd, regex: &str) {
566 if check_matches(runcmd, Stream::Stderr, MatchKind::Regex, regex)? {
567 throw!(format!("stderr matches {regex:?}"));
568 }
569}