torrust_tracker_deployer_lib/shared/command/
executor.rs1use std::path::Path;
7use std::process::{Command, Stdio};
8use tracing::info;
9
10use super::error::CommandError;
11use super::result::CommandResult;
12
13#[derive(Debug)]
15pub struct CommandExecutor {}
16
17impl Default for CommandExecutor {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl CommandExecutor {
24 #[must_use]
26 pub fn new() -> Self {
27 Self {}
28 }
29
30 pub fn run_command(
47 &self,
48 cmd: &str,
49 args: &[&str],
50 working_dir: Option<&Path>,
51 ) -> Result<CommandResult, CommandError> {
52 Self::validate_working_directory(working_dir)?;
53
54 let mut command = Self::build_command(cmd, args, working_dir);
55
56 let command_display = Self::format_command_display(cmd, args);
57
58 Self::log_command_start(&command_display, working_dir);
59
60 let (status, stdout, stderr) = Self::execute_command(&mut command, &command_display)?;
61
62 Self::check_command_success(status, &command_display, &stdout, &stderr)?;
63
64 Self::log_command_output(&command_display, &stdout, &stderr);
65
66 Ok(CommandResult::new(status, stdout, stderr))
67 }
68
69 fn validate_working_directory(working_dir: Option<&Path>) -> Result<(), CommandError> {
74 if let Some(dir) = working_dir {
75 if !dir.exists() {
76 return Err(CommandError::WorkingDirectoryNotFound {
77 working_dir: dir.to_path_buf(),
78 });
79 }
80 }
81
82 Ok(())
83 }
84
85 fn build_command(cmd: &str, args: &[&str], working_dir: Option<&Path>) -> Command {
87 let mut command = Command::new(cmd);
88
89 command.args(args);
90
91 if let Some(dir) = working_dir {
92 command.current_dir(dir);
93 }
94
95 command
96 }
97
98 fn format_command_display(cmd: &str, args: &[&str]) -> String {
100 format!("{} {}", cmd, args.join(" "))
101 }
102
103 fn log_command_start(command_display: &str, working_dir: Option<&Path>) {
105 info!(
106 operation = "command_execution",
107 command = %command_display,
108 "Running command"
109 );
110
111 if let Some(dir) = working_dir {
112 info!(
113 operation = "command_execution",
114 working_directory = %dir.display(),
115 "Working directory set"
116 );
117 }
118 }
119
120 fn execute_command(
124 command: &mut Command,
125 command_display: &str,
126 ) -> Result<(std::process::ExitStatus, String, String), CommandError> {
127 let output = command
128 .stdout(Stdio::piped())
129 .stderr(Stdio::piped())
130 .output()
131 .map_err(|source| CommandError::StartupFailed {
132 command: command_display.to_string(),
133 source,
134 })?;
135
136 let (stdout, stderr) = Self::extract_output(&output);
137
138 Ok((output.status, stdout, stderr))
139 }
140
141 fn extract_output(output: &std::process::Output) -> (String, String) {
143 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
144 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
145 (stdout, stderr)
146 }
147
148 fn check_command_success(
150 status: std::process::ExitStatus,
151 command_display: &str,
152 stdout: &str,
153 stderr: &str,
154 ) -> Result<(), CommandError> {
155 if !status.success() {
156 let exit_code = status
157 .code()
158 .map_or_else(|| "unknown".to_string(), |code| code.to_string());
159
160 return Err(CommandError::ExecutionFailed {
161 command: command_display.to_string(),
162 exit_code,
163 stdout: stdout.to_string(),
164 stderr: stderr.to_string(),
165 });
166 }
167 Ok(())
168 }
169
170 fn log_command_output(command_display: &str, stdout: &str, stderr: &str) {
172 if !stdout.trim().is_empty() {
173 tracing::debug!(
174 operation = "command_execution",
175 command = %command_display,
176 "stdout: {}",
177 stdout.trim()
178 );
179 }
180
181 if !stderr.trim().is_empty() {
182 tracing::debug!(
183 operation = "command_execution",
184 command = %command_display,
185 "stderr: {}",
186 stderr.trim()
187 );
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::env;
196
197 #[test]
198 fn it_should_execute_simple_command_successfully() {
199 let executor = CommandExecutor::new();
200 let result = executor.run_command("echo", &["hello"], None);
201
202 assert!(result.is_ok());
203 let output = result.unwrap();
204 assert_eq!(output.stdout_trimmed(), "hello");
205 assert!(output.is_success());
206 }
207
208 #[test]
209 fn it_should_respect_working_directory() {
210 let executor = CommandExecutor::new();
211 let temp_dir = env::temp_dir();
212 let result = executor.run_command("pwd", &[], Some(&temp_dir));
213
214 assert!(result.is_ok());
215 let output = result.unwrap();
216 assert!(output.stdout.contains(temp_dir.to_string_lossy().as_ref()));
218 assert!(output.is_success());
219 }
220
221 #[test]
222 fn it_should_return_error_for_nonexistent_command() {
223 let executor = CommandExecutor::new();
224 let result = executor.run_command("nonexistent_command_xyz123", &[], None);
225
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn it_should_return_error_for_failing_command() {
231 let executor = CommandExecutor::new();
232 let result = executor.run_command("false", &[], None);
233
234 assert!(result.is_err());
235 let error_msg = result.unwrap_err().to_string();
236 assert!(error_msg.contains("failed with exit code"));
237 }
238
239 #[test]
240 fn it_should_use_tracing_for_logging() {
241 let executor = CommandExecutor::new();
245 let result = executor.run_command("echo", &["tracing_test"], None);
246
247 assert!(result.is_ok());
248 let output = result.unwrap();
249 assert_eq!(output.stdout_trimmed(), "tracing_test");
250 assert!(output.is_success());
251 }
252
253 #[test]
254 fn it_should_return_clear_error_when_working_directory_does_not_exist() {
255 let executor = CommandExecutor::new();
256 let nonexistent_dir = Path::new("/nonexistent/path/that/does/not/exist");
257 let result = executor.run_command("echo", &["hello"], Some(nonexistent_dir));
258
259 assert!(result.is_err());
260 let error = result.unwrap_err();
261 match error {
262 CommandError::WorkingDirectoryNotFound { working_dir } => {
263 assert_eq!(working_dir, nonexistent_dir);
264 }
265 other => panic!("Expected WorkingDirectoryNotFound, got: {other:?}"),
266 }
267 }
268}