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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum OutputSource {
129    /// Standard output.
130    Stdout,
131    /// Standard error (if separate).
132    Stderr,
133    /// Combined output.
134    Combined,
135}
136
137impl OutputChunk {
138    /// Create a new output chunk.
139    pub fn new(raw: Vec<u8>, source: OutputSource) -> Self {
140        let text = String::from_utf8_lossy(&raw).into_owned();
141        Self { raw, text, source }
142    }
143
144    /// Create a stdout chunk.
145    pub fn stdout(raw: Vec<u8>) -> Self {
146        Self::new(raw, OutputSource::Stdout)
147    }
148
149    /// Create a combined output chunk.
150    pub fn combined(raw: Vec<u8>) -> Self {
151        Self::new(raw, OutputSource::Combined)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_execution_result_new() {
161        let result = ExecutionResult::new(
162            b"hello\n".to_vec(),
163            "hello\n".to_string(),
164            Duration::from_millis(100),
165        );
166
167        assert_eq!(result.raw_output, b"hello\n");
168        assert_eq!(result.text_output, "hello\n");
169        assert_eq!(result.duration, Duration::from_millis(100));
170        assert!(!result.timed_out);
171        assert!(result.exit_code.is_none());
172    }
173
174    #[test]
175    fn test_execution_result_success() {
176        let result = ExecutionResult::default().with_exit_code(0);
177        assert!(result.success());
178        assert!(!result.failed());
179    }
180
181    #[test]
182    fn test_execution_result_failed() {
183        let result = ExecutionResult::default().with_exit_code(1);
184        assert!(!result.success());
185        assert!(result.failed());
186    }
187
188    #[test]
189    fn test_execution_result_timeout() {
190        let result = ExecutionResult::timeout(vec![], String::new(), Duration::from_secs(30));
191        assert!(result.timed_out);
192        assert!(result.failed());
193    }
194
195    #[test]
196    fn test_output_trimmed() {
197        let result = ExecutionResult::new(vec![], "  hello world  \n".to_string(), Duration::ZERO);
198        assert_eq!(result.output_trimmed(), "hello world");
199    }
200
201    #[test]
202    fn test_output_lines() {
203        let result =
204            ExecutionResult::new(vec![], "line1\nline2\nline3".to_string(), Duration::ZERO);
205        let lines: Vec<_> = result.output_lines().collect();
206        assert_eq!(lines, vec!["line1", "line2", "line3"]);
207    }
208
209    #[test]
210    fn test_output_chunk_stdout() {
211        let chunk = OutputChunk::stdout(b"test output".to_vec());
212        assert_eq!(chunk.source, OutputSource::Stdout);
213        assert_eq!(chunk.text, "test output");
214    }
215
216    #[test]
217    fn test_output_chunk_combined() {
218        let chunk = OutputChunk::combined(b"mixed output".to_vec());
219        assert_eq!(chunk.source, OutputSource::Combined);
220    }
221}