automaat_processor_shell_command/
lib.rs

1//! An [Automaat] processor to execute shell commands.
2//!
3//! Execute shell commands in an Automaat-based workflow. The return value of
4//! the shell command is returned as the output of the processor.
5//!
6//! If the shell command returns a non-zero exit code, the processor returns the
7//! _stderr_ output as its error value.
8//!
9//! All commands are executed within the [`Context`] workspace.
10//!
11//! [Automaat]: automaat_core
12//! [`Context`]: automaat_core::Context
13//!
14//! # Examples
15//!
16//! Execute the `echo "hello world"` command in a shell, and return its output.
17//!
18//! ```rust
19//! # fn main() -> Result<(), Box<std::error::Error>> {
20//! use automaat_core::{Context, Processor};
21//! use automaat_processor_shell_command::ShellCommand;
22//!
23//! let context = Context::new()?;
24//!
25//! let processor = ShellCommand {
26//!     command: "echo".to_owned(),
27//!     arguments: Some(vec!["hello world".to_owned()]),
28//!     cwd: None,
29//!     paths: None,
30//! };
31//!
32//! let output = processor.run(&context)?;
33//!
34//! assert_eq!(output, Some("hello world".to_owned()));
35//! #     Ok(())
36//! # }
37//! ```
38//!
39//! # Package Features
40//!
41//! * `juniper` – creates a set of objects to be used in GraphQL-based
42//!   requests/responses.
43#![deny(
44    clippy::all,
45    clippy::cargo,
46    clippy::nursery,
47    clippy::pedantic,
48    deprecated_in_future,
49    future_incompatible,
50    missing_docs,
51    nonstandard_style,
52    rust_2018_idioms,
53    rustdoc,
54    warnings,
55    unused_results,
56    unused_qualifications,
57    unused_lifetimes,
58    unused_import_braces,
59    unsafe_code,
60    unreachable_pub,
61    trivial_casts,
62    trivial_numeric_casts,
63    missing_debug_implementations,
64    missing_copy_implementations
65)]
66#![warn(variant_size_differences)]
67#![allow(clippy::multiple_crate_versions, missing_doc_code_examples)]
68#![doc(html_root_url = "https://docs.rs/automaat-processor-shell-command/0.1.0")]
69
70use automaat_core::{Context, Processor};
71use serde::{Deserialize, Serialize};
72use std::{env, error, fmt, io, path, process};
73
74/// The processor configuration.
75#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
76#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
77pub struct ShellCommand {
78    /// The main shell command to execute.
79    pub command: String,
80
81    /// The arguments added to the `main` command.
82    pub arguments: Option<Vec<String>>,
83
84    /// The _current working directory_ in which the command is executed.
85    ///
86    /// This allows you to move to a child path within the [`Context`]
87    /// workspace.
88    ///
89    /// If set to `None`, the root of the workspace is used as the default.
90    ///
91    /// [`Context`]: automaat_core::Context
92    pub cwd: Option<String>,
93
94    /// Optional paths added to the `PATH` environment variable.
95    ///
96    /// If you have a single script inside the `bin/` directory you want to
97    /// execute, you can also use the `cwd` option, but if your scripts call
98    /// other custom scripts, and expect them to be directly accessible, you can
99    /// add `bin` to `paths` to make that work.
100    pub paths: Option<Vec<String>>,
101}
102
103/// The GraphQL [Input Object][io] used to initialize the processor via an API.
104///
105/// [`ShellCommand`] implements `From<Input>`, so you can directly initialize
106/// the processor using this type.
107///
108/// _requires the `juniper` package feature to be enabled_
109///
110/// [io]: https://graphql.github.io/graphql-spec/June2018/#sec-Input-Objects
111#[cfg(feature = "juniper")]
112#[graphql(name = "ShellCommandInput")]
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, juniper::GraphQLInputObject)]
114pub struct Input {
115    command: String,
116    arguments: Option<Vec<String>>,
117    cwd: Option<String>,
118    paths: Option<Vec<String>>,
119}
120
121#[cfg(feature = "juniper")]
122impl From<Input> for ShellCommand {
123    fn from(input: Input) -> Self {
124        Self {
125            command: input.command,
126            arguments: input.arguments,
127            cwd: input.cwd,
128            paths: input.paths,
129        }
130    }
131}
132
133impl<'a> Processor<'a> for ShellCommand {
134    const NAME: &'static str = "Shell Command";
135
136    type Error = Error;
137    type Output = String;
138
139    /// Validate the `ShellCommand` configuration.
140    ///
141    /// # Errors
142    ///
143    /// This method returns the [`Error::Path`] error if either the [`cwd`] or
144    /// the [`paths`] fields contain anything other than a simple relative path,
145    /// such as `my/path`. Anything such as `../`, or `/etc` is not allowed.
146    ///
147    /// [`cwd`]: ShellCommand::cwd
148    /// [`paths`]: ShellCommand::paths
149    fn validate(&self) -> Result<(), Self::Error> {
150        fn check_path(path: &str) -> Result<(), Error> {
151            let path = path::Path::new(path);
152
153            path.components().try_for_each(|c| match c {
154                path::Component::Normal(_) => Ok(()),
155                _ => Err(Error::Path(
156                    "only sibling or child paths are accessible".into(),
157                )),
158            })
159        }
160
161        if let Some(cwd) = &self.cwd {
162            check_path(cwd)?;
163        };
164
165        if let Some(paths) = &self.paths {
166            paths.iter().map(String::as_str).try_for_each(check_path)?;
167        }
168
169        Ok(())
170    }
171
172    /// Run the shell command as defined by the provided configuration.
173    ///
174    /// The command will be executed in the [`automaat_core::Context`]
175    /// workspace, optionally in a child path using the [`cwd`] option.
176    ///
177    /// [`cwd`]: ShellCommand::cwd
178    ///
179    /// # Output
180    ///
181    /// `None` is returned if the processor runs successfully but no value was
182    /// returned by the command on _stdout_.
183    ///
184    /// `Some` is returned if the command did return a value and exited with
185    /// status code `0`.
186    ///
187    /// If a value is returned, any ANSI escape codes are stripped, and the
188    /// return value is transformed lossy transformed into a valid UTF-8 string,
189    /// with any invalid bytes transformed to the [replacement character]. Any
190    /// whitespace to the right of the output (including newlines) is also
191    /// stripped.
192    ///
193    /// [replacement character]: std::char::REPLACEMENT_CHARACTER
194    ///
195    /// # Errors
196    ///
197    /// If the run fails, an [`Error`] result value is returned. The variant can
198    /// differ, depending on if the command itself failed, some IO error
199    /// happened, or the configuration is invalid.
200    fn run(&self, context: &Context) -> Result<Option<Self::Output>, Self::Error> {
201        self.validate()?;
202
203        let arguments = match &self.arguments {
204            None => vec![],
205            Some(v) => v.iter().map(String::as_str).collect(),
206        };
207
208        let workspace = context.workspace_path();
209        let cwd = workspace.join(path::Path::new(
210            self.cwd.as_ref().unwrap_or(&"".to_owned()).as_str(),
211        ));
212
213        // Optionally add custom paths to the PATH environment variable.
214        if let Some(new_paths) = &self.paths {
215            let paths: Vec<_> = match env::var_os("PATH") {
216                Some(current_path) => env::split_paths(&current_path)
217                    .chain(new_paths.iter().map(path::PathBuf::from))
218                    .collect(),
219                None => new_paths
220                    .iter()
221                    .map(path::Path::new)
222                    .map(|p| workspace.join(p))
223                    .collect(),
224            };
225
226            let path = env::join_paths(paths)?;
227            env::set_var("PATH", &path);
228        };
229
230        let output = process::Command::new(&self.command)
231            .current_dir(cwd)
232            .args(arguments)
233            .output()?;
234
235        if !output.status.success() {
236            if output.stderr.is_empty() {
237                return Err(Error::Command(
238                    "unknown error during command execution".into(),
239                ));
240            };
241
242            return Err(Error::Command(
243                String::from_utf8_lossy(&strip_ansi_escapes::strip(output.stderr)?)
244                    .trim_end()
245                    .to_owned(),
246            ));
247        }
248
249        if output.stdout.is_empty() {
250            return Ok(None);
251        };
252
253        Ok(Some(
254            String::from_utf8_lossy(&strip_ansi_escapes::strip(output.stdout)?)
255                .trim_end()
256                .to_owned(),
257        ))
258    }
259}
260
261/// Represents all the ways that [`ShellCommand`] can fail.
262///
263/// This type is not intended to be exhaustively matched, and new variants may
264/// be added in the future without a major version bump.
265#[derive(Debug)]
266pub enum Error {
267    /// The command execution failed.
268    ///
269    /// This happens if the command returns with a non-zero exit code.
270    ///
271    /// The string value represents the _stderr_ output of the command.
272    Command(String),
273
274    /// An I/O operation failed.
275    ///
276    /// This is a wrapper around [`std::io::Error`].
277    Io(io::Error),
278
279    /// The provided [`ShellCommand::paths`] or [`ShellCommand::cwd`]
280    /// configuration is invalid.
281    Path(String),
282
283    #[doc(hidden)]
284    __Unknown, // Match against _ instead, more variants may be added in the future.
285}
286
287impl fmt::Display for Error {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        match *self {
290            Error::Command(ref err) => write!(f, "Command error: {}", err),
291            Error::Io(ref err) => write!(f, "IO error: {}", err),
292            Error::Path(ref err) => write!(f, "Path error: {}", err),
293            Error::__Unknown => unreachable!(),
294        }
295    }
296}
297
298impl error::Error for Error {
299    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
300        match *self {
301            Error::Command(_) | Error::Path(_) => None,
302            Error::Io(ref err) => Some(err),
303            Error::__Unknown => unreachable!(),
304        }
305    }
306}
307
308impl From<io::Error> for Error {
309    fn from(err: io::Error) -> Self {
310        Error::Io(err)
311    }
312}
313
314impl From<env::JoinPathsError> for Error {
315    fn from(err: env::JoinPathsError) -> Self {
316        Error::Path(err.to_string())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn processor_stub() -> ShellCommand {
325        ShellCommand {
326            command: "echo".to_owned(),
327            arguments: None,
328            cwd: None,
329            paths: None,
330        }
331    }
332
333    mod run {
334        use super::*;
335
336        #[test]
337        fn test_command_without_output() {
338            let mut processor = processor_stub();
339            processor.command = "true".to_owned();
340
341            let context = Context::new().unwrap();
342            let output = processor.run(&context).unwrap();
343
344            assert!(output.is_none())
345        }
346
347        #[test]
348        fn test_command_with_output() {
349            let mut processor = processor_stub();
350            processor.command = "ps".to_owned();
351
352            let context = Context::new().unwrap();
353            let output = processor.run(&context).unwrap().expect("Some");
354
355            dbg!(&output);
356
357            assert!(output.contains("PID"))
358        }
359
360        #[test]
361        fn test_command_with_arguments() {
362            let mut processor = processor_stub();
363            processor.command = "echo".to_owned();
364            processor.arguments = Some(vec!["hello world".to_owned()]);
365
366            let context = Context::new().unwrap();
367            let output = processor.run(&context).unwrap().expect("Some");
368
369            assert_eq!(output, "hello world".to_owned())
370        }
371
372        #[test]
373        #[should_panic]
374        fn test_command_non_zero_exit_code() {
375            let mut processor = processor_stub();
376            processor.command = "false".to_owned();
377
378            let context = Context::new().unwrap();
379            let _ = processor.run(&context).unwrap();
380        }
381
382        #[test]
383        fn test_command_stderr_output() {
384            let mut processor = processor_stub();
385            processor.command = "ls".to_owned();
386            processor.arguments = Some(vec!["invalid-file".to_owned()]);
387
388            let context = Context::new().unwrap();
389            let error = processor.run(&context).unwrap_err();
390
391            assert!(error.to_string().contains("Command error"))
392        }
393
394        #[test]
395        fn test_invalid_command() {
396            let mut processor = processor_stub();
397            processor.command = "doesnotexist".to_owned();
398
399            let context = Context::new().unwrap();
400            let error = processor.run(&context).unwrap_err();
401
402            assert_eq!(
403                error.to_string(),
404                "IO error: No such file or directory (os error 2)".to_owned()
405            )
406        }
407    }
408
409    mod validate {
410        use super::*;
411
412        #[test]
413        fn test_no_cwd() {
414            let mut processor = processor_stub();
415            processor.cwd = None;
416
417            processor.validate().unwrap()
418        }
419
420        #[test]
421        fn test_relative_cwd() {
422            let mut processor = processor_stub();
423            processor.cwd = Some("hello/world".to_owned());
424
425            processor.validate().unwrap()
426        }
427
428        #[test]
429        #[should_panic]
430        fn test_prefix_cwd() {
431            let mut processor = processor_stub();
432            processor.cwd = Some("../parent".to_owned());
433
434            processor.validate().unwrap()
435        }
436
437        #[test]
438        #[should_panic]
439        fn test_absolute_cwd() {
440            let mut processor = processor_stub();
441            processor.cwd = Some("/etc".to_owned());
442
443            processor.validate().unwrap()
444        }
445
446        #[test]
447        fn test_no_paths() {
448            let mut processor = processor_stub();
449            processor.paths = None;
450
451            processor.validate().unwrap()
452        }
453
454        #[test]
455        fn test_relative_paths() {
456            let mut processor = processor_stub();
457            processor.paths = Some(vec!["hello/world".to_owned()]);
458
459            processor.validate().unwrap()
460        }
461
462        #[test]
463        fn test_multiple_valid_paths() {
464            let mut processor = processor_stub();
465            processor.paths = Some(vec!["valid/path".to_owned(), "another/path".to_owned()]);
466
467            processor.validate().unwrap()
468        }
469
470        #[test]
471        #[should_panic]
472        fn test_prefix_paths() {
473            let mut processor = processor_stub();
474            processor.paths = Some(vec!["../parent".to_owned()]);
475
476            processor.validate().unwrap()
477        }
478
479        #[test]
480        #[should_panic]
481        fn test_absolute_paths() {
482            let mut processor = processor_stub();
483            processor.paths = Some(vec!["/etc".to_owned()]);
484
485            processor.validate().unwrap()
486        }
487
488        #[test]
489        #[should_panic]
490        fn test_multiple_paths_one_bad() {
491            let mut processor = processor_stub();
492            processor.paths = Some(vec!["valid/path".to_owned(), "/etc".to_owned()]);
493
494            processor.validate().unwrap()
495        }
496    }
497
498    #[test]
499    fn test_readme_deps() {
500        version_sync::assert_markdown_deps_updated!("README.md");
501    }
502
503    #[test]
504    fn test_html_root_url() {
505        version_sync::assert_html_root_url_updated!("src/lib.rs");
506    }
507}