use spate_core::framing::RecordFramer;
use std::collections::VecDeque;
use std::io;
#[derive(Debug)]
pub struct NdjsonFramer {
partial: Vec<u8>,
ready: VecDeque<Vec<u8>>,
decoded_bytes: u64,
max_record_bytes: usize,
}
impl NdjsonFramer {
#[must_use]
pub fn new(max_record_bytes: usize) -> NdjsonFramer {
NdjsonFramer {
partial: Vec::new(),
ready: VecDeque::new(),
decoded_bytes: 0,
max_record_bytes,
}
}
fn push_partial(&mut self, bytes: &[u8]) -> io::Result<()> {
if self.partial.len() + bytes.len() > self.max_record_bytes {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"record line exceeds the configured max_record_bytes ({}); \
is the stream really newline-delimited?",
self.max_record_bytes
),
));
}
self.partial.extend_from_slice(bytes);
Ok(())
}
fn complete_line(&mut self) {
if self.partial.last() == Some(&b'\r') {
self.partial.pop();
}
if self.partial.iter().all(u8::is_ascii_whitespace) {
self.partial.clear();
return;
}
self.ready.push_back(std::mem::take(&mut self.partial));
}
}
impl RecordFramer for NdjsonFramer {
fn push(&mut self, bytes: &[u8]) -> io::Result<()> {
self.decoded_bytes += bytes.len() as u64;
let mut rest = bytes;
while let Some(nl) = rest.iter().position(|&b| b == b'\n') {
self.push_partial(&rest[..nl])?;
self.complete_line();
rest = &rest[nl + 1..];
}
self.push_partial(rest)
}
fn finish(&mut self) -> io::Result<()> {
if !self.partial.is_empty() {
self.complete_line();
}
Ok(())
}
fn pop(&mut self) -> Option<Vec<u8>> {
self.ready.pop_front()
}
fn decoded_bytes(&self) -> u64 {
self.decoded_bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const TEST_CAP: usize = 1 << 20;
fn reference_frames(decoded: &[u8]) -> Vec<Vec<u8>> {
decoded
.split(|&b| b == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| !line.iter().all(u8::is_ascii_whitespace))
.map(<[u8]>::to_vec)
.collect()
}
fn frame_all(max: usize, chunks: &[&[u8]]) -> io::Result<Vec<Vec<u8>>> {
let mut f = NdjsonFramer::new(max);
for chunk in chunks {
f.push(chunk)?;
}
f.finish()?;
let mut out = Vec::new();
while let Some(r) = f.pop() {
out.push(r);
}
Ok(out)
}
#[test]
fn splits_strips_cr_and_skips_blank_lines() {
let stream = b"{\"a\":1}\r\n\n \n{\"b\":2}\n\t\r\n{\"c\":3}";
let records = frame_all(TEST_CAP, &[stream]).unwrap();
assert_eq!(
records,
vec![
b"{\"a\":1}".to_vec(),
b"{\"b\":2}".to_vec(),
b"{\"c\":3}".to_vec()
],
"one CR stripped, whitespace-only lines skipped, unterminated final line kept"
);
}
#[test]
fn empty_stream_frames_no_records() {
assert!(frame_all(TEST_CAP, &[b""]).unwrap().is_empty());
}
#[test]
fn decoded_bytes_counts_every_fed_byte() {
let mut f = NdjsonFramer::new(TEST_CAP);
f.push(b"one\n").unwrap();
f.push(b"two").unwrap();
f.finish().unwrap();
assert_eq!(f.decoded_bytes(), 7);
}
#[test]
fn a_line_over_the_record_cap_is_an_error_not_an_allocation() {
let mut f = NdjsonFramer::new(8);
f.push(b"1234").unwrap();
f.push(b"5678").unwrap();
let err = f.push(b"9").unwrap_err();
assert!(err.to_string().contains("max_record_bytes"), "{err}");
let records = frame_all(8, &[b"12345678\n"]).unwrap();
assert_eq!(records, vec![b"12345678".to_vec()]);
}
#[test]
fn record_framer_is_object_safe() {
let mut framer: Box<dyn RecordFramer> = Box::new(NdjsonFramer::new(TEST_CAP));
framer.push(b"x\n").unwrap();
framer.finish().unwrap();
assert_eq!(framer.pop(), Some(b"x".to_vec()));
}
fn arb_line() -> impl Strategy<Value = Vec<u8>> {
proptest::collection::vec(
prop_oneof![
any::<u8>().prop_filter("no newline", |b| *b != b'\n'),
Just(b' '),
Just(b'\t'),
Just(b'\r'),
],
0..80,
)
}
fn arb_stream() -> impl Strategy<Value = Vec<u8>> {
(
proptest::collection::vec(arb_line(), 0..40),
any::<bool>(), )
.prop_map(|(lines, terminated)| {
let mut stream = lines.join(&b'\n');
if terminated && !stream.is_empty() {
stream.push(b'\n');
}
stream
})
}
proptest! {
#[test]
fn line_framing_is_chunking_independent(stream in arb_stream()) {
let expected = reference_frames(&stream);
for split in 0..=stream.len() {
let framed = frame_all(TEST_CAP, &[&stream[..split], &stream[split..]]).unwrap();
prop_assert_eq!(&framed, &expected, "split at {}", split);
}
}
}
}