use std::fmt::Write as _;
use std::io::{BufRead as _, BufReader, Seek, SeekFrom};
use super::{Agent, error::AgentError};
use crate::channel::Channel;
use crate::config::{LogRotation, LoggingConfig};
use crate::redact::scrub_content;
impl<C: Channel> Agent<C> {
pub async fn handle_log_command(&mut self) -> Result<(), AgentError> {
let logging = self.debug_state.logging_config.clone();
let mut out = String::new();
format_logging_status(&logging, &mut out);
if !logging.file.is_empty() {
let base_path = std::path::PathBuf::from(&logging.file);
let tail = tokio::task::spawn_blocking(move || {
let actual = resolve_current_log_file(&base_path);
actual.and_then(|p| read_log_tail(&p, 20))
})
.await
.unwrap_or(None);
if let Some(lines) = tail {
let _ = writeln!(out);
let _ = writeln!(out, "Recent entries:");
out.push_str(&scrub_content(&lines));
}
}
self.channel.send(out.trim_end()).await?;
Ok(())
}
}
pub(crate) fn format_logging_status(logging: &LoggingConfig, out: &mut String) {
let _ = writeln!(
out,
"Log file: {}",
if logging.file.is_empty() {
"<disabled>"
} else {
&logging.file
}
);
let _ = writeln!(out, "Level: {}", logging.level);
let rotation_str = match logging.rotation {
LogRotation::Daily => "daily",
LogRotation::Hourly => "hourly",
LogRotation::Never => "never",
};
let _ = writeln!(out, "Rotation: {rotation_str}");
let _ = writeln!(out, "Max files: {}", logging.max_files);
}
pub(crate) fn resolve_current_log_file(base: &std::path::Path) -> Option<std::path::PathBuf> {
if base.exists() {
return Some(base.to_path_buf());
}
let dir = base.parent()?;
let stem = base.file_stem()?.to_string_lossy();
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(stem.as_ref()) {
continue;
}
if let Ok(meta) = entry.metadata()
&& let Ok(modified) = meta.modified()
&& best.as_ref().is_none_or(|(t, _)| modified > *t)
{
best = Some((modified, entry.path()));
}
}
best.map(|(_, p)| p)
}
pub(crate) const MAX_LINE_CHARS: usize = 512;
pub(crate) const MAX_TAIL_BYTES: usize = 4 * 1024;
pub(crate) fn read_log_tail(path: &std::path::Path, n: usize) -> Option<String> {
let file = std::fs::File::open(path).ok()?;
let mut reader = BufReader::new(file);
let size = reader.seek(SeekFrom::End(0)).ok()?;
if size == 0 {
return None;
}
let chunk = size.min(64 * 1024);
reader.seek(SeekFrom::End(-chunk.cast_signed())).ok()?;
let mut lines: Vec<String> = reader
.lines()
.map_while(Result::ok)
.map(|l| {
if l.chars().count() > MAX_LINE_CHARS {
let mut s: String = l.chars().take(MAX_LINE_CHARS).collect();
s.push('…');
s
} else {
l
}
})
.collect();
lines.reverse();
lines.truncate(n);
lines.reverse();
let mut out = String::new();
for line in &lines {
if out.len() + line.len() + 1 > MAX_TAIL_BYTES {
break;
}
out.push_str(line);
out.push('\n');
}
if out.is_empty() { None } else { Some(out) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_logging_status_disabled() {
let logging = LoggingConfig {
file: String::new(),
level: "info".into(),
rotation: LogRotation::Daily,
max_files: 7,
};
let mut out = String::new();
format_logging_status(&logging, &mut out);
assert!(
out.contains("<disabled>"),
"expected <disabled>, got: {out}"
);
assert!(out.contains("info"));
assert!(out.contains("daily"));
assert!(out.contains('7'));
}
#[test]
fn format_logging_status_enabled() {
let logging = LoggingConfig {
file: "/var/log/zeph.log".into(),
level: "debug".into(),
rotation: LogRotation::Hourly,
max_files: 3,
};
let mut out = String::new();
format_logging_status(&logging, &mut out);
assert!(out.contains("/var/log/zeph.log"), "path missing: {out}");
assert!(out.contains("debug"));
assert!(out.contains("hourly"));
assert!(out.contains('3'));
}
#[test]
fn read_log_tail_missing_file_returns_none() {
let result = read_log_tail(std::path::Path::new("/nonexistent/path/zeph.log"), 20);
assert!(result.is_none());
}
#[test]
fn read_log_tail_empty_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.log");
std::fs::write(&path, b"").unwrap();
let result = read_log_tail(&path, 20);
assert!(result.is_none());
}
#[test]
fn read_log_tail_returns_last_n_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("zeph.log");
let content = (1u32..=30)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
std::fs::write(&path, content).unwrap();
let result = read_log_tail(&path, 5).unwrap();
let lines: Vec<&str> = result.trim_end().split('\n').collect();
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], "line 26");
assert_eq!(lines[4], "line 30");
}
#[test]
fn read_log_tail_long_line_truncated() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("zeph.log");
let long_line = "x".repeat(MAX_LINE_CHARS + 100);
std::fs::write(&path, format!("{long_line}\n")).unwrap();
let result = read_log_tail(&path, 5).unwrap();
let line = result.trim_end();
assert!(line.chars().count() <= MAX_LINE_CHARS + 1);
assert!(line.ends_with('…'));
}
#[test]
fn resolve_current_log_file_base_path_exists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("zeph.log");
std::fs::write(&path, b"hello\n").unwrap();
let result = resolve_current_log_file(&path);
assert_eq!(result.as_deref(), Some(path.as_path()));
}
#[test]
fn resolve_current_log_file_date_suffixed_file_found() {
let dir = tempfile::tempdir().unwrap();
let rotated = dir.path().join("zeph.2026-03-09.log");
std::fs::write(&rotated, b"rotated\n").unwrap();
let base = dir.path().join("zeph.log");
let result = resolve_current_log_file(&base);
assert_eq!(result.as_deref(), Some(rotated.as_path()));
}
#[test]
fn resolve_current_log_file_no_matching_files_returns_none() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("other.log"), b"x\n").unwrap();
let base = dir.path().join("zeph.log");
let result = resolve_current_log_file(&base);
assert!(result.is_none());
}
}