Skip to main content

swiftide_core/
command_output.rs

1//! Command output that retains stdout and stderr identity in observed order.
2
3use std::borrow::Cow;
4
5use bytes::Bytes;
6
7/// A chunk observed while reading a command's output pipes.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum CommandOutputChunk {
10    /// Bytes read from stdout.
11    Stdout(Bytes),
12    /// Bytes read from stderr.
13    Stderr(Bytes),
14}
15
16impl CommandOutputChunk {
17    /// Returns the bytes in this chunk.
18    pub fn as_bytes(&self) -> &Bytes {
19        match self {
20            Self::Stdout(bytes) | Self::Stderr(bytes) => bytes,
21        }
22    }
23
24    fn into_bytes(self) -> Bytes {
25        match self {
26            Self::Stdout(bytes) | Self::Stderr(bytes) => bytes,
27        }
28    }
29}
30
31/// Output collected from a finished command.
32///
33/// Chunks retain the order in which the executor observed stdout and stderr. This is not a
34/// guarantee of the order in which the process wrote to separate operating-system pipes.
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct CommandOutput {
37    chunks: Vec<CommandOutputChunk>,
38}
39
40impl CommandOutput {
41    /// Creates command output without any chunks.
42    pub fn empty() -> Self {
43        Self::default()
44    }
45
46    /// Creates output containing one stdout chunk.
47    pub fn new(stdout: impl Into<Bytes>) -> Self {
48        Self::from_chunks([CommandOutputChunk::Stdout(stdout.into())])
49    }
50
51    /// Creates output from chunks in the order they were observed.
52    ///
53    /// Empty chunks are discarded.
54    pub fn from_chunks(chunks: impl IntoIterator<Item = CommandOutputChunk>) -> Self {
55        Self {
56            chunks: chunks
57                .into_iter()
58                .filter(|chunk| !chunk.as_bytes().is_empty())
59                .collect(),
60        }
61    }
62
63    /// Returns the ordered stdout and stderr chunks.
64    pub fn chunks(&self) -> &[CommandOutputChunk] {
65        &self.chunks
66    }
67
68    /// Iterates over borrowed stdout chunks in observed order.
69    pub fn stdout(&self) -> impl Iterator<Item = &Bytes> {
70        self.chunks.iter().filter_map(|chunk| match chunk {
71            CommandOutputChunk::Stdout(bytes) => Some(bytes),
72            CommandOutputChunk::Stderr(_) => None,
73        })
74    }
75
76    /// Iterates over borrowed stderr chunks in observed order.
77    pub fn stderr(&self) -> impl Iterator<Item = &Bytes> {
78        self.chunks.iter().filter_map(|chunk| match chunk {
79            CommandOutputChunk::Stdout(_) => None,
80            CommandOutputChunk::Stderr(bytes) => Some(bytes),
81        })
82    }
83
84    /// Returns all output bytes in observed chunk order.
85    pub fn as_bytes(&self) -> Cow<'_, [u8]> {
86        match self.chunks.as_slice() {
87            [] => Cow::Borrowed(&[]),
88            [chunk] => Cow::Borrowed(chunk.as_bytes()),
89            chunks => {
90                let mut bytes = Vec::with_capacity(self.len());
91                for chunk in chunks {
92                    bytes.extend_from_slice(chunk.as_bytes());
93                }
94                Cow::Owned(bytes)
95            }
96        }
97    }
98
99    /// Converts all output to text in observed chunk order.
100    pub fn to_string_lossy(&self) -> Cow<'_, str> {
101        match self.as_bytes() {
102            Cow::Borrowed(bytes) => String::from_utf8_lossy(bytes),
103            Cow::Owned(bytes) => Cow::Owned(bytes_into_string_lossy(bytes)),
104        }
105    }
106
107    /// Returns all output bytes in observed chunk order.
108    ///
109    /// A single chunk reuses its allocation when it is uniquely owned; multiple chunks are
110    /// concatenated with one copy.
111    pub fn into_bytes(self) -> Vec<u8> {
112        let output_len = self.len();
113        match <[CommandOutputChunk; 1]>::try_from(self.chunks) {
114            Ok([chunk]) => chunk.into_bytes().into(),
115            Err(chunks) => {
116                let mut bytes = Vec::with_capacity(output_len);
117                for chunk in chunks {
118                    bytes.extend_from_slice(chunk.as_bytes());
119                }
120                bytes
121            }
122        }
123    }
124
125    /// Converts all output to owned text in observed chunk order.
126    pub fn into_string_lossy(self) -> String {
127        bytes_into_string_lossy(self.into_bytes())
128    }
129
130    /// Returns the number of bytes across all chunks.
131    pub fn len(&self) -> usize {
132        self.chunks
133            .iter()
134            .map(CommandOutputChunk::as_bytes)
135            .map(Bytes::len)
136            .sum()
137    }
138
139    /// Returns `true` when there is no stdout or stderr output.
140    pub fn is_empty(&self) -> bool {
141        self.chunks.is_empty()
142    }
143}
144
145impl<T: Into<Bytes>> From<T> for CommandOutput {
146    fn from(value: T) -> Self {
147        Self::new(value)
148    }
149}
150
151impl std::fmt::Display for CommandOutput {
152    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        formatter.write_str(&self.to_string_lossy())
154    }
155}
156
157fn bytes_into_string_lossy(bytes: Vec<u8>) -> String {
158    String::from_utf8(bytes)
159        .unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::{CommandOutput, CommandOutputChunk};
165    use bytes::Bytes;
166    use std::borrow::Cow;
167
168    #[test]
169    fn exposes_borrowed_stream_chunks_in_observed_order() {
170        let output = output([stdout("one"), stderr("two"), stdout("three")]);
171        let stdout = output.stdout().collect::<Vec<_>>();
172        let stderr = output.stderr().collect::<Vec<_>>();
173
174        assert_eq!(
175            stdout.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
176            [b"one".as_slice(), b"three".as_slice()]
177        );
178        assert_eq!(
179            stderr.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
180            [b"two".as_slice()]
181        );
182        assert_eq!(stdout[0].as_ptr(), output.chunks()[0].as_bytes().as_ptr());
183        assert_eq!(stderr[0].as_ptr(), output.chunks()[1].as_bytes().as_ptr());
184    }
185
186    #[test]
187    fn combines_exact_bytes_in_observed_order() {
188        let output = output([stdout("one"), stderr("two"), stdout("three")]);
189
190        assert_eq!(output.as_bytes(), b"onetwothree".as_slice());
191        assert_eq!(output.into_bytes(), b"onetwothree");
192    }
193
194    #[test]
195    fn reuses_single_chunk_allocation_when_converted_into_owned_output() {
196        let bytes = Bytes::from(b"hello".to_vec());
197        let data = bytes.as_ptr();
198
199        let owned_bytes = output([stdout(bytes)]).into_bytes();
200        assert_eq!(owned_bytes, b"hello");
201        assert_eq!(owned_bytes.as_ptr(), data);
202
203        let text = output([stdout(owned_bytes)]).into_string_lossy();
204        assert_eq!(text, "hello");
205        assert_eq!(text.as_ptr(), data);
206    }
207
208    #[test]
209    fn borrows_single_chunk_text() {
210        let output = output([stdout("hello")]);
211
212        assert!(matches!(output.to_string_lossy(), Cow::Borrowed("hello")));
213    }
214
215    #[test]
216    fn owns_multi_chunk_text() {
217        let output = output([stdout("one"), stderr("two")]);
218
219        assert!(matches!(output.to_string_lossy(), Cow::Owned(text) if text == "onetwo"));
220    }
221
222    #[test]
223    fn decodes_utf8_split_across_chunks() {
224        let output = output([
225            stdout(Bytes::from_static(&[0xf0, 0x9f])),
226            stdout(Bytes::from_static(&[0x98, 0x80])),
227        ]);
228
229        assert_eq!(output.to_string_lossy(), "😀");
230    }
231
232    #[test]
233    fn replaces_invalid_utf8_only_when_text_is_requested() {
234        let output = output([stdout(Bytes::from_static(b"valid\xff"))]);
235
236        assert_eq!(output.into_string_lossy(), "valid\u{fffd}");
237    }
238
239    #[test]
240    fn display_uses_observed_chunk_order() {
241        let output = output([stdout("out"), stderr("err")]);
242
243        assert_eq!(output.to_string(), "outerr");
244    }
245
246    #[test]
247    fn display_decodes_utf8_split_across_chunks() {
248        let output = output([
249            stdout(Bytes::from_static(&[0xf0, 0x9f])),
250            stdout(Bytes::from_static(&[0x98, 0x80])),
251        ]);
252
253        assert_eq!(output.to_string(), "😀");
254    }
255
256    #[test]
257    fn omits_empty_chunks() {
258        let output = output([stdout(""), stderr("")]);
259
260        assert!(output.is_empty());
261        assert!(output.chunks().is_empty());
262    }
263
264    fn output(chunks: impl IntoIterator<Item = CommandOutputChunk>) -> CommandOutput {
265        CommandOutput::from_chunks(chunks)
266    }
267
268    fn stdout(bytes: impl Into<Bytes>) -> CommandOutputChunk {
269        CommandOutputChunk::Stdout(bytes.into())
270    }
271
272    fn stderr(bytes: impl Into<Bytes>) -> CommandOutputChunk {
273        CommandOutputChunk::Stderr(bytes.into())
274    }
275}