use chrono::Local;
use log::{debug, trace};
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Seek, SeekFrom},
rc::Rc,
time::Duration,
};
use subprocess::{Popen, PopenConfig, Redirection};
use tempfile;
#[derive(Debug, Deserialize, PartialEq, Eq, Serialize, Clone)]
pub struct Command {
pub(crate) name: String,
pub(crate) title: Option<String>,
pub(crate) description: Option<String>,
pub(crate) command: String,
#[serde(rename = "timeout")]
pub(crate) timeout_sec: Option<u64>,
pub(crate) links: Option<Vec<Link>>,
}
impl Command {
pub fn new<T: Into<String>>(name: T, command: T) -> Command {
Command {
name: name.into(),
title: None,
description: None,
command: command.into(),
timeout_sec: None,
links: None,
}
}
pub fn name(&self) -> &str { &self.name }
pub fn command(&self) -> &str { &self.command }
pub fn title(&self) -> Option<&str> { self.title.as_ref().map(|x| x.as_str()) }
pub fn description(&self) -> Option<&str> { self.description.as_ref().map(|x| x.as_str()) }
pub fn with_title<T: Into<String>>(self, title: T) -> Command {
Command {
title: Some(title.into()),
..self
}
}
pub fn with_timeout<T: Into<Option<u64>>>(self, timeout_sec: T) -> Command {
Command {
timeout_sec: timeout_sec.into(),
..self
}
}
pub fn with_description<T: Into<String>, S: Into<Option<T>>>(self, description: S) -> Command {
Command {
description: description.into().map(Into::into),
..self
}
}
pub fn with_links<T: Into<Option<Vec<Link>>>>(self, links: T) -> Command {
Command {
links: links.into(),
..self
}
}
pub fn exec(self) -> CommandResult {
let args = match self.args() {
Ok(args) => args,
Err(_) => return self.fail("failed to split command into arguments"),
};
let mut tmpfile = match tempfile::tempfile() {
Ok(f) => Rc::new(f),
Err(err) => return self.fail(err),
};
let popen_config = PopenConfig {
stdout: Redirection::RcFile(Rc::clone(&tmpfile)),
stderr: Redirection::Merge,
..Default::default()
};
let start_time = Local::now();
let popen = Popen::create(&args, popen_config);
let mut p = match popen {
Ok(p) => p,
Err(err) => return self.fail(err),
};
debug!("Running '{:?}' as '{:?}'", args, p);
let wait = p.wait_timeout(Duration::new(self.timeout_sec.unwrap_or(1), 0));
let run_time_ms = (Local::now() - start_time).num_milliseconds() as u64;
if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().seek(SeekFrom::Start(0)) {
return self.fail(err);
};
match wait {
Ok(Some(status)) if status.success() => {
debug!(
"{:?} process successfully finished as {:?} with {} bytes output",
args,
status,
tmpfile.metadata().unwrap().len()
);
let mut stdout = String::new();
if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
return self.fail(err);
};
trace!("stdout '{}'", stdout);
CommandResult::Success {
command: self,
run_time_ms,
stdout,
}
}
Ok(Some(status)) => {
debug!("{:?} process finished as {:?}", args, status);
let mut stdout = String::new();
if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
return self.fail(err);
};
trace!("stdout '{}'", stdout);
CommandResult::Failed {
command: self,
run_time_ms,
stdout,
}
}
Ok(None) => {
debug!("{:?} process timed out and will be killed", args);
self.terminate(&mut p);
CommandResult::Timeout {
command: self,
run_time_ms,
}
}
Err(err) => {
debug!("{:?} process failed '{:?}'", args, err);
self.terminate(&mut p);
CommandResult::Error {
command: self,
reason: err.to_string(),
}
}
}
}
fn fail<T: ToString>(self, reason: T) -> CommandResult {
CommandResult::Error {
command: self,
reason: reason.to_string(),
}
}
fn args(&self) -> Result<Vec<String>, shellwords::MismatchedQuotes> { shellwords::split(&self.command) }
fn terminate(&self, p: &mut Popen) {
p.kill().expect("failed to kill command");
p.wait().expect("failed to wait for command to finish");
trace!("process killed");
}
}
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
pub struct Link {
pub(crate) name: String,
pub(crate) url: String,
}
impl Link {
pub fn new<T: Into<String>>(name: T, url: T) -> Link {
Link {
name: name.into(),
url: url.into(),
}
}
pub fn name(&self) -> &str { &self.name }
pub fn url(&self) -> &str { &self.url }
}
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
pub enum CommandResult {
Success {
command: Command,
run_time_ms: u64,
stdout: String,
},
Failed {
command: Command,
run_time_ms: u64,
stdout: String,
},
Timeout { command: Command, run_time_ms: u64 },
Error { command: Command, reason: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
use spectral::prelude::*;
#[test]
fn execution_ok() {
init();
#[cfg(target_os = "macos")]
let command = Command::new("true", r#"/usr/bin/true"#);
#[cfg(target_os = "linux")]
let command = Command::new("true", r#"/bin/true"#);
let res = command.exec();
asserting("executing command successfully")
.that(&res)
.is_success_contains("");
}
#[test]
fn execution_failed() {
init();
#[cfg(target_os = "macos")]
let command = Command::new("false", r#"/usr/bin/false"#);
#[cfg(target_os = "linux")]
let command = Command::new("false", r#"/bin/false"#);
let res = command.exec();
asserting("executing command successfully").that(&res).is_failed();
}
#[test]
fn execution_timeout() {
init();
let command = Command::new("sleep", r#"/bin/sleep 5"#).with_timeout(1);
let res = command.exec();
asserting("executing command successfully").that(&res).is_timeout();
}
#[test]
fn execution_error() {
init();
let command = Command::new("no_such_command", r#"/no_such_command"#);
let res = command.exec();
asserting("executing command errors")
.that(&res)
.is_error_contains("No such file or directory")
}
#[test]
fn command_split() {
init();
let command = Command::new("no_such_command", r#"/bin/sleep 5"#);
let args = command.args();
asserting("splitting command into args")
.that(&args)
.is_ok()
.has_length(2);
}
#[test]
fn command_split_single_quotes() {
init();
let command = Command::new("no_such_command", r#"sh -c 'dmesg -T | grep "failed"'"#);
let args = command.args();
asserting("splitting command into args")
.that(&args)
.is_ok()
.has_length(3);
}
#[test]
fn command_split_double_quotes() {
init();
let command = Command::new("no_such_command", r#"sh -c "dmesg -T | tail""#);
let args = command.args();
asserting("splitting command into args")
.that(&args)
.is_ok()
.has_length(3);
}
}