1use async_trait::async_trait;
7use tokio::process::Command;
8
9use crate::error::{CiError, Result};
10
11#[async_trait]
13pub trait CommandRunner: Send + Sync {
14 async fn run(&self, program: &'static str, args: &[String]) -> Result<String>;
17}
18
19pub struct RealRunner;
21
22#[async_trait]
23impl CommandRunner for RealRunner {
24 async fn run(&self, program: &'static str, args: &[String]) -> Result<String> {
25 let output = Command::new(program)
26 .args(args)
27 .output()
28 .await
29 .map_err(|_| CiError::CliMissing(program))?;
30 if !output.status.success() {
31 return Err(CiError::Exec {
32 cmd: format!("{program} {}", args.join(" ")),
33 message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
34 });
35 }
36 String::from_utf8(output.stdout).map_err(|err| CiError::Parse {
37 what: format!("{program} output"),
38 message: err.to_string(),
39 })
40 }
41}
42
43#[cfg(test)]
44pub(crate) mod test_support {
45 use async_trait::async_trait;
46
47 use super::{CommandRunner, Result};
48
49 pub struct RecordingRunner {
54 responses: Vec<(&'static str, String)>,
55 }
56
57 impl RecordingRunner {
58 pub fn new(responses: &[(&'static str, &str)]) -> Self {
59 Self {
60 responses: responses
61 .iter()
62 .map(|(k, v)| (*k, (*v).to_owned()))
63 .collect(),
64 }
65 }
66 }
67
68 #[async_trait]
69 impl CommandRunner for RecordingRunner {
70 async fn run(&self, _program: &'static str, args: &[String]) -> Result<String> {
71 let joined = args.join(" ");
72 let hit = self
73 .responses
74 .iter()
75 .find(|(key, _)| joined.contains(key))
76 .map(|(_, value)| value.clone());
77 Ok(hit.unwrap_or_default())
78 }
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[tokio::test]
87 async fn real_runner_reports_a_missing_binary() {
88 let err = RealRunner
89 .run("definitely-not-a-real-binary-xyzzy", &[])
90 .await
91 .unwrap_err();
92 assert!(matches!(err, CiError::CliMissing(_)));
93 }
94}