swiftide_core/
command_output.rs1use std::borrow::Cow;
4
5use bytes::Bytes;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum CommandOutputChunk {
10 Stdout(Bytes),
12 Stderr(Bytes),
14}
15
16impl CommandOutputChunk {
17 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct CommandOutput {
37 chunks: Vec<CommandOutputChunk>,
38}
39
40impl CommandOutput {
41 pub fn empty() -> Self {
43 Self::default()
44 }
45
46 pub fn new(stdout: impl Into<Bytes>) -> Self {
48 Self::from_chunks([CommandOutputChunk::Stdout(stdout.into())])
49 }
50
51 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 pub fn chunks(&self) -> &[CommandOutputChunk] {
65 &self.chunks
66 }
67
68 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 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 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 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 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 pub fn into_string_lossy(self) -> String {
127 bytes_into_string_lossy(self.into_bytes())
128 }
129
130 pub fn len(&self) -> usize {
132 self.chunks
133 .iter()
134 .map(CommandOutputChunk::as_bytes)
135 .map(Bytes::len)
136 .sum()
137 }
138
139 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}