1use anyhow::{Context, Result, bail};
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5
6#[derive(Clone, Eq, PartialEq)]
7pub struct CommandRequest {
8 pub program: String,
9 pub arguments: Vec<String>,
10 pub current_dir: PathBuf,
11 pub environment: BTreeMap<String, String>,
12}
13
14impl std::fmt::Debug for CommandRequest {
15 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 let redacted_environment: BTreeMap<_, _> = self
17 .environment
18 .keys()
19 .map(|key| (key, "[REDACTED]"))
20 .collect();
21 formatter
22 .debug_struct("CommandRequest")
23 .field("program", &self.program)
24 .field("arguments", &self.arguments)
25 .field("current_dir", &self.current_dir)
26 .field("environment", &redacted_environment)
27 .finish()
28 }
29}
30
31impl CommandRequest {
32 pub fn new(
33 program: impl Into<String>,
34 arguments: impl IntoIterator<Item = impl Into<String>>,
35 current_dir: impl Into<PathBuf>,
36 ) -> Self {
37 Self {
38 program: program.into(),
39 arguments: arguments.into_iter().map(Into::into).collect(),
40 current_dir: current_dir.into(),
41 environment: BTreeMap::new(),
42 }
43 }
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct CommandResult {
48 pub status: i32,
49 pub stdout: String,
50 pub stderr: String,
51}
52
53impl CommandResult {
54 pub fn redact(mut self, secrets: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
55 for secret in secrets {
56 let secret = secret.as_ref();
57 if secret.is_empty() {
58 continue;
59 }
60 self.stdout = self.stdout.replace(secret, "[REDACTED]");
61 self.stderr = self.stderr.replace(secret, "[REDACTED]");
62 }
63 self
64 }
65
66 pub fn require_success(self, description: &str) -> Result<Self> {
67 if self.status == 0 {
68 return Ok(self);
69 }
70 let detail = self.stderr.trim();
71 if detail.is_empty() {
72 bail!("{description} failed with exit status {}", self.status);
73 }
74 bail!(
75 "{description} failed with exit status {}: {detail}",
76 self.status
77 )
78 }
79}
80
81pub trait CommandRunner: Send + Sync {
82 fn execute(&self, request: &CommandRequest) -> Result<CommandResult>;
83
84 fn execute_inheriting_output(&self, request: &CommandRequest) -> Result<CommandResult> {
87 self.execute(request)
88 }
89}
90
91#[derive(Clone, Copy, Debug, Default)]
92pub struct SystemCommandRunner;
93
94impl CommandRunner for SystemCommandRunner {
95 fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
96 let output = command(request)
97 .output()
98 .with_context(|| format!("failed to execute `{}`", request.program))?;
99 Ok(CommandResult {
100 status: output.status.code().unwrap_or(1),
101 stdout: String::from_utf8(output.stdout)
102 .context("command stdout is not valid UTF-8")?,
103 stderr: String::from_utf8(output.stderr)
104 .context("command stderr is not valid UTF-8")?,
105 })
106 }
107
108 fn execute_inheriting_output(&self, request: &CommandRequest) -> Result<CommandResult> {
109 let status = command(request)
110 .stdout(Stdio::inherit())
111 .stderr(Stdio::inherit())
112 .status()
113 .with_context(|| format!("failed to execute `{}`", request.program))?;
114 Ok(CommandResult {
115 status: status.code().unwrap_or(1),
116 stdout: String::new(),
117 stderr: String::new(),
118 })
119 }
120}
121
122fn command(request: &CommandRequest) -> Command {
123 let mut command = Command::new(&request.program);
124 command
125 .args(&request.arguments)
126 .current_dir(&request.current_dir)
127 .envs(&request.environment)
128 .stdin(Stdio::null());
129 command
130}