use std::borrow::Cow;
use bytes::Bytes;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandOutputChunk {
Stdout(Bytes),
Stderr(Bytes),
}
impl CommandOutputChunk {
pub fn as_bytes(&self) -> &Bytes {
match self {
Self::Stdout(bytes) | Self::Stderr(bytes) => bytes,
}
}
fn into_bytes(self) -> Bytes {
match self {
Self::Stdout(bytes) | Self::Stderr(bytes) => bytes,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandOutput {
chunks: Vec<CommandOutputChunk>,
}
impl CommandOutput {
pub fn empty() -> Self {
Self::default()
}
pub fn new(stdout: impl Into<Bytes>) -> Self {
Self::from_chunks([CommandOutputChunk::Stdout(stdout.into())])
}
pub fn from_chunks(chunks: impl IntoIterator<Item = CommandOutputChunk>) -> Self {
Self {
chunks: chunks
.into_iter()
.filter(|chunk| !chunk.as_bytes().is_empty())
.collect(),
}
}
pub fn chunks(&self) -> &[CommandOutputChunk] {
&self.chunks
}
pub fn stdout(&self) -> impl Iterator<Item = &Bytes> {
self.chunks.iter().filter_map(|chunk| match chunk {
CommandOutputChunk::Stdout(bytes) => Some(bytes),
CommandOutputChunk::Stderr(_) => None,
})
}
pub fn stderr(&self) -> impl Iterator<Item = &Bytes> {
self.chunks.iter().filter_map(|chunk| match chunk {
CommandOutputChunk::Stdout(_) => None,
CommandOutputChunk::Stderr(bytes) => Some(bytes),
})
}
pub fn as_bytes(&self) -> Cow<'_, [u8]> {
match self.chunks.as_slice() {
[] => Cow::Borrowed(&[]),
[chunk] => Cow::Borrowed(chunk.as_bytes()),
chunks => {
let mut bytes = Vec::with_capacity(self.len());
for chunk in chunks {
bytes.extend_from_slice(chunk.as_bytes());
}
Cow::Owned(bytes)
}
}
}
pub fn to_string_lossy(&self) -> Cow<'_, str> {
match self.as_bytes() {
Cow::Borrowed(bytes) => String::from_utf8_lossy(bytes),
Cow::Owned(bytes) => Cow::Owned(bytes_into_string_lossy(bytes)),
}
}
pub fn into_bytes(self) -> Vec<u8> {
let output_len = self.len();
match <[CommandOutputChunk; 1]>::try_from(self.chunks) {
Ok([chunk]) => chunk.into_bytes().into(),
Err(chunks) => {
let mut bytes = Vec::with_capacity(output_len);
for chunk in chunks {
bytes.extend_from_slice(chunk.as_bytes());
}
bytes
}
}
}
pub fn into_string_lossy(self) -> String {
bytes_into_string_lossy(self.into_bytes())
}
pub fn len(&self) -> usize {
self.chunks
.iter()
.map(CommandOutputChunk::as_bytes)
.map(Bytes::len)
.sum()
}
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
}
impl<T: Into<Bytes>> From<T> for CommandOutput {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl std::fmt::Display for CommandOutput {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.to_string_lossy())
}
}
fn bytes_into_string_lossy(bytes: Vec<u8>) -> String {
String::from_utf8(bytes)
.unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())
}
#[cfg(test)]
mod tests {
use super::{CommandOutput, CommandOutputChunk};
use bytes::Bytes;
use std::borrow::Cow;
#[test]
fn exposes_borrowed_stream_chunks_in_observed_order() {
let output = output([stdout("one"), stderr("two"), stdout("three")]);
let stdout = output.stdout().collect::<Vec<_>>();
let stderr = output.stderr().collect::<Vec<_>>();
assert_eq!(
stdout.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
[b"one".as_slice(), b"three".as_slice()]
);
assert_eq!(
stderr.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
[b"two".as_slice()]
);
assert_eq!(stdout[0].as_ptr(), output.chunks()[0].as_bytes().as_ptr());
assert_eq!(stderr[0].as_ptr(), output.chunks()[1].as_bytes().as_ptr());
}
#[test]
fn combines_exact_bytes_in_observed_order() {
let output = output([stdout("one"), stderr("two"), stdout("three")]);
assert_eq!(output.as_bytes(), b"onetwothree".as_slice());
assert_eq!(output.into_bytes(), b"onetwothree");
}
#[test]
fn reuses_single_chunk_allocation_when_converted_into_owned_output() {
let bytes = Bytes::from(b"hello".to_vec());
let data = bytes.as_ptr();
let owned_bytes = output([stdout(bytes)]).into_bytes();
assert_eq!(owned_bytes, b"hello");
assert_eq!(owned_bytes.as_ptr(), data);
let text = output([stdout(owned_bytes)]).into_string_lossy();
assert_eq!(text, "hello");
assert_eq!(text.as_ptr(), data);
}
#[test]
fn borrows_single_chunk_text() {
let output = output([stdout("hello")]);
assert!(matches!(output.to_string_lossy(), Cow::Borrowed("hello")));
}
#[test]
fn owns_multi_chunk_text() {
let output = output([stdout("one"), stderr("two")]);
assert!(matches!(output.to_string_lossy(), Cow::Owned(text) if text == "onetwo"));
}
#[test]
fn decodes_utf8_split_across_chunks() {
let output = output([
stdout(Bytes::from_static(&[0xf0, 0x9f])),
stdout(Bytes::from_static(&[0x98, 0x80])),
]);
assert_eq!(output.to_string_lossy(), "😀");
}
#[test]
fn replaces_invalid_utf8_only_when_text_is_requested() {
let output = output([stdout(Bytes::from_static(b"valid\xff"))]);
assert_eq!(output.into_string_lossy(), "valid\u{fffd}");
}
#[test]
fn display_uses_observed_chunk_order() {
let output = output([stdout("out"), stderr("err")]);
assert_eq!(output.to_string(), "outerr");
}
#[test]
fn display_decodes_utf8_split_across_chunks() {
let output = output([
stdout(Bytes::from_static(&[0xf0, 0x9f])),
stdout(Bytes::from_static(&[0x98, 0x80])),
]);
assert_eq!(output.to_string(), "😀");
}
#[test]
fn omits_empty_chunks() {
let output = output([stdout(""), stderr("")]);
assert!(output.is_empty());
assert!(output.chunks().is_empty());
}
fn output(chunks: impl IntoIterator<Item = CommandOutputChunk>) -> CommandOutput {
CommandOutput::from_chunks(chunks)
}
fn stdout(bytes: impl Into<Bytes>) -> CommandOutputChunk {
CommandOutputChunk::Stdout(bytes.into())
}
fn stderr(bytes: impl Into<Bytes>) -> CommandOutputChunk {
CommandOutputChunk::Stderr(bytes.into())
}
}