#[derive(Default)]
pub struct LineBuffer {
buf: Vec<u8>,
}
impl LineBuffer {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, bytes: &[u8]) -> Vec<String> {
self.buf.extend_from_slice(bytes);
let mut out = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i < self.buf.len() {
if self.buf[i] == b'\n' {
let end = if i > start && self.buf[i - 1] == b'\r' {
i - 1
} else {
i
};
out.push(String::from_utf8_lossy(&self.buf[start..end]).into_owned());
start = i + 1;
}
i += 1;
}
if start > 0 {
self.buf.drain(..start);
}
out
}
pub fn take_remaining(&mut self) -> Option<String> {
if self.buf.is_empty() {
return None;
}
let s = String::from_utf8_lossy(&self.buf).into_owned();
self.buf.clear();
if s.is_empty() { None } else { Some(s) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_line_on_one_push_returns_it() {
let mut b = LineBuffer::new();
let lines = b.push(b"hello\n");
assert_eq!(lines, vec!["hello".to_string()]);
assert!(b.take_remaining().is_none());
}
#[test]
fn split_line_is_reassembled_across_pushes() {
let mut b = LineBuffer::new();
assert!(b.push(b"hel").is_empty());
assert!(b.push(b"lo wor").is_empty());
let lines = b.push(b"ld\n");
assert_eq!(lines, vec!["hello world".to_string()]);
}
#[test]
fn multiple_lines_in_one_push() {
let mut b = LineBuffer::new();
let lines = b.push(b"a\nb\nc\n");
assert_eq!(lines, vec!["a", "b", "c"]);
}
#[test]
fn crlf_is_stripped() {
let mut b = LineBuffer::new();
let lines = b.push(b"data: foo\r\n");
assert_eq!(lines, vec!["data: foo".to_string()]);
}
#[test]
fn utf8_word_split_across_chunks_survives() {
let mut b = LineBuffer::new();
assert!(b.push("à reb".as_bytes()).is_empty());
let lines = b.push(b"ours\n");
assert_eq!(lines, vec!["à rebours".to_string()]);
}
#[test]
fn trailing_partial_stays_until_completed() {
let mut b = LineBuffer::new();
assert!(b.push(b"partial").is_empty());
assert_eq!(b.take_remaining(), Some("partial".to_string()));
assert!(b.take_remaining().is_none());
}
}