Skip to main content

rskit_process/
result.rs

1//! Process execution result.
2
3use std::time::Duration;
4
5/// Result of a completed subprocess execution.
6#[derive(Debug, Clone)]
7#[non_exhaustive]
8pub struct ProcessResult {
9    /// Process exit code. None if the process was killed.
10    pub exit_code: Option<i32>,
11    /// Captured standard output as a string.
12    pub stdout: String,
13    /// Captured standard output as raw bytes before lossy UTF-8 conversion.
14    pub stdout_bytes: Vec<u8>,
15    /// Captured standard error as a string.
16    pub stderr: String,
17    /// Captured standard error as raw bytes before lossy UTF-8 conversion.
18    pub stderr_bytes: Vec<u8>,
19    /// Whether stdout exceeded the configured capture limit.
20    pub stdout_truncated: bool,
21    /// Whether stderr exceeded the configured capture limit.
22    pub stderr_truncated: bool,
23    /// Total duration the process ran.
24    pub duration: Duration,
25    /// Whether the process was killed due to timeout.
26    pub timed_out: bool,
27    /// Whether the process was cancelled by the caller.
28    pub cancelled: bool,
29}
30
31impl ProcessResult {
32    /// Build a process result from captured output bytes.
33    #[must_use]
34    #[allow(clippy::too_many_arguments)]
35    pub fn completed(
36        exit_code: Option<i32>,
37        stdout_bytes: Vec<u8>,
38        stderr_bytes: Vec<u8>,
39        stdout_truncated: bool,
40        stderr_truncated: bool,
41        duration: Duration,
42        timed_out: bool,
43        cancelled: bool,
44    ) -> Self {
45        Self {
46            exit_code,
47            stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
48            stdout_bytes,
49            stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
50            stderr_bytes,
51            stdout_truncated,
52            stderr_truncated,
53            duration,
54            timed_out,
55            cancelled,
56        }
57    }
58
59    /// Check if the process exited successfully (exit code 0).
60    ///
61    /// # Example
62    ///
63    /// ```
64    /// use rskit_process::ProcessResult;
65    /// use std::time::Duration;
66    ///
67    /// let result = ProcessResult::completed(
68    ///     Some(0),
69    ///     b"output".to_vec(),
70    ///     Vec::new(),
71    ///     false,
72    ///     false,
73    ///     Duration::from_secs(1),
74    ///     false,
75    ///     false,
76    /// );
77    ///
78    /// assert!(result.success());
79    /// ```
80    pub fn success(&self) -> bool {
81        self.exit_code == Some(0)
82    }
83
84    /// Verify the process exited successfully, returning an error if not.
85    ///
86    /// # Example
87    ///
88    /// ```no_run
89    /// use rskit_process::{ProcessConfig, ProcessSpec, run_with_cancel};
90    /// use tokio_util::sync::CancellationToken;
91    ///
92    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
93    /// let spec = ProcessSpec::new("echo").arg("hello");
94    /// let result = run_with_cancel(&spec, &ProcessConfig::default(), CancellationToken::new()).await?;
95    /// result.check()?;
96    /// # Ok(())
97    /// # }
98    /// ```
99    pub fn check(&self) -> crate::AppResult<&Self> {
100        if self.cancelled {
101            return Err(
102                crate::AppError::new(crate::ErrorCode::Cancelled, "process cancelled")
103                    .with_detail("cancelled", true),
104            );
105        }
106        if self.timed_out {
107            return Err(
108                crate::AppError::new(crate::ErrorCode::Timeout, "process timed out")
109                    .with_detail("timed_out", true),
110            );
111        }
112
113        match self.exit_code {
114            Some(0) => Ok(self),
115            Some(code) => Err(crate::AppError::new(
116                crate::ErrorCode::Internal,
117                format!("process exited with code {}", code),
118            )
119            .with_detail("exit_code", code)),
120            None => Err(
121                crate::AppError::new(crate::ErrorCode::Internal, "process was killed")
122                    .with_detail("killed", true),
123            ),
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::ErrorCode;
132
133    fn result(exit_code: Option<i32>, timed_out: bool, cancelled: bool) -> ProcessResult {
134        ProcessResult::completed(
135            exit_code,
136            b"out".to_vec(),
137            b"err".to_vec(),
138            false,
139            true,
140            Duration::from_millis(5),
141            timed_out,
142            cancelled,
143        )
144    }
145
146    #[test]
147    fn completed_result_preserves_bytes_strings_flags_and_duration() {
148        let result = result(Some(0), false, false);
149
150        assert!(result.success());
151        assert_eq!(result.stdout, "out");
152        assert_eq!(result.stdout_bytes, b"out");
153        assert_eq!(result.stderr, "err");
154        assert_eq!(result.stderr_bytes, b"err");
155        assert!(!result.stdout_truncated);
156        assert!(result.stderr_truncated);
157        assert_eq!(result.duration, Duration::from_millis(5));
158        assert!(std::ptr::eq(result.check().unwrap(), &result));
159    }
160
161    #[test]
162    fn check_reports_cancelled_timeout_failed_and_killed_states() {
163        assert_eq!(
164            result(Some(0), false, true).check().unwrap_err().code(),
165            ErrorCode::Cancelled
166        );
167        assert_eq!(
168            result(Some(0), true, false).check().unwrap_err().code(),
169            ErrorCode::Timeout
170        );
171        assert_eq!(
172            result(Some(2), false, false).check().unwrap_err().code(),
173            ErrorCode::Internal
174        );
175        assert_eq!(
176            result(None, false, false).check().unwrap_err().code(),
177            ErrorCode::Internal
178        );
179        assert!(!result(Some(2), false, false).success());
180    }
181}