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}