use std::io::Read;
use tokio::io::{AsyncBufRead, AsyncBufReadExt};
pub enum CappedLine {
Line(Vec<u8>),
TooLong,
Eof,
}
pub async fn read_line_capped<R>(reader: &mut R, max_bytes: usize) -> std::io::Result<CappedLine>
where
R: AsyncBufRead + Unpin,
{
let mut buf: Vec<u8> = Vec::new();
let mut overflow = false;
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
if overflow {
return Ok(CappedLine::TooLong);
}
if buf.is_empty() {
return Ok(CappedLine::Eof);
}
return Ok(CappedLine::Line(strip_trailing_cr(buf)));
}
if let Some(nl) = available.iter().position(|&b| b == b'\n') {
if !overflow {
if buf.len() + nl > max_bytes {
overflow = true;
} else {
buf.extend_from_slice(&available[..nl]);
}
}
reader.consume(nl + 1);
if overflow {
return Ok(CappedLine::TooLong);
}
return Ok(CappedLine::Line(strip_trailing_cr(buf)));
}
let n = available.len();
if !overflow {
if buf.len() + n > max_bytes {
overflow = true;
buf = Vec::new();
} else {
buf.extend_from_slice(available);
}
}
reader.consume(n);
}
}
fn strip_trailing_cr(mut v: Vec<u8>) -> Vec<u8> {
if v.last() == Some(&b'\r') {
v.pop();
}
v
}
pub fn read_file_capped(
path: &std::path::Path,
max_bytes: usize,
) -> std::io::Result<(Vec<u8>, bool)> {
read_capped(std::fs::File::open(path)?, max_bytes)
}
pub fn read_capped(file: std::fs::File, max_bytes: usize) -> std::io::Result<(Vec<u8>, bool)> {
let mut buf = Vec::new();
let cap_plus = (max_bytes as u64).saturating_add(1);
file.take(cap_plus).read_to_end(&mut buf)?;
let truncated = buf.len() > max_bytes;
if truncated {
buf.truncate(max_bytes);
}
Ok((buf, truncated))
}
#[cfg(test)]
mod tests {
use super::*;
async fn read_all(input: &[u8], cap: usize) -> Vec<CappedLine> {
let mut reader = tokio::io::BufReader::new(input);
let mut out = Vec::new();
loop {
match read_line_capped(&mut reader, cap).await.unwrap() {
CappedLine::Eof => break,
other => out.push(other),
}
}
out
}
fn lines(outcomes: &[CappedLine]) -> Vec<String> {
outcomes
.iter()
.map(|o| match o {
CappedLine::Line(b) => String::from_utf8_lossy(b).into_owned(),
CappedLine::TooLong => "<TOOLONG>".to_string(),
CappedLine::Eof => "<EOF>".to_string(),
})
.collect()
}
#[tokio::test]
async fn splits_lines_and_strips_crlf() {
let out = read_all(b"alpha\r\nbeta\ngamma", 1024).await;
assert_eq!(lines(&out), vec!["alpha", "beta", "gamma"]);
}
#[tokio::test]
async fn empty_input_is_eof() {
let out = read_all(b"", 1024).await;
assert!(out.is_empty());
}
#[tokio::test]
async fn oversize_line_is_rejected_then_stream_resyncs() {
let big = "x".repeat(5000);
let input = format!("{big}\nok\n");
let out = read_all(input.as_bytes(), 16).await;
assert_eq!(lines(&out), vec!["<TOOLONG>", "ok"]);
}
#[tokio::test]
async fn line_exactly_at_cap_is_kept() {
let input = b"0123456789\n"; let out = read_all(input, 10).await;
assert_eq!(lines(&out), vec!["0123456789"]);
}
#[test]
fn read_file_capped_truncates_large_files() {
let dir = std::env::temp_dir().join(format!("bounded_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.txt");
std::fs::write(&path, vec![b'a'; 1000]).unwrap();
let (bytes, truncated) = read_file_capped(&path, 100).unwrap();
assert_eq!(bytes.len(), 100);
assert!(truncated);
let (bytes, truncated) = read_file_capped(&path, 5000).unwrap();
assert_eq!(bytes.len(), 1000);
assert!(!truncated);
let _ = std::fs::remove_dir_all(&dir);
}
}