use std::io;
pub(crate) const TAIL_WHOLE_FILE_THRESHOLD: u64 = 4 * 1024 * 1024;
pub(crate) const TAIL_AVG_LINE_BYTES: u64 = 2 * 1024;
pub(crate) const TAIL_MAX_WINDOW_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) async fn read_tail_lines(
path: &std::path::Path,
requested: usize,
) -> io::Result<Vec<String>> {
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
if requested == 0 {
return Ok(vec![]);
}
let mut file = tokio::fs::File::open(path).await?;
let len = file.metadata().await?.len();
if len <= TAIL_WHOLE_FILE_THRESHOLD {
let mut body = String::new();
file.read_to_string(&mut body).await?;
return Ok(tail_of(&body, requested));
}
let mut window = (requested as u64)
.saturating_mul(TAIL_AVG_LINE_BYTES)
.clamp(TAIL_AVG_LINE_BYTES, TAIL_MAX_WINDOW_BYTES);
loop {
let start = len.saturating_sub(window);
file.seek(SeekFrom::Start(start)).await?;
let mut buf = Vec::with_capacity(window.min(len) as usize);
(&mut file).take(window).read_to_end(&mut buf).await?;
let usable: &[u8] = if start > 0 {
match buf.iter().position(|&b| b == b'\n') {
Some(nl) => &buf[nl + 1..],
None => &[],
}
} else {
&buf
};
let text = String::from_utf8_lossy(usable);
let lines = tail_of(&text, requested);
if lines.len() >= requested || start == 0 || window >= TAIL_MAX_WINDOW_BYTES {
return Ok(lines);
}
window = window.saturating_mul(2).min(TAIL_MAX_WINDOW_BYTES);
}
}
fn tail_of(body: &str, n: usize) -> Vec<String> {
let mut lines: Vec<String> = body.lines().rev().take(n).map(str::to_string).collect();
lines.reverse();
lines
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[tokio::test]
async fn small_file_returns_exact_tail() {
let f = NamedTempFile::new().unwrap();
std::fs::write(f.path(), "a\nb\nc\nd\n").unwrap();
let lines = read_tail_lines(f.path(), 2).await.unwrap();
assert_eq!(lines, vec!["c", "d"]);
}
#[tokio::test]
async fn zero_requested_short_circuits() {
let f = NamedTempFile::new().unwrap();
std::fs::write(f.path(), "a\nb\n").unwrap();
let lines = read_tail_lines(f.path(), 0).await.unwrap();
assert!(lines.is_empty());
}
#[tokio::test]
async fn missing_file_propagates_not_found() {
let e = read_tail_lines(std::path::Path::new("definitely-missing.log"), 5)
.await
.unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
}
#[tokio::test]
async fn large_file_seek_path_returns_exact_tail() {
let f = NamedTempFile::new().unwrap();
let pad = "x".repeat(64);
let count = 100_000usize;
let body = (1..=count)
.map(|i| format!("line-{i:08}-{pad}"))
.collect::<Vec<_>>()
.join("\n");
assert!(body.len() as u64 > TAIL_WHOLE_FILE_THRESHOLD);
std::fs::write(f.path(), body).unwrap();
let lines = read_tail_lines(f.path(), 100).await.unwrap();
assert_eq!(lines.len(), 100);
assert_eq!(lines[0], format!("line-{:08}-{pad}", count - 99));
assert_eq!(lines.last().unwrap(), &format!("line-{count:08}-{pad}"));
}
}