Skip to main content

torrust_tracker_deployer_lib/shared/command/
executor.rs

1//! Command execution utilities
2//!
3//! This module provides the `CommandExecutor` struct for executing external commands
4//! with proper error handling, logging, and output capture.
5
6use std::path::Path;
7use std::process::{Command, Stdio};
8use tracing::info;
9
10use super::error::CommandError;
11use super::result::CommandResult;
12
13/// A command executor that can run shell commands
14#[derive(Debug)]
15pub struct CommandExecutor {}
16
17impl Default for CommandExecutor {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl CommandExecutor {
24    /// Creates a new `CommandExecutor`
25    #[must_use]
26    pub fn new() -> Self {
27        Self {}
28    }
29
30    /// Runs a command with the given arguments and optional working directory
31    ///
32    /// # Arguments
33    /// * `cmd` - The command to execute
34    /// * `args` - Arguments to pass to the command
35    /// * `working_dir` - Optional working directory to run the command in
36    ///
37    /// # Returns
38    /// * `Ok(CommandResult)` - Complete command execution information if the command succeeds
39    /// * `Err(CommandError)` - A specific error describing what went wrong
40    ///
41    /// # Errors
42    /// This function will return an error if:
43    /// * The working directory does not exist - `CommandError::WorkingDirectoryNotFound`
44    /// * The command cannot be started (e.g., command not found) - `CommandError::StartupFailed`
45    /// * The command execution fails with a non-zero exit code - `CommandError::ExecutionFailed`
46    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    /// Validates that the working directory exists if provided.
70    ///
71    /// This provides a clearer error message than the generic "No such file or directory"
72    /// that would be returned by the OS.
73    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    /// Builds a Command with the given arguments and optional working directory.
86    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    /// Formats a command and its arguments for display in logs and error messages.
99    fn format_command_display(cmd: &str, args: &[&str]) -> String {
100        format!("{} {}", cmd, args.join(" "))
101    }
102
103    /// Logs the command execution start with optional working directory.
104    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    /// Executes the command and captures its output.
121    ///
122    /// Returns a tuple of (`exit_status`, `stdout`, `stderr`).
123    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    /// Extracts stdout and stderr from command output as strings.
142    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    /// Checks if the command executed successfully and returns an error if it failed.
149    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    /// Logs the command output (stdout/stderr) at debug level.
171    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        // The output should contain the temp directory path
217        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        // This test verifies that the command executor uses tracing for logging
242        // We can't easily test the tracing output in unit tests without a subscriber
243        // but we can verify the executor runs correctly and uses tracing internally
244        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}