use std::collections::VecDeque;
use std::pin::Pin;
use bytes::{Bytes, BytesMut};
use futures::{Stream, StreamExt};
use crate::error::KovaError;
use crate::models::StreamEvent;
pub(crate) enum LineOutcome {
Events(Vec<StreamEvent>),
#[cfg_attr(not(any(feature = "openai", feature = "gemini")), allow(dead_code))]
Done,
Fail(KovaError),
}
struct State<F> {
stream: Pin<Box<dyn Stream<Item = Result<Bytes, KovaError>> + Send>>,
buffer: BytesMut,
pending: VecDeque<StreamEvent>,
parse_line: F,
eof: bool,
finished: bool,
}
pub(crate) fn line_stream_to_events<F>(
byte_stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
parse_line: F,
) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, KovaError>> + Send>>
where
F: FnMut(&str) -> LineOutcome + Send + 'static,
{
let mapped = byte_stream
.map(|chunk| chunk.map_err(|e| KovaError::Stream(format!("Connection error: {e}"))));
let state = State {
stream: Box::pin(mapped),
buffer: BytesMut::new(),
pending: VecDeque::new(),
parse_line,
eof: false,
finished: false,
};
Box::pin(futures::stream::unfold(state, |mut st| async move {
loop {
if let Some(event) = st.pending.pop_front() {
return Some((Ok(event), st));
}
if st.finished {
return None;
}
if let Some(nl) = st.buffer.iter().position(|&b| b == b'\n') {
let line_bytes = st.buffer.split_to(nl + 1);
let line = String::from_utf8_lossy(&line_bytes[..nl]);
match (st.parse_line)(line.as_ref()) {
LineOutcome::Events(events) => {
st.pending.extend(events);
continue;
}
LineOutcome::Done => return None,
LineOutcome::Fail(e) => return Some((Err(e), st)),
}
}
if st.eof {
st.finished = true;
if st.buffer.is_empty() {
return None;
}
let rest = std::mem::take(&mut st.buffer);
let line = String::from_utf8_lossy(&rest);
match (st.parse_line)(line.as_ref()) {
LineOutcome::Events(events) => {
st.pending.extend(events);
continue;
}
LineOutcome::Done => return None,
LineOutcome::Fail(e) => return Some((Err(e), st)),
}
}
match st.stream.next().await {
Some(Ok(bytes)) => st.buffer.extend_from_slice(&bytes),
Some(Err(e)) => return Some((Err(e), st)),
None => st.eof = true,
}
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::StreamEvent;
fn byte_chunks(
chunks: Vec<Vec<u8>>,
) -> impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static {
futures::stream::iter(chunks.into_iter().map(|c| Ok(Bytes::from(c))))
}
fn text_events(line: &str) -> LineOutcome {
if line.trim().is_empty() {
return LineOutcome::Events(vec![]);
}
LineOutcome::Events(vec![StreamEvent::ContentDelta {
text: line.to_string(),
}])
}
async fn collect_texts(
stream: Pin<Box<dyn Stream<Item = Result<StreamEvent, KovaError>> + Send>>,
) -> Vec<String> {
stream
.map(|r| match r.unwrap() {
StreamEvent::ContentDelta { text } => text,
other => panic!("unexpected event {other:?}"),
})
.collect()
.await
}
#[tokio::test]
async fn multibyte_char_split_across_chunks_survives() {
let stream = line_stream_to_events(
byte_chunks(vec![vec![b'h', 0xC3], vec![0xA9, b'l', b'l', b'o', b'\n']]),
text_events,
);
assert_eq!(collect_texts(stream).await, vec!["héllo"]);
}
#[tokio::test]
async fn lines_split_across_many_chunks_reassemble() {
let stream = line_stream_to_events(
byte_chunks(vec![
b"first ".to_vec(),
b"line\nsecond".to_vec(),
b" line\n".to_vec(),
]),
text_events,
);
assert_eq!(
collect_texts(stream).await,
vec!["first line", "second line"]
);
}
#[tokio::test]
async fn trailing_line_without_newline_is_parsed_at_eof() {
let stream = line_stream_to_events(byte_chunks(vec![b"a\nb".to_vec()]), text_events);
assert_eq!(collect_texts(stream).await, vec!["a", "b"]);
}
#[tokio::test]
async fn done_outcome_ends_stream() {
let stream = line_stream_to_events(byte_chunks(vec![b"a\nDONE\nb\n".to_vec()]), |line| {
if line == "DONE" {
LineOutcome::Done
} else {
text_events(line)
}
});
assert_eq!(collect_texts(stream).await, vec!["a"]);
}
#[tokio::test]
async fn fail_outcome_surfaces_error() {
let mut stream = line_stream_to_events(byte_chunks(vec![b"bad\n".to_vec()]), |_line| {
LineOutcome::Fail(KovaError::Stream("boom".into()))
});
let first = stream.next().await.unwrap();
assert!(matches!(first, Err(KovaError::Stream(m)) if m == "boom"));
}
}