use std::env;
use std::io;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
use super::spec::ChildSpec;
const ROLE_ENV: &str = "PROTOCOL_DROID_STDIO_CHILD_ROLE";
pub(super) const STDERR_MARKER: &str = "protocol-droid-child-stderr-marker";
const CHILD_TEST: &str = "adapters::secondary::stdio::testing::tests::stdio_child_server";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Role {
Echo,
Split,
Once,
Silent,
Exit,
Stderr,
}
impl Role {
fn name(self) -> &'static str {
match self {
Self::Echo => "echo",
Self::Split => "split",
Self::Once => "once",
Self::Silent => "silent",
Self::Exit => "exit",
Self::Stderr => "stderr",
}
}
pub(super) const fn all() -> [Self; 6] {
[
Self::Echo,
Self::Split,
Self::Once,
Self::Silent,
Self::Exit,
Self::Stderr,
]
}
fn from_env() -> Option<Self> {
let name = env::var(ROLE_ENV).ok()?;
Self::all().into_iter().find(|role| role.name() == name)
}
}
pub(super) fn child_spec(role: Role) -> Result<ChildSpec, SpawnFailure> {
let exe = env::current_exe().map_err(|_| SpawnFailure::NoCurrentExe)?;
ChildSpec::new(exe)
.and_then(|spec| {
spec.with_args([
"--ignored",
"--exact",
CHILD_TEST,
"--nocapture",
"--quiet",
"--test-threads=1",
])
})
.and_then(|spec| spec.with_env(ROLE_ENV, role.name()))
.map_err(|_| SpawnFailure::BadSpec)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SpawnFailure {
NoCurrentExe,
BadSpec,
}
#[derive(Clone, Default)]
pub(super) struct Capture {
written: Arc<Mutex<Vec<u8>>>,
}
impl Capture {
pub(super) fn shared() -> Self {
Self::default()
}
pub(super) fn contents(&self) -> String {
String::from_utf8_lossy(&self.bytes()).into_owned()
}
pub(super) fn bytes(&self) -> Vec<u8> {
self.written.lock().map_or_else(
|poisoned| Vec::clone(&poisoned.into_inner()),
|guard| Vec::clone(&guard),
)
}
}
impl AsyncWrite for Capture {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let mut written = match self.written.lock() {
Ok(written) => written,
Err(poisoned) => poisoned.into_inner(),
};
written.extend_from_slice(buf);
drop(written);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[derive(Debug, Default)]
struct LineReader {
buffered: Vec<u8>,
}
impl LineReader {
fn next_line(&mut self) -> Option<String> {
let newline = self.buffered.iter().position(|byte| *byte == b'\n')?;
let line: Vec<u8> = self.buffered.drain(..=newline).collect();
String::from_utf8(line).ok()
}
fn push(&mut self, bytes: &[u8]) {
self.buffered.extend_from_slice(bytes);
}
}
#[cfg(test)]
mod tests {
use std::io::{self, Read as _, Write};
use serde_json::Value;
use tokio::io::AsyncWriteExt;
use super::{Capture, LineReader, Role, STDERR_MARKER, SpawnFailure, child_spec};
use crate::application::jsonrpc::document::RequestError;
use crate::application::jsonrpc::document::{reply_result, request_document};
use crate::domain::RequestId;
use crate::domain::ir::{JsonObject, MethodName, MethodNameError};
#[derive(Debug)]
#[expect(dead_code, reason = "the payloads are read by the derived Debug")]
enum Failure {
Io(io::Error),
Spec(SpawnFailure),
Request(RequestError),
Method(MethodNameError),
}
impl From<io::Error> for Failure {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<SpawnFailure> for Failure {
fn from(error: SpawnFailure) -> Self {
Self::Spec(error)
}
}
impl From<RequestError> for Failure {
fn from(error: RequestError) -> Self {
Self::Request(error)
}
}
impl From<MethodNameError> for Failure {
fn from(error: MethodNameError) -> Self {
Self::Method(error)
}
}
#[test]
#[ignore = "spawned as a child process by the stdio client tests"]
fn stdio_child_server() {
let Some(role) = Role::from_env() else {
return;
};
if role == Role::Exit {
exit();
}
if role == Role::Stderr {
let mut stderr = std::io::stderr();
let marker = format!("{STDERR_MARKER}\n");
if stderr.write_all(marker.as_bytes()).is_err() || stderr.flush().is_err() {
exit();
}
}
let mut stdin = std::io::stdin().lock();
let mut stdout = std::io::stdout().lock();
let mut reader = LineReader::default();
let mut buffer = [0_u8; 4 * 1024];
loop {
while let Some(line) = reader.next_line() {
if role == Role::Silent {
continue;
}
let Some(reply) = answer(&line, role) else {
continue;
};
if write_reply(&mut stdout, &reply, role).is_err() {
exit();
}
if role == Role::Once {
exit();
}
}
match stdin.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
let Some(chunk) = buffer.get(..read) else {
break;
};
reader.push(chunk);
}
}
}
exit();
}
fn write_reply(out: &mut impl Write, reply: &str, role: Role) -> io::Result<()> {
if role != Role::Split {
return out.write_all(reply.as_bytes()).and_then(|()| out.flush());
}
let half = reply.len() / 2;
let (first, second) = reply.split_at(half);
out.write_all(first.as_bytes())?;
out.flush()?;
out.write_all(second.as_bytes())?;
out.flush()
}
fn answer(line: &str, role: Role) -> Option<String> {
let request: Value = serde_json::from_str(line).ok()?;
let id = request.get("id")?.clone();
let method = request.get("method").and_then(Value::as_str)?;
let result = serde_json::json!({
"child": true,
"method": method,
"role": role.name(),
});
let document = serde_json::json!({
"id": id,
"jsonrpc": "2.0",
"result": result,
});
Some(format!("{document}\n"))
}
fn exit() -> ! {
std::process::exit(0)
}
#[tokio::test]
async fn a_capture_sees_exactly_what_was_written() -> Result<(), Failure> {
let capture = Capture::shared();
let mut writer = capture.clone();
writer.write_all(STDERR_MARKER.as_bytes()).await?;
assert_eq!(capture.contents(), STDERR_MARKER);
Ok(())
}
#[test]
fn a_line_reader_only_yields_whole_lines() {
let mut reader = LineReader::default();
reader.push(b"first\nsec");
assert_eq!(reader.next_line().as_deref(), Some("first\n"));
assert_eq!(reader.next_line(), None);
}
#[test]
fn every_role_becomes_a_child_spec() -> Result<(), Failure> {
for role in Role::all() {
child_spec(role)?;
}
Ok(())
}
#[test]
fn the_childs_answer_is_a_reply_this_crate_reads() -> Result<(), Failure> {
let method = MethodName::new("tools/list")?;
let id = RequestId::text("r-1");
let request = request_document(&id, &method, &JsonObject::default())?.to_string();
let reply = answer(&request, Role::Echo).ok_or(Failure::Spec(SpawnFailure::BadSpec))?;
let result = reply_result(&reply, &id)?;
assert_eq!(
result.as_str(),
r#"{"child":true,"method":"tools/list","role":"echo"}"#
);
Ok(())
}
}