Skip to main content

update_kit/utils/
process.rs

1use async_trait::async_trait;
2use tokio::process::Command;
3
4use crate::errors::UpdateKitError;
5
6/// Output captured from a completed process.
7#[derive(Debug, Clone)]
8pub struct CommandOutput {
9    pub exit_code: Option<i32>,
10    pub stdout: String,
11    pub stderr: String,
12}
13
14impl CommandOutput {
15    /// Returns `true` if the process exited with code 0.
16    pub fn success(&self) -> bool {
17        self.exit_code == Some(0)
18    }
19}
20
21/// Trait for running external commands, enabling test doubles.
22#[async_trait]
23pub trait CommandRunner: Send + Sync {
24    async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput, UpdateKitError>;
25}
26
27/// Default implementation backed by `tokio::process::Command`.
28#[derive(Debug, Default)]
29pub struct TokioCommandRunner;
30
31#[async_trait]
32impl CommandRunner for TokioCommandRunner {
33    async fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput, UpdateKitError> {
34        let output = Command::new(program)
35            .args(args)
36            .stdout(std::process::Stdio::piped())
37            .stderr(std::process::Stdio::piped())
38            .spawn()
39            .map_err(|e| {
40                UpdateKitError::CommandSpawnFailed(format!(
41                    "failed to spawn '{program}': {e}"
42                ))
43            })?
44            .wait_with_output()
45            .await
46            .map_err(|e| {
47                UpdateKitError::CommandSpawnFailed(format!(
48                    "failed to collect output from '{program}': {e}"
49                ))
50            })?;
51
52        Ok(CommandOutput {
53            exit_code: output.status.code(),
54            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
55            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
56        })
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn command_output_success_true() {
66        let output = CommandOutput {
67            exit_code: Some(0),
68            stdout: String::new(),
69            stderr: String::new(),
70        };
71        assert!(output.success());
72    }
73
74    #[test]
75    fn command_output_success_false_nonzero() {
76        let output = CommandOutput {
77            exit_code: Some(1),
78            stdout: String::new(),
79            stderr: String::new(),
80        };
81        assert!(!output.success());
82    }
83
84    #[test]
85    fn command_output_success_false_none() {
86        let output = CommandOutput {
87            exit_code: None,
88            stdout: String::new(),
89            stderr: String::new(),
90        };
91        assert!(!output.success());
92    }
93
94    #[tokio::test]
95    async fn tokio_runner_runs_echo() {
96        let runner = TokioCommandRunner;
97        let result = runner.run("echo", &["hello"]).await.unwrap();
98        assert!(result.success());
99        assert!(result.stdout.contains("hello"));
100    }
101}