use crate::io::reader::StreamEvent;
use crate::io::reader::StreamEvent::{Ended, InitialReadComplete};
use crate::io::reader::line_batcher::{BUF_READER_CAPACITY, LineBatch, ReadResult, read_batch};
use anyhow::{Context, Result};
use std::fs::File;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;
use std::thread;
use std::time::Duration;
const POLL_INTERVAL: Duration = Duration::from_millis(100);
enum Stage {
InitialRead,
Following,
Terminated,
}
pub struct FileReader {
reader: BufReader<File>,
buf: Vec<u8>,
stage: Stage,
terminate_after_first_read: bool,
}
impl FileReader {
pub fn new<P: AsRef<Path>>(file_path: P, terminate_after_first_read: bool) -> Result<FileReader> {
let file_path = std::fs::canonicalize(file_path.as_ref()).context("Could not canonicalize file path")?;
let file = File::open(&file_path).context("Could not open file")?;
let reader = BufReader::with_capacity(BUF_READER_CAPACITY, file);
Ok(Self {
reader,
buf: Vec::new(),
stage: Stage::InitialRead,
terminate_after_first_read,
})
}
fn next_line(&mut self) -> Result<LineBatch> {
loop {
let bytes_read = self
.reader
.read_until(b'\n', &mut self.buf)
.context("Could not read next line")?;
if bytes_read == 0 {
let file_size = self.reader.get_ref().metadata().context("Could not stat file")?.len();
let position = self.reader.stream_position().context("Could not get stream position")?;
if file_size < position {
self.reader
.seek(SeekFrom::Start(0))
.context("Could not seek to start after truncation")?;
self.buf.clear();
}
thread::sleep(POLL_INTERVAL);
continue;
}
if self.buf.ends_with(b"\n") {
let line = LineBatch::single_line(&self.buf);
self.buf.clear();
return Ok(line);
}
thread::sleep(POLL_INTERVAL);
}
}
pub fn next(&mut self) -> Result<StreamEvent> {
match self.stage {
Stage::InitialRead => match read_batch(&mut self.reader)? {
ReadResult::Batch(batch) => Ok(StreamEvent::Lines(batch)),
ReadResult::Eof => {
self.stage = if self.terminate_after_first_read {
Stage::Terminated
} else {
Stage::Following
};
Ok(InitialReadComplete)
}
},
Stage::Following => Ok(StreamEvent::Lines(self.next_line()?)),
Stage::Terminated => Ok(Ended),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::reader::StreamEvent::*;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use tempfile::tempdir;
fn events_of(mut reader: FileReader) -> Receiver<Result<StreamEvent>> {
let (tx, rx) = channel();
thread::spawn(move || {
loop {
let event = reader.next();
let done = matches!(event, Ok(Ended) | Err(_));
if tx.send(event).is_err() || done {
break;
}
}
});
rx
}
fn texts(batch: &LineBatch) -> Vec<String> {
batch.iter().map(std::borrow::Cow::into_owned).collect()
}
fn next_event(events: &Receiver<Result<StreamEvent>>) -> StreamEvent {
events
.recv_timeout(Duration::from_secs(5))
.expect("timed out waiting for an event")
.expect("reader errored")
}
fn assert_no_event(events: &Receiver<Result<StreamEvent>>) {
match events.recv_timeout(Duration::from_millis(200)) {
Err(RecvTimeoutError::Timeout) => {}
other => panic!("expected no event, got {other:?}"),
}
}
#[test]
fn test_read_exactly_n_lines() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.log");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "line1").unwrap();
writeln!(file, "line2").unwrap();
writeln!(file, "line3").unwrap();
let events = events_of(FileReader::new(file_path, false)?);
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["line1", "line2", "line3"]),
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
assert!(matches!(next_event(&events), InitialReadComplete));
assert_no_event(&events);
Ok(())
}
#[test]
fn test_terminate_after_first_read() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.log");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "only_line").unwrap();
let mut reader = FileReader::new(file_path, true)?;
match reader.next()? {
Lines(batch) => assert_eq!(texts(&batch), vec!["only_line"]),
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
assert!(matches!(reader.next()?, InitialReadComplete));
assert!(matches!(reader.next()?, Ended));
Ok(())
}
#[test]
fn test_append_new_lines_after_initial_read() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("test_append.log");
let mut file = File::create(&file_path)?;
writeln!(file, "initial1")?;
writeln!(file, "initial2")?;
let events = events_of(FileReader::new(file_path.as_path(), false)?);
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["initial1", "initial2"]),
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
assert!(matches!(next_event(&events), InitialReadComplete));
let mut file = OpenOptions::new().append(true).open(&file_path)?;
writeln!(file, "appended1")?;
writeln!(file, "appended2")?;
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["appended1"]),
other => panic!("Expected appended1, got {other:?}"),
}
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["appended2"]),
other => panic!("Expected appended2, got {other:?}"),
}
Ok(())
}
#[test]
fn test_empty_file() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("empty.log");
File::create(&file_path).unwrap();
let mut reader = FileReader::new(file_path, true)?;
assert!(matches!(reader.next()?, InitialReadComplete));
assert!(matches!(reader.next()?, Ended));
Ok(())
}
#[test]
fn test_no_trailing_newline() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("no_trailing.log");
let mut file = File::create(&file_path).unwrap();
write!(file, "line1\nline2").unwrap();
let mut reader = FileReader::new(file_path, true)?;
let mut all_lines = Vec::new();
while let Lines(batch) = reader.next()? {
all_lines.extend(texts(&batch));
}
assert_eq!(all_lines, vec!["line1", "line2"]);
Ok(())
}
#[test]
fn test_crlf_line_endings() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("crlf.log");
{
let mut file = File::create(&file_path)?;
file.write_all(b"line1\r\nline2\r\n")?;
}
let events = events_of(FileReader::new(file_path.as_path(), false)?);
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["line1", "line2"]),
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
assert!(matches!(next_event(&events), InitialReadComplete));
{
let mut file = OpenOptions::new().append(true).open(&file_path)?;
file.write_all(b"appended\r\n")?;
}
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["appended"]),
other => panic!("Expected StreamEvent::Lines(\"appended\"), got {other:?}"),
}
Ok(())
}
#[test]
fn test_non_utf8_content() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("non_utf8.log");
{
let mut file = File::create(&file_path).unwrap();
file.write_all(b"hello \xff\xfe world\n").unwrap();
}
let mut reader = FileReader::new(file_path, true)?;
match reader.next()? {
Lines(batch) => {
let lines = texts(&batch);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("hello"));
assert!(lines[0].contains("world"));
assert!(lines[0].contains('\u{FFFD}'));
}
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
Ok(())
}
#[test]
fn test_non_utf8_in_follow_mode() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("non_utf8_follow.log");
{
let mut file = File::create(&file_path)?;
writeln!(file, "initial")?;
}
let events = events_of(FileReader::new(file_path.as_path(), false)?);
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["initial"]),
other => panic!("Expected StreamEvent::Lines(\"initial\"), got {other:?}"),
}
assert!(matches!(next_event(&events), InitialReadComplete));
{
let mut file = OpenOptions::new().append(true).open(&file_path)?;
file.write_all(b"caf\xe9\n")?;
}
match next_event(&events) {
Lines(batch) => {
let lines = texts(&batch);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("caf"));
assert!(lines[0].contains('\u{FFFD}'));
}
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
Ok(())
}
#[test]
fn test_truncation_detection() -> Result<()> {
let dir = tempdir()?;
let file_path = dir.path().join("truncate.log");
let mut file = File::create(&file_path)?;
writeln!(file, "original1")?;
writeln!(file, "original2")?;
let events = events_of(FileReader::new(file_path.as_path(), false)?);
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["original1", "original2"]),
other => panic!("Expected StreamEvent::Lines(...), got {other:?}"),
}
assert!(matches!(next_event(&events), InitialReadComplete));
let mut file = File::create(&file_path)?;
writeln!(file, "new")?;
match next_event(&events) {
Lines(batch) => assert_eq!(texts(&batch), vec!["new"]),
other => panic!("Expected StreamEvent::Lines(\"new\"), got {other:?}"),
}
Ok(())
}
#[test]
fn test_large_file_streams_in_batches() -> Result<()> {
let dir = tempdir().unwrap();
let file_path = dir.path().join("large.log");
let line_count = BUF_READER_CAPACITY * 2 / 68;
{
let mut file = File::create(&file_path).unwrap();
for i in 0..line_count {
writeln!(
file,
"line {i:05} - padding to make this line reasonably long for testing"
)
.unwrap();
}
}
let mut reader = FileReader::new(file_path, true)?;
let mut event_count = 0;
let mut total_lines = 0;
loop {
match reader.next()? {
Lines(batch) => {
event_count += 1;
total_lines += batch.lines.len();
}
InitialReadComplete => break,
Ended => panic!("Unexpected Ended before InitialReadComplete"),
}
}
assert_eq!(total_lines, line_count);
assert!(
event_count > 1,
"Large file should produce multiple events, got {event_count}"
);
Ok(())
}
}