use std::collections::VecDeque;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, Result};
use clap::Parser;
use crate::daemon::{paths, server};
const FOLLOW_POLL: Duration = Duration::from_millis(250);
const DEFAULT_LINES: usize = 200;
#[derive(Parser)]
pub struct LogsCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'n', long, default_value_t = DEFAULT_LINES)]
pub lines: usize,
#[arg(short = 'f', long)]
pub follow: bool,
}
impl LogsCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let path = paths::log_path_for_socket(&socket);
let limit = (self.lines != 0).then_some(self.lines);
let follow = self.follow;
tokio::task::spawn_blocking(move || {
let stdout = io::stdout();
let mut out = stdout.lock();
tail(&path, limit, follow, &mut out)
})
.await
.map_err(|e| anyhow!("daemon logs reader panicked: {e}"))?
}
}
fn tail<W: Write>(path: &Path, limit: Option<usize>, follow: bool, out: &mut W) -> Result<()> {
let mut pos = 0u64;
match File::open(path) {
Ok(file) => {
let mut reader = BufReader::new(file);
pos = print_backlog(&mut reader, limit, out)?;
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
if !follow {
eprintln!(
"No daemon log yet at {} (start the daemon to create it).",
path.display()
);
return Ok(());
}
eprintln!("Waiting for daemon log at {}…", path.display());
}
Err(e) => return Err(e.into()),
}
if follow {
if let Err(e) = follow_loop(path, pos, out) {
return swallow_broken_pipe(e);
}
}
Ok(())
}
fn print_backlog<R: BufRead, W: Write>(
reader: &mut R,
limit: Option<usize>,
out: &mut W,
) -> Result<u64> {
let mut pos = 0u64;
let mut ring: VecDeque<String> = VecDeque::new();
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break;
}
pos += n as u64;
match limit {
Some(cap) => {
ring.push_back(line.clone());
while ring.len() > cap {
ring.pop_front();
}
}
None => write!(out, "{line}").or_else(ignore_broken_pipe)?,
}
}
for buffered in &ring {
write!(out, "{buffered}").or_else(ignore_broken_pipe)?;
}
Ok(pos)
}
fn follow_loop<W: Write>(path: &Path, mut pos: u64, out: &mut W) -> Result<()> {
loop {
pos = drain_appended(path, pos, out)?;
std::thread::sleep(FOLLOW_POLL);
}
}
fn drain_appended<W: Write>(path: &Path, mut pos: u64, out: &mut W) -> Result<u64> {
let Ok(file) = File::open(path) else {
return Ok(pos);
};
let len = file.metadata().map_or(pos, |m| m.len());
if len < pos {
pos = 0; }
if len > pos {
let mut reader = BufReader::new(file);
reader.seek(SeekFrom::Start(pos))?;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 || !line.ends_with('\n') {
break; }
pos += n as u64;
write!(out, "{line}")?;
out.flush()?;
}
}
Ok(pos)
}
fn ignore_broken_pipe(e: io::Error) -> io::Result<()> {
if e.kind() == io::ErrorKind::BrokenPipe {
Ok(())
} else {
Err(e)
}
}
fn swallow_broken_pipe(e: anyhow::Error) -> Result<()> {
if let Some(io_err) = e.downcast_ref::<io::Error>() {
if io_err.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
}
Err(e)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fmt::Write as _;
use std::io::Cursor;
fn sample() -> String {
(0..5).fold(String::new(), |mut acc, i| {
let _ = writeln!(acc, "line {i}");
acc
})
}
#[test]
fn backlog_emits_all_without_limit() {
let mut reader = BufReader::new(Cursor::new(sample()));
let mut out = Vec::new();
let pos = print_backlog(&mut reader, None, &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(text.lines().count(), 5);
assert!(text.starts_with("line 0\n"));
assert!(text.ends_with("line 4\n"));
assert_eq!(pos, sample().len() as u64);
}
#[test]
fn backlog_limit_keeps_the_most_recent_lines() {
let mut reader = BufReader::new(Cursor::new(sample()));
let mut out = Vec::new();
print_backlog(&mut reader, Some(2), &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(text, "line 3\nline 4\n");
}
#[test]
fn drain_appended_reads_only_new_complete_lines_and_handles_rotation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.log");
std::fs::write(&path, sample()).unwrap();
let mut out = Vec::new();
let pos = drain_appended(&path, 0, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap().lines().count(), 5);
assert!(pos > 0);
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(f, "line 5").unwrap();
}
let mut out = Vec::new();
let pos2 = drain_appended(&path, pos, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "line 5\n");
assert!(pos2 > pos);
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
write!(f, "partial").unwrap();
}
let mut out = Vec::new();
let pos3 = drain_appended(&path, pos2, &mut out).unwrap();
assert!(String::from_utf8(out).unwrap().is_empty());
assert_eq!(pos3, pos2, "a partial line must not advance the position");
std::fs::write(&path, "fresh\n").unwrap();
let mut out = Vec::new();
drain_appended(&path, pos2, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "fresh\n");
let missing = dir.path().join("gone.log");
let mut out = Vec::new();
assert_eq!(drain_appended(&missing, 7, &mut out).unwrap(), 7);
assert!(String::from_utf8(out).unwrap().is_empty());
}
#[test]
fn broken_pipe_is_swallowed_other_errors_propagate() {
use std::io::{Error, ErrorKind};
assert!(ignore_broken_pipe(Error::from(ErrorKind::BrokenPipe)).is_ok());
assert!(ignore_broken_pipe(Error::from(ErrorKind::PermissionDenied)).is_err());
assert!(swallow_broken_pipe(anyhow!(Error::from(ErrorKind::BrokenPipe))).is_ok());
assert!(swallow_broken_pipe(anyhow!("unrelated")).is_err());
}
#[tokio::test]
async fn execute_reads_the_log_beside_the_socket() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("daemon.sock");
std::fs::write(dir.path().join("daemon.log"), "hello\n").unwrap();
let cmd = LogsCommand {
socket: Some(sock),
lines: 200,
follow: false,
};
cmd.execute().await.unwrap();
}
#[test]
fn tail_on_missing_file_without_follow_is_ok() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("daemon.log");
let mut out = Vec::new();
assert!(tail(&missing, Some(10), false, &mut out).is_ok());
assert!(out.is_empty());
}
#[test]
fn tail_prints_the_backlog_window_of_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.log");
std::fs::write(&path, sample()).unwrap();
let mut out = Vec::new();
tail(&path, None, false, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), sample());
let mut out = Vec::new();
tail(&path, Some(2), false, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "line 3\nline 4\n");
}
}