1use chrono::Local;
2use log::{debug, trace};
3use serde::{Deserialize, Serialize};
4use std::{
5 io::{Read, Seek, SeekFrom},
6 rc::Rc,
7 time::Duration,
8};
9use subprocess::{Popen, PopenConfig, Redirection};
10use tempfile;
11
12#[derive(Debug, Deserialize, PartialEq, Eq, Serialize, Clone)]
47pub struct Command {
48 pub(crate) name: String,
49 pub(crate) title: Option<String>,
50 pub(crate) description: Option<String>,
51 pub(crate) command: String,
52 #[serde(rename = "timeout")]
53 pub(crate) timeout_sec: Option<u64>,
55 pub(crate) links: Option<Vec<Link>>,
56}
57
58impl Command {
59 pub fn new<T: Into<String>>(name: T, command: T) -> Command {
61 Command {
62 name: name.into(),
63 title: None,
64 description: None,
65 command: command.into(),
66 timeout_sec: None,
67 links: None,
68 }
69 }
70
71 pub fn name(&self) -> &str { &self.name }
73
74 pub fn command(&self) -> &str { &self.command }
76
77 pub fn title(&self) -> Option<&str> { self.title.as_ref().map(|x| x.as_str()) }
79
80 pub fn description(&self) -> Option<&str> { self.description.as_ref().map(|x| x.as_str()) }
82
83 pub fn with_title<T: Into<String>>(self, title: T) -> Command {
85 Command {
86 title: Some(title.into()),
87 ..self
88 }
89 }
90
91 pub fn with_timeout<T: Into<Option<u64>>>(self, timeout_sec: T) -> Command {
93 Command {
94 timeout_sec: timeout_sec.into(),
95 ..self
96 }
97 }
98
99 pub fn with_description<T: Into<String>, S: Into<Option<T>>>(self, description: S) -> Command {
101 Command {
102 description: description.into().map(Into::into),
103 ..self
104 }
105 }
106
107 pub fn with_links<T: Into<Option<Vec<Link>>>>(self, links: T) -> Command {
109 Command {
110 links: links.into(),
111 ..self
112 }
113 }
114
115 pub fn exec(self) -> CommandResult {
120 let args = match self.args() {
121 Ok(args) => args,
122 Err(_) => return self.fail("failed to split command into arguments"),
123 };
124 let mut tmpfile = match tempfile::tempfile() {
125 Ok(f) => Rc::new(f),
126 Err(err) => return self.fail(err),
127 };
128 let popen_config = PopenConfig {
129 stdout: Redirection::RcFile(Rc::clone(&tmpfile)),
130 stderr: Redirection::Merge,
131 ..Default::default()
132 };
133 let start_time = Local::now();
134 let popen = Popen::create(&args, popen_config);
135
136 let mut p = match popen {
137 Ok(p) => p,
138 Err(err) => return self.fail(err),
139 };
140 debug!("Running '{:?}' as '{:?}'", args, p);
141
142 let wait = p.wait_timeout(Duration::new(self.timeout_sec.unwrap_or(1), 0));
143 let run_time_ms = (Local::now() - start_time).num_milliseconds() as u64;
144
145 if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().seek(SeekFrom::Start(0)) {
146 return self.fail(err);
148 };
149 match wait {
150 Ok(Some(status)) if status.success() => {
151 debug!(
152 "{:?} process successfully finished as {:?} with {} bytes output",
153 args,
154 status,
155 tmpfile.metadata().unwrap().len()
156 );
157 let mut stdout = String::new();
158 if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
159 return self.fail(err);
161 };
162 trace!("stdout '{}'", stdout);
163
164 CommandResult::Success {
165 command: self,
166 run_time_ms,
167 stdout,
168 }
169 }
170 Ok(Some(status)) => {
171 debug!("{:?} process finished as {:?}", args, status);
172 let mut stdout = String::new();
173 if let Err(err) = Rc::get_mut(&mut tmpfile).unwrap().read_to_string(&mut stdout) {
174 return self.fail(err);
176 };
177 trace!("stdout '{}'", stdout);
178 CommandResult::Failed {
179 command: self,
180 run_time_ms,
181 stdout,
182 }
183 }
184 Ok(None) => {
185 debug!("{:?} process timed out and will be killed", args);
186 self.terminate(&mut p);
187 CommandResult::Timeout {
188 command: self,
189 run_time_ms,
190 }
191 }
192 Err(err) => {
193 debug!("{:?} process failed '{:?}'", args, err);
194 self.terminate(&mut p);
195 CommandResult::Error {
196 command: self,
197 reason: err.to_string(),
198 }
199 }
200 }
201 }
202
203 fn fail<T: ToString>(self, reason: T) -> CommandResult {
205 CommandResult::Error {
206 command: self,
207 reason: reason.to_string(),
208 }
209 }
210
211 fn args(&self) -> Result<Vec<String>, shellwords::MismatchedQuotes> { shellwords::split(&self.command) }
212
213 fn terminate(&self, p: &mut Popen) {
215 p.kill().expect("failed to kill command");
216 p.wait().expect("failed to wait for command to finish");
217 trace!("process killed");
218 }
219}
220
221#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
222pub struct Link {
223 pub(crate) name: String,
224 pub(crate) url: String,
225}
226
227impl Link {
228 pub fn new<T: Into<String>>(name: T, url: T) -> Link {
229 Link {
230 name: name.into(),
231 url: url.into(),
232 }
233 }
234
235 pub fn name(&self) -> &str { &self.name }
236
237 pub fn url(&self) -> &str { &self.url }
238}
239
240#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
242pub enum CommandResult {
243 Success {
245 command: Command,
246 run_time_ms: u64,
247 stdout: String,
248 },
249 Failed {
251 command: Command,
252 run_time_ms: u64,
253 stdout: String,
254 },
255 Timeout { command: Command, run_time_ms: u64 },
257 Error { command: Command, reason: String },
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::tests::*;
265
266 use spectral::prelude::*;
267
268 #[test]
269 fn execution_ok() {
270 init();
271
272 #[cfg(target_os = "macos")]
273 let command = Command::new("true", r#"/usr/bin/true"#);
274 #[cfg(target_os = "linux")]
275 let command = Command::new("true", r#"/bin/true"#);
276
277 let res = command.exec();
278
279 asserting("executing command successfully")
280 .that(&res)
281 .is_success_contains("");
282 }
283
284 #[test]
285 fn execution_failed() {
286 init();
287
288 #[cfg(target_os = "macos")]
289 let command = Command::new("false", r#"/usr/bin/false"#);
290 #[cfg(target_os = "linux")]
291 let command = Command::new("false", r#"/bin/false"#);
292
293 let res = command.exec();
294
295 asserting("executing command successfully").that(&res).is_failed();
296 }
297
298 #[test]
299 fn execution_timeout() {
300 init();
301
302 let command = Command::new("sleep", r#"/bin/sleep 5"#).with_timeout(1);
303
304 let res = command.exec();
305
306 asserting("executing command successfully").that(&res).is_timeout();
307 }
308
309 #[test]
310 fn execution_error() {
311 init();
312
313 let command = Command::new("no_such_command", r#"/no_such_command"#);
314
315 let res = command.exec();
316
317 asserting("executing command errors")
318 .that(&res)
319 .is_error_contains("No such file or directory")
320 }
321
322 #[test]
323 fn command_split() {
324 init();
325
326 let command = Command::new("no_such_command", r#"/bin/sleep 5"#);
327 let args = command.args();
328
329 asserting("splitting command into args")
330 .that(&args)
331 .is_ok()
332 .has_length(2);
333 }
334
335 #[test]
336 fn command_split_single_quotes() {
337 init();
338
339 let command = Command::new("no_such_command", r#"sh -c 'dmesg -T | grep "failed"'"#);
340 let args = command.args();
341
342 asserting("splitting command into args")
343 .that(&args)
344 .is_ok()
345 .has_length(3);
346 }
347
348 #[test]
349 fn command_split_double_quotes() {
350 init();
351
352 let command = Command::new("no_such_command", r#"sh -c "dmesg -T | tail""#);
353 let args = command.args();
354
355 asserting("splitting command into args")
356 .that(&args)
357 .is_ok()
358 .has_length(3);
359 }
360}