use std::fmt;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use tokio::io::{AsyncRead, AsyncWriteExt};
use tokio_stream::{Stream, StreamExt};
type SharedReader = Arc<Mutex<Option<Pin<Box<dyn AsyncRead + Send>>>>>;
type SharedLines = Arc<Mutex<Option<Pin<Box<dyn Stream<Item = String> + Send>>>>>;
fn lock_cell<T>(cell: &Arc<Mutex<T>>) -> MutexGuard<'_, T> {
cell.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Clone)]
pub struct Stdin(Source);
#[derive(Clone)]
enum Source {
Empty,
Bytes(Vec<u8>),
File(PathBuf),
Reader(SharedReader),
Lines(SharedLines),
}
impl Stdin {
pub fn empty() -> Self {
Stdin(Source::Empty)
}
pub fn from_string(text: impl Into<String>) -> Self {
Stdin(Source::Bytes(text.into().into_bytes()))
}
pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
Stdin(Source::Bytes(bytes.into()))
}
pub fn from_file(path: impl AsRef<Path>) -> Self {
Stdin(Source::File(path.as_ref().to_path_buf()))
}
pub fn from_iter_lines<I, S>(lines: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut buf = Vec::new();
for line in lines {
buf.extend_from_slice(line.as_ref().as_bytes());
buf.push(b'\n');
}
Stdin(Source::Bytes(buf))
}
pub fn from_reader<R>(reader: R) -> Self
where
R: AsyncRead + Send + 'static,
{
Stdin(Source::Reader(Arc::new(Mutex::new(Some(Box::pin(reader))))))
}
pub fn from_lines<S>(lines: S) -> Self
where
S: Stream<Item = String> + Send + 'static,
{
Stdin(Source::Lines(Arc::new(Mutex::new(Some(Box::pin(lines))))))
}
pub(crate) fn is_empty(&self) -> bool {
matches!(self.0, Source::Empty)
}
pub(crate) fn is_one_shot(&self) -> bool {
matches!(self.0, Source::Reader(_) | Source::Lines(_))
}
#[cfg(feature = "record")]
pub(crate) fn content_digest(&self) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
fn mix(mut h: u64, bytes: &[u8]) -> u64 {
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(PRIME);
}
h
}
let (tag, payload): (u8, &[u8]) = match &self.0 {
Source::Empty => (0, &[]),
Source::Bytes(b) => (1, b),
Source::File(p) => (2, p.as_os_str().as_encoded_bytes()),
Source::Reader(_) | Source::Lines(_) => (3, b"<stream>"),
};
mix(mix(OFFSET, &[tag]), payload)
}
pub(crate) fn stdio(&self) -> Stdio {
if self.is_empty() {
Stdio::null()
} else {
Stdio::piped()
}
}
pub(crate) fn take_for_run(&self) -> Result<StdinReservation, OneShotConsumed> {
let reserved = match &self.0 {
Source::Empty => Reserved::Reusable(TakenStdin::Empty),
Source::Bytes(bytes) => Reserved::Reusable(TakenStdin::Bytes(bytes.clone())),
Source::File(path) => Reserved::Reusable(TakenStdin::File(path.clone())),
Source::Reader(reader) => match lock_cell(reader).take() {
Some(r) => Reserved::OneShotReader(r, Arc::clone(reader)),
None => return Err(OneShotConsumed),
},
Source::Lines(lines) => match lock_cell(lines).take() {
Some(s) => Reserved::OneShotLines(s, Arc::clone(lines)),
None => return Err(OneShotConsumed),
},
};
Ok(StdinReservation(Some(reserved)))
}
}
#[derive(Debug)]
pub(crate) struct OneShotConsumed;
pub(crate) struct StdinReservation(Option<Reserved>);
enum Reserved {
Reusable(TakenStdin),
OneShotReader(Pin<Box<dyn AsyncRead + Send>>, SharedReader),
OneShotLines(Pin<Box<dyn Stream<Item = String> + Send>>, SharedLines),
}
impl StdinReservation {
pub(crate) fn commit(mut self) -> TakenStdin {
match self
.0
.take()
.expect("a StdinReservation is committed at most once")
{
Reserved::Reusable(payload) => payload,
Reserved::OneShotReader(reader, _cell) => TakenStdin::Reader(reader),
Reserved::OneShotLines(lines, _cell) => TakenStdin::Lines(lines),
}
}
}
impl Drop for StdinReservation {
fn drop(&mut self) {
match self.0.take() {
Some(Reserved::OneShotReader(reader, cell)) => {
*lock_cell(&cell) = Some(reader);
}
Some(Reserved::OneShotLines(lines, cell)) => {
*lock_cell(&cell) = Some(lines);
}
Some(Reserved::Reusable(_)) | None => {}
}
}
}
pub(crate) enum TakenStdin {
Empty,
Bytes(Vec<u8>),
File(PathBuf),
Reader(Pin<Box<dyn AsyncRead + Send>>),
Lines(Pin<Box<dyn Stream<Item = String> + Send>>),
}
impl TakenStdin {
pub(crate) fn is_empty(&self) -> bool {
matches!(self, TakenStdin::Empty)
}
pub(crate) async fn write_to<W>(self, sink: &mut W) -> std::io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
match self {
TakenStdin::Empty => Ok(()),
TakenStdin::Bytes(bytes) => sink.write_all(&bytes).await,
TakenStdin::File(path) => {
let mut file = tokio::fs::File::open(&path).await?;
tokio::io::copy(&mut file, sink).await.map(|_| ())
}
TakenStdin::Reader(mut r) => tokio::io::copy(&mut r, sink).await.map(|_| ()),
TakenStdin::Lines(mut stream) => {
while let Some(line) = stream.next().await {
sink.write_all(line.as_bytes()).await?;
sink.write_all(b"\n").await?;
}
Ok(())
}
}
}
}
impl fmt::Debug for Stdin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match &self.0 {
Source::Empty => "Empty",
Source::Bytes(_) => "Bytes",
Source::File(_) => "File",
Source::Reader(_) => "Reader",
Source::Lines(_) => "Lines",
};
f.debug_tuple("Stdin").field(&kind).finish()
}
}
pub struct ProcessStdin {
sink: tokio::process::ChildStdin,
}
impl ProcessStdin {
pub(crate) fn new(sink: tokio::process::ChildStdin) -> Self {
Self { sink }
}
pub async fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.sink.write_all(bytes).await
}
pub async fn write_line(&mut self, line: &str) -> std::io::Result<()> {
self.sink.write_all(line.as_bytes()).await?;
self.sink.write_all(b"\n").await?;
self.sink.flush().await
}
pub async fn flush(&mut self) -> std::io::Result<()> {
self.sink.flush().await
}
pub async fn finish(mut self) -> std::io::Result<()> {
self.sink.shutdown().await
}
pub async fn send_control(&mut self, c: char) -> std::io::Result<()> {
let byte = control_byte(c)?;
self.sink.write_all(&[byte]).await
}
}
fn control_byte(c: char) -> std::io::Result<u8> {
match c {
'a'..='z' | 'A'..='Z' | '[' | '\\' | ']' | '^' | '_' => {
Ok((c.to_ascii_uppercase() as u8) & 0x1f)
}
'?' => Ok(0x7f),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"send_control: {c:?} is not a recognized control character \
(expected 'a'..='z'/'A'..='Z', '[', '\\\\', ']', '^', '_', or '?')"
),
)),
}
}
impl fmt::Debug for ProcessStdin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProcessStdin").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn written(stdin: &Stdin) -> Vec<u8> {
let mut sink = Vec::new();
stdin
.take_for_run()
.unwrap_or_else(|_| panic!("source already consumed"))
.commit()
.write_to(&mut sink)
.await
.expect("write_to");
sink
}
#[tokio::test]
async fn reader_source_is_one_shot() {
let stdin = Stdin::from_reader(&b"payload"[..]);
assert_eq!(written(&stdin).await, b"payload");
assert!(
stdin.take_for_run().is_err(),
"a consumed one-shot reader reports OneShotConsumed, not empty"
);
}
#[tokio::test]
async fn is_one_shot_classifies_streaming_sources() {
assert!(Stdin::from_reader(&b"x"[..]).is_one_shot());
assert!(Stdin::from_lines(tokio_stream::iter(vec!["x".to_owned()])).is_one_shot());
assert!(!Stdin::from_bytes(b"abc".to_vec()).is_one_shot());
assert!(!Stdin::from_iter_lines(["a", "b"]).is_one_shot());
assert!(!Stdin::from_string("x").is_one_shot());
assert!(!Stdin::empty().is_one_shot());
}
#[tokio::test]
async fn lines_source_is_one_shot_and_newline_terminated() {
let stdin = Stdin::from_lines(tokio_stream::iter(vec![
"first".to_owned(),
"second".to_owned(),
]));
assert_eq!(written(&stdin).await, b"first\nsecond\n");
assert!(
stdin.take_for_run().is_err(),
"the stream was consumed by the first run"
);
}
#[tokio::test]
async fn iter_lines_is_reusable_and_newline_terminated() {
let stdin = Stdin::from_iter_lines(["a", "b"]);
assert_eq!(written(&stdin).await, b"a\nb\n");
assert_eq!(
written(&stdin).await,
b"a\nb\n",
"eagerly-collected lines replay on every run"
);
}
#[tokio::test]
async fn iter_lines_appends_one_newline_per_item_verbatim() {
assert_eq!(
written(&Stdin::from_iter_lines(["a\n", "b"])).await,
b"a\n\nb\n"
);
assert_eq!(
written(&Stdin::from_iter_lines(Vec::<&str>::new())).await,
b""
);
}
#[tokio::test]
async fn missing_file_surfaces_not_found() {
let stdin = Stdin::from_file("processkit-definitely-missing-424242.txt");
let mut sink = Vec::new();
let err = stdin
.take_for_run()
.unwrap_or_else(|_| panic!("file source is re-runnable"))
.commit()
.write_to(&mut sink)
.await
.expect_err("a missing stdin file must error, not feed silence");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[tokio::test]
async fn empty_source_writes_nothing() {
assert!(written(&Stdin::empty()).await.is_empty());
}
#[test]
fn a_reservation_holds_the_source_taken_until_it_is_committed() {
let stdin = Stdin::from_reader(&b"payload"[..]);
let stdin2 = stdin.clone();
let reservation = stdin.take_for_run().expect("the first reservation wins");
assert!(
stdin2.take_for_run().is_err(),
"a second concurrent take sees the source already reserved"
);
let _committed = reservation.commit();
assert!(
stdin.take_for_run().is_err(),
"a committed one-shot source stays consumed"
);
}
#[tokio::test]
async fn dropping_a_reservation_without_committing_returns_the_payload() {
let stdin = Stdin::from_reader(&b"payload"[..]);
{
let _reservation = stdin.take_for_run().expect("first reservation");
assert!(
stdin.take_for_run().is_err(),
"while a reservation is outstanding the source is taken"
);
}
assert_eq!(written(&stdin).await, b"payload");
assert!(
stdin.take_for_run().is_err(),
"the committed re-run consumes the one-shot source"
);
}
#[tokio::test]
async fn dropping_a_lines_reservation_also_rolls_back() {
let stdin = Stdin::from_lines(tokio_stream::iter(vec!["a".to_owned(), "b".to_owned()]));
drop(stdin.take_for_run().expect("reserve the line stream"));
assert_eq!(written(&stdin).await, b"a\nb\n");
assert!(
stdin.take_for_run().is_err(),
"the committed re-run consumes the line stream"
);
}
#[tokio::test]
async fn a_reusable_source_survives_reserve_commit_and_rollback() {
let stdin = Stdin::from_bytes(b"abc".to_vec());
drop(stdin.take_for_run().expect("reserve")); assert_eq!(written(&stdin).await, b"abc"); assert_eq!(
written(&stdin).await,
b"abc",
"a re-runnable source replays on every run"
);
}
#[test]
fn control_byte_maps_letters_to_the_control_range() {
assert_eq!(control_byte('c').expect("valid"), 0x03);
assert_eq!(control_byte('C').expect("valid"), 0x03);
assert_eq!(control_byte('d').expect("valid"), 0x04);
assert_eq!(control_byte('D').expect("valid"), 0x04);
assert_eq!(control_byte('z').expect("valid"), 0x1a);
assert_eq!(control_byte('[').expect("valid"), 0x1b);
assert_eq!(control_byte('\\').expect("valid"), 0x1c);
assert_eq!(control_byte(']').expect("valid"), 0x1d);
assert_eq!(control_byte('^').expect("valid"), 0x1e);
assert_eq!(control_byte('_').expect("valid"), 0x1f);
assert_eq!(control_byte('?').expect("valid"), 0x7f);
}
#[test]
fn control_byte_rejects_unrecognized_characters() {
for bad in ['0', ' ', '!', '@', 'é', '\u{1}'] {
let err = control_byte(bad).expect_err("must reject an unrecognized character");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}
}