Skip to main content

shell_tunnel/execution/
result.rs

1//! Execution result types.
2
3use std::time::Duration;
4
5/// Result of command execution.
6#[derive(Debug, Clone)]
7pub struct ExecutionResult {
8    /// Raw output from the terminal.
9    pub raw_output: Vec<u8>,
10    /// Sanitized text output (ANSI codes stripped).
11    pub text_output: String,
12    /// Exit code (if command completed).
13    pub exit_code: Option<i32>,
14    /// Execution duration.
15    pub duration: Duration,
16    /// Whether execution timed out.
17    pub timed_out: bool,
18    /// Bytes the command produced, including any this result did not keep.
19    ///
20    /// Counted as the output arrives rather than measured from `raw_output`,
21    /// so it stays accurate once a cap has discarded the tail — which is the
22    /// only situation where the two numbers differ, and exactly the situation
23    /// a caller needs the real figure for.
24    pub total_bytes: u64,
25    /// Whether output was discarded because it exceeded the cap.
26    pub truncated: bool,
27}
28
29impl ExecutionResult {
30    /// Create a new execution result.
31    ///
32    /// `total_bytes` is taken from `raw_output` and `truncated` is false: a
33    /// result built this way kept everything it was given. The executor, which
34    /// is the only place that can discard output, sets both explicitly through
35    /// [`Self::with_output_extent`].
36    pub fn new(raw_output: Vec<u8>, text_output: String, duration: Duration) -> Self {
37        let total_bytes = raw_output.len() as u64;
38        Self {
39            raw_output,
40            text_output,
41            exit_code: None,
42            duration,
43            timed_out: false,
44            total_bytes,
45            truncated: false,
46        }
47    }
48
49    /// Create a result indicating timeout.
50    pub fn timeout(raw_output: Vec<u8>, text_output: String, duration: Duration) -> Self {
51        let total_bytes = raw_output.len() as u64;
52        Self {
53            raw_output,
54            text_output,
55            exit_code: None,
56            duration,
57            timed_out: true,
58            total_bytes,
59            truncated: false,
60        }
61    }
62
63    /// Record how much output the command actually produced.
64    ///
65    /// Separate from the constructors because only the collecting loop knows
66    /// the figure once a cap is in play — `raw_output.len()` is what was kept,
67    /// not what was produced.
68    pub fn with_output_extent(mut self, total_bytes: u64, truncated: bool) -> Self {
69        self.total_bytes = total_bytes;
70        self.truncated = truncated;
71        self
72    }
73
74    /// Set the exit code.
75    pub fn with_exit_code(mut self, code: i32) -> Self {
76        self.exit_code = Some(code);
77        self
78    }
79
80    /// Check if command succeeded (exit code 0).
81    pub fn success(&self) -> bool {
82        self.exit_code == Some(0)
83    }
84
85    /// Check if command failed (non-zero exit code or timeout).
86    pub fn failed(&self) -> bool {
87        self.timed_out || matches!(self.exit_code, Some(c) if c != 0)
88    }
89
90    /// Get output as string, trimmed.
91    pub fn output_trimmed(&self) -> &str {
92        self.text_output.trim()
93    }
94
95    /// Get output lines.
96    pub fn output_lines(&self) -> impl Iterator<Item = &str> {
97        self.text_output.lines()
98    }
99}
100
101impl Default for ExecutionResult {
102    fn default() -> Self {
103        Self {
104            raw_output: Vec::new(),
105            text_output: String::new(),
106            exit_code: None,
107            duration: Duration::ZERO,
108            timed_out: false,
109            total_bytes: 0,
110            truncated: false,
111        }
112    }
113}
114
115/// Streaming output chunk from execution.
116#[derive(Debug, Clone)]
117pub struct OutputChunk {
118    /// Raw bytes.
119    pub raw: Vec<u8>,
120    /// Decoded text (best effort).
121    pub text: String,
122    /// Stream source.
123    pub source: OutputSource,
124}
125
126/// Source of output data.
127///
128/// **Execution only ever produces [`Combined`](OutputSource::Combined).** The
129/// executor merges a child's stdout and stderr into one stream before any chunk
130/// is built, so a consumer matching on this will never see the other two from
131/// this crate — do not write a branch that depends on telling them apart, and do
132/// not read the presence of these variants as a promise that separation is
133/// available. The variants and [`OutputChunk::stdout`] exist because separating
134/// the two streams is a standing proposal, not because it happens.
135///
136/// They are kept rather than deleted so the day that proposal lands does not
137/// also change this type's shape. That is a judgement, and it comes with the
138/// cost of this paragraph: a half-built structure reads as a working one unless
139/// it says otherwise.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum OutputSource {
142    /// Standard output. **Not produced by this crate** — see the type's note.
143    Stdout,
144    /// Standard error. **Not produced by this crate** — see the type's note.
145    Stderr,
146    /// Combined output. The only variant execution produces.
147    Combined,
148}
149
150impl OutputChunk {
151    /// Create a new output chunk.
152    pub fn new(raw: Vec<u8>, source: OutputSource) -> Self {
153        let text = String::from_utf8_lossy(&raw).into_owned();
154        Self { raw, text, source }
155    }
156
157    /// Create a stdout chunk.
158    ///
159    /// **Nothing in this crate calls this** — execution merges the two streams
160    /// and builds every chunk with [`combined`](Self::combined). See
161    /// [`OutputSource`].
162    pub fn stdout(raw: Vec<u8>) -> Self {
163        Self::new(raw, OutputSource::Stdout)
164    }
165
166    /// Create a combined output chunk.
167    pub fn combined(raw: Vec<u8>) -> Self {
168        Self::new(raw, OutputSource::Combined)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_execution_result_new() {
178        let result = ExecutionResult::new(
179            b"hello\n".to_vec(),
180            "hello\n".to_string(),
181            Duration::from_millis(100),
182        );
183
184        assert_eq!(result.raw_output, b"hello\n");
185        assert_eq!(result.text_output, "hello\n");
186        assert_eq!(result.duration, Duration::from_millis(100));
187        assert!(!result.timed_out);
188        assert!(result.exit_code.is_none());
189    }
190
191    #[test]
192    fn test_execution_result_success() {
193        let result = ExecutionResult::default().with_exit_code(0);
194        assert!(result.success());
195        assert!(!result.failed());
196    }
197
198    #[test]
199    fn test_execution_result_failed() {
200        let result = ExecutionResult::default().with_exit_code(1);
201        assert!(!result.success());
202        assert!(result.failed());
203    }
204
205    #[test]
206    fn test_execution_result_timeout() {
207        let result = ExecutionResult::timeout(vec![], String::new(), Duration::from_secs(30));
208        assert!(result.timed_out);
209        assert!(result.failed());
210    }
211
212    #[test]
213    fn test_output_trimmed() {
214        let result = ExecutionResult::new(vec![], "  hello world  \n".to_string(), Duration::ZERO);
215        assert_eq!(result.output_trimmed(), "hello world");
216    }
217
218    #[test]
219    fn test_output_lines() {
220        let result =
221            ExecutionResult::new(vec![], "line1\nline2\nline3".to_string(), Duration::ZERO);
222        let lines: Vec<_> = result.output_lines().collect();
223        assert_eq!(lines, vec!["line1", "line2", "line3"]);
224    }
225
226    #[test]
227    fn test_output_chunk_stdout() {
228        let chunk = OutputChunk::stdout(b"test output".to_vec());
229        assert_eq!(chunk.source, OutputSource::Stdout);
230        assert_eq!(chunk.text, "test output");
231    }
232
233    #[test]
234    fn test_output_chunk_combined() {
235        let chunk = OutputChunk::combined(b"mixed output".to_vec());
236        assert_eq!(chunk.source, OutputSource::Combined);
237    }
238}