use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::{Args, ValueEnum};
const FOLLOW_POLL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, ValueEnum)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
}
impl LogLevel {
fn as_str(&self) -> &'static str {
match self {
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
LogLevel::Debug => "DEBUG",
}
}
}
#[derive(Debug, Args)]
pub struct LogsArgs {
#[arg(long, short = 'f')]
pub follow: bool,
#[arg(long, default_value_t = 50)]
pub lines: u64,
#[arg(long)]
pub level: Option<LogLevel>,
#[arg(long)]
pub log_file: Option<PathBuf>,
}
pub fn dispatch(args: LogsArgs) -> ExitCode {
let log_path = resolve_log_path(&args);
let file = match std::fs::File::open(&log_path) {
Ok(f) => f,
Err(e) => {
eprintln!("error: cannot open {}: {e}", log_path.display());
return ExitCode::FAILURE;
}
};
let level_filter = args.level.as_ref().map(|l| l.as_str());
if args.follow {
follow_logs(file, level_filter)
} else {
tail_logs(file, args.lines, level_filter)
}
}
fn resolve_log_path(args: &LogsArgs) -> PathBuf {
if let Some(ref p) = args.log_file {
return p.clone();
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".aasm")
.join("logs")
.join("gateway.log")
}
fn tail_logs(file: std::fs::File, n: u64, level_filter: Option<&str>) -> ExitCode {
let reader = BufReader::new(file);
let mut window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if matches_level(&line, level_filter) {
if window.len() as u64 >= n {
window.pop_front();
}
window.push_back(line);
}
}
for line in &window {
println!("{line}");
}
ExitCode::SUCCESS
}
fn follow_logs(mut file: std::fs::File, level_filter: Option<&str>) -> ExitCode {
if file.seek(SeekFrom::End(0)).is_err() {
eprintln!("error: could not seek to end of log file");
return ExitCode::FAILURE;
}
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
rt.block_on(async {
let mut reader = BufReader::new(file);
let mut buf = String::new();
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break,
_ = tokio::time::sleep(FOLLOW_POLL) => {
loop {
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) => break, Ok(_) => {
let line = buf.trim_end_matches('\n').trim_end_matches('\r');
if matches_level(line, level_filter) {
println!("{line}");
let _ = std::io::stdout().flush();
}
}
Err(_) => break,
}
}
}
}
}
});
ExitCode::SUCCESS
}
pub fn matches_level(line: &str, level_filter: Option<&str>) -> bool {
let Some(filter) = level_filter else {
return true;
};
let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
return true; };
let Some(level_field) = val.get("level").and_then(|v| v.as_str()) else {
return true; };
level_field.eq_ignore_ascii_case(filter)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_level_no_filter_always_true() {
assert!(matches_level(r#"{"level":"DEBUG","message":"x"}"#, None));
assert!(matches_level("plain text line", None));
}
#[test]
fn matches_level_filters_by_json_level_field() {
let info = r#"{"level":"INFO","fields":{"message":"started"}}"#;
let debug = r#"{"level":"DEBUG","fields":{"message":"tick"}}"#;
assert!(matches_level(info, Some("INFO")));
assert!(!matches_level(debug, Some("INFO")));
}
#[test]
fn matches_level_case_insensitive() {
let warn = r#"{"level":"WARN","message":"high memory"}"#;
assert!(matches_level(warn, Some("warn")));
assert!(matches_level(warn, Some("WARN")));
}
#[test]
fn matches_level_passes_non_json_lines_through() {
assert!(matches_level("not json at all", Some("ERROR")));
}
#[test]
fn matches_level_passes_json_without_level_field() {
assert!(matches_level(r#"{"message":"no level here"}"#, Some("INFO")));
}
#[test]
fn log_level_as_str_values() {
assert_eq!(LogLevel::Error.as_str(), "ERROR");
assert_eq!(LogLevel::Warn.as_str(), "WARN");
assert_eq!(LogLevel::Info.as_str(), "INFO");
assert_eq!(LogLevel::Debug.as_str(), "DEBUG");
}
#[test]
fn tail_logs_returns_last_n_lines() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
for i in 0..10u32 {
writeln!(tmp, "{{\"level\":\"INFO\",\"msg\":{i}}}").unwrap();
}
let file = std::fs::File::open(tmp.path()).unwrap();
assert_eq!(tail_logs(file, 5, None), ExitCode::SUCCESS);
}
}