automaat_processor_shell_command/
lib.rs1#![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#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
76#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
77pub struct ShellCommand {
78 pub command: String,
80
81 pub arguments: Option<Vec<String>>,
83
84 pub cwd: Option<String>,
93
94 pub paths: Option<Vec<String>>,
101}
102
103#[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 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 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 if let Some(new_paths) = &self.paths {
215 let paths: Vec<_> = match env::var_os("PATH") {
216 Some(current_path) => env::split_paths(¤t_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#[derive(Debug)]
266pub enum Error {
267 Command(String),
273
274 Io(io::Error),
278
279 Path(String),
282
283 #[doc(hidden)]
284 __Unknown, }
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}