use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use crate::error::{LogdiveError, Result};
const READ_BUFFER_SIZE: usize = 8 * 1024;
#[derive(Debug)]
pub struct FileTailer {
path: PathBuf,
file: File,
offset: u64,
inode: u64,
dev: u64,
leftover: Vec<u8>,
}
impl FileTailer {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let mut file = File::open(&path).map_err(|e| LogdiveError::io_at(&path, e))?;
let meta = file.metadata().map_err(|e| LogdiveError::io_at(&path, e))?;
let inode = meta.ino();
let dev = meta.dev();
let offset = meta.len();
file.seek(SeekFrom::Start(offset))
.map_err(|e| LogdiveError::io_at(&path, e))?;
Ok(Self {
path,
file,
offset,
inode,
dev,
leftover: Vec::new(),
})
}
pub fn read_new_lines(&mut self) -> Result<Vec<String>> {
match std::fs::metadata(&self.path) {
Ok(meta) => {
let current_ino = meta.ino();
let current_dev = meta.dev();
let current_size = meta.len();
if current_ino != self.inode || current_dev != self.dev {
self.handle_rotation()?;
} else if current_size < self.offset {
self.offset = 0;
self.leftover.clear();
self.file
.seek(SeekFrom::Start(0))
.map_err(|e| LogdiveError::io_at(&self.path, e))?;
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(vec![]);
}
Err(e) => return Err(LogdiveError::io_at(&self.path, e)),
}
let mut buf = [0u8; READ_BUFFER_SIZE];
let mut raw_bytes: Vec<u8> = Vec::new();
loop {
match self.file.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
raw_bytes.extend_from_slice(&buf[..n]);
self.offset += n as u64;
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) => return Err(LogdiveError::io_at(&self.path, e)),
}
}
if raw_bytes.is_empty() && self.leftover.is_empty() {
return Ok(vec![]);
}
let mut combined = std::mem::take(&mut self.leftover);
combined.extend_from_slice(&raw_bytes);
let mut lines: Vec<String> = Vec::new();
let mut start = 0usize;
while start < combined.len() {
match combined[start..].iter().position(|&b| b == b'\n') {
Some(rel) => {
let end = start + rel;
let line_bytes = if end > start && combined[end - 1] == b'\r' {
&combined[start..end - 1]
} else {
&combined[start..end]
};
let line = String::from_utf8_lossy(line_bytes).into_owned();
lines.push(line);
start = end + 1; }
None => {
self.leftover = combined[start..].to_vec();
return Ok(lines);
}
}
}
Ok(lines)
}
fn handle_rotation(&mut self) -> Result<()> {
match File::open(&self.path) {
Ok(new_file) => {
let meta = new_file
.metadata()
.map_err(|e| LogdiveError::io_at(&self.path, e))?;
self.file = new_file;
self.offset = 0;
self.inode = meta.ino();
self.dev = meta.dev();
self.leftover.clear();
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Ok(())
}
Err(e) => Err(LogdiveError::io_at(&self.path, e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
fn append(f: &mut NamedTempFile, data: &[u8]) {
f.write_all(data).expect("write");
f.flush().expect("flush");
}
fn append_file(f: &mut File, data: &[u8]) {
f.write_all(data).expect("write");
f.flush().expect("flush");
}
#[test]
fn open_at_eof_returns_no_initial_lines() {
let mut f = NamedTempFile::new().unwrap();
append(&mut f, b"existing line\n");
let mut tailer = FileTailer::open(f.path()).unwrap();
let lines = tailer.read_new_lines().unwrap();
assert!(
lines.is_empty(),
"expected no lines on first read, got {lines:?}"
);
}
#[test]
fn single_append_returns_appended_lines() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"foo\n");
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["foo"]);
}
#[test]
fn multiple_appends_across_calls() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"alpha\n");
let first = tailer.read_new_lines().unwrap();
assert_eq!(first, vec!["alpha"]);
append(&mut f, b"beta\ngamma\n");
let second = tailer.read_new_lines().unwrap();
assert_eq!(second, vec!["beta", "gamma"]);
}
#[test]
fn read_after_no_new_data_returns_empty() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"line\n");
tailer.read_new_lines().unwrap(); let second = tailer.read_new_lines().unwrap();
assert!(second.is_empty(), "expected empty, got {second:?}");
}
#[test]
fn empty_file_returns_no_lines() {
let f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
let lines = tailer.read_new_lines().unwrap();
assert!(lines.is_empty());
}
#[test]
fn partial_line_buffered_until_newline() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"par");
let first = tailer.read_new_lines().unwrap();
assert!(
first.is_empty(),
"partial should be buffered, got {first:?}"
);
append(&mut f, b"tial\n");
let second = tailer.read_new_lines().unwrap();
assert_eq!(second, vec!["partial"]);
}
#[test]
fn multiple_lines_with_partial_at_end() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"a\nb\nc");
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["a", "b"], "got {lines:?}");
assert!(!tailer.leftover.is_empty(), "leftover should hold 'c'");
}
#[test]
fn very_long_line_buffered_correctly() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
let long_line: Vec<u8> = std::iter::repeat_n(b'x', 20 * 1024).collect();
let mut data = long_line.clone();
data.push(b'\n');
append(&mut f, &data);
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines.len(), 1, "expected one line, got {}", lines.len());
let expected: String = "x".repeat(20 * 1024);
assert_eq!(lines[0], expected);
}
#[test]
fn unicode_lines_preserved() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, "héllo wörld 日本語\n".as_bytes());
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["héllo wörld 日本語"]);
}
#[test]
fn crlf_line_endings_stripped() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"line1\r\nline2\r\n");
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["line1", "line2"]);
}
#[test]
fn blank_lines_are_returned() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"\n\n");
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["", ""], "got {lines:?}");
}
#[test]
fn truncation_resets_offset() {
let mut f = NamedTempFile::new().unwrap();
let mut tailer = FileTailer::open(f.path()).unwrap();
append(&mut f, b"old data\n");
let first = tailer.read_new_lines().unwrap();
assert_eq!(first, vec!["old data"]);
f.as_file().set_len(0).unwrap();
f.as_file().seek(SeekFrom::Start(0)).unwrap();
append(&mut f, b"fresh\n");
let second = tailer.read_new_lines().unwrap();
assert_eq!(second, vec!["fresh"], "got {second:?}");
}
#[test]
fn rotation_via_rename_reopens_file() {
let dir = TempDir::new().unwrap();
let watched = dir.path().join("app.log");
std::fs::write(&watched, b"initial\n").unwrap();
let mut tailer = FileTailer::open(&watched).unwrap();
let rotated = dir.path().join("app.log.1");
std::fs::rename(&watched, &rotated).unwrap();
let mut new_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&watched)
.unwrap();
append_file(&mut new_file, b"new\n");
let lines = tailer.read_new_lines().unwrap();
assert_eq!(lines, vec!["new"], "got {lines:?}");
}
#[test]
fn rotation_then_more_appends() {
let dir = TempDir::new().unwrap();
let watched = dir.path().join("app.log");
std::fs::write(&watched, b"before\n").unwrap();
let mut tailer = FileTailer::open(&watched).unwrap();
let rotated = dir.path().join("app.log.1");
std::fs::rename(&watched, &rotated).unwrap();
let mut new_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&watched)
.unwrap();
append_file(&mut new_file, b"first\n");
let batch1 = tailer.read_new_lines().unwrap();
assert_eq!(batch1, vec!["first"], "batch1: {batch1:?}");
append_file(&mut new_file, b"second\nthird\n");
let batch2 = tailer.read_new_lines().unwrap();
assert_eq!(batch2, vec!["second", "third"], "batch2: {batch2:?}");
}
#[test]
fn missing_file_errors_on_open() {
let result = FileTailer::open("/nonexistent/path/that/does/not/exist.log");
assert!(result.is_err(), "expected Err on missing file");
assert!(
matches!(result.unwrap_err(), LogdiveError::Io { .. }),
"expected LogdiveError::Io"
);
}
}