#![allow(dead_code)]
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
const LOG_DIRNAME: &str = "logs";
const AGENT_LOG_PREFIX: &str = "agent.log";
const ERROR_LOG_PREFIX: &str = "errors.log";
pub fn init_with_files() -> Result<WorkerGuard> {
let log_dir = log_dir();
std::fs::create_dir_all(&log_dir)
.with_context(|| format!("create log dir {}", log_dir.display()))?;
let agent_appender = rolling::daily(&log_dir, AGENT_LOG_PREFIX);
let (agent_nb, guard) = tracing_appender::non_blocking(agent_appender);
let error_appender = rolling::daily(&log_dir, ERROR_LOG_PREFIX);
let stderr_filter =
EnvFilter::try_from_env("MERLION_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let stderr_layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.compact()
.with_filter(stderr_filter);
let agent_file_layer = tracing_subscriber::fmt::layer()
.with_writer(agent_nb)
.with_ansi(false)
.with_filter(LevelFilter::INFO);
let error_file_layer = tracing_subscriber::fmt::layer()
.with_writer(make_writer(error_appender))
.with_ansi(false)
.with_filter(LevelFilter::WARN);
tracing_subscriber::registry()
.with(stderr_layer)
.with(agent_file_layer)
.with(error_file_layer)
.try_init()
.map_err(|e| anyhow::anyhow!("tracing init: {e}"))?;
Ok(guard)
}
fn make_writer(appender: rolling::RollingFileAppender) -> RollingMakeWriter {
RollingMakeWriter(appender)
}
struct RollingMakeWriter(rolling::RollingFileAppender);
impl<'a> MakeWriter<'a> for RollingMakeWriter {
type Writer = <rolling::RollingFileAppender as MakeWriter<'a>>::Writer;
fn make_writer(&'a self) -> Self::Writer {
self.0.make_writer()
}
}
#[derive(Debug, Clone)]
pub struct LogsArgs {
pub follow: bool,
pub errors: bool,
pub since: Option<String>,
pub lines: usize,
}
impl Default for LogsArgs {
fn default() -> Self {
Self {
follow: false,
errors: false,
since: None,
lines: 50,
}
}
}
pub async fn run(args: LogsArgs) -> Result<()> {
let path = current_log_file(args.errors);
if !path.exists() {
eprintln!(
"(no log file at {} yet — run merlion to generate one)",
path.display()
);
return Ok(());
}
let since_cutoff = match args.since.as_deref() {
Some(s) => Some(parse_since(s)?),
None => None,
};
let text =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let filtered: Vec<&str> = match since_cutoff {
Some(cutoff) => text
.lines()
.filter(|line| line_after(line, cutoff).unwrap_or(true))
.collect(),
None => text.lines().collect(),
};
let start = filtered.len().saturating_sub(args.lines);
for line in &filtered[start..] {
println!("{line}");
}
if !args.follow {
return Ok(());
}
if cfg!(windows) {
eprintln!("(--follow is unix-only; printed snapshot above)");
return Ok(());
}
let status = tokio::process::Command::new("tail")
.arg("-F")
.arg(&path)
.status()
.await
.with_context(|| "spawn `tail -F` (is `tail` installed?)")?;
if !status.success() {
anyhow::bail!("tail -F exited with status {status}");
}
Ok(())
}
fn log_dir() -> PathBuf {
merlion_config::merlion_home().join(LOG_DIRNAME)
}
fn current_log_file(errors: bool) -> PathBuf {
let prefix = if errors {
ERROR_LOG_PREFIX
} else {
AGENT_LOG_PREFIX
};
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
log_dir().join(format!("{prefix}.{today}"))
}
fn parse_since(s: &str) -> Result<chrono::DateTime<chrono::Utc>> {
let dur = humantime::parse_duration(s)
.with_context(|| format!("invalid --since duration `{s}` (try `1h`, `10m`, `30s`)"))?;
let cd = chrono::Duration::from_std(dur)
.map_err(|e| anyhow::anyhow!("--since duration out of range: {e}"))?;
Ok(chrono::Utc::now() - cd)
}
fn line_after(line: &str, cutoff: chrono::DateTime<chrono::Utc>) -> Option<bool> {
let ts_token = line.split_whitespace().next()?;
let parsed = chrono::DateTime::parse_from_rfc3339(ts_token).ok()?;
Some(parsed.with_timezone(&chrono::Utc) >= cutoff)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logs_args_default_is_50_lines_agent() {
let a = LogsArgs::default();
assert_eq!(a.lines, 50);
assert!(!a.follow);
assert!(!a.errors);
assert!(a.since.is_none());
}
#[test]
fn current_log_file_picks_agent_or_errors() {
let agent = current_log_file(false);
let errors = current_log_file(true);
assert!(
agent
.file_name()
.and_then(|s| s.to_str())
.unwrap()
.starts_with("agent.log."),
"agent path = {}",
agent.display()
);
assert!(
errors
.file_name()
.and_then(|s| s.to_str())
.unwrap()
.starts_with("errors.log."),
"errors path = {}",
errors.display()
);
assert_eq!(agent.parent(), errors.parent());
}
#[test]
fn log_dir_honors_merlion_home_env() {
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var("MERLION_HOME").ok();
unsafe {
std::env::set_var("MERLION_HOME", tmp.path());
}
let d = log_dir();
assert_eq!(d, tmp.path().join(LOG_DIRNAME));
unsafe {
match prev {
Some(v) => std::env::set_var("MERLION_HOME", v),
None => std::env::remove_var("MERLION_HOME"),
}
}
}
#[test]
fn parse_since_accepts_humantime() {
let cutoff = parse_since("1h").unwrap();
let now = chrono::Utc::now();
let diff = now - cutoff;
assert!(
diff.num_seconds() >= 3599 && diff.num_seconds() <= 3601,
"expected ~1h ago, got {} seconds",
diff.num_seconds()
);
}
#[test]
fn parse_since_rejects_garbage() {
assert!(parse_since("not-a-duration").is_err());
}
#[test]
fn line_after_respects_cutoff() {
let cutoff = chrono::DateTime::parse_from_rfc3339("2026-05-19T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let before = "2026-05-18T23:59:59Z INFO before";
let after = "2026-05-19T00:00:01Z INFO after";
let untimestamped = " ... continuation line";
assert_eq!(line_after(before, cutoff), Some(false));
assert_eq!(line_after(after, cutoff), Some(true));
assert_eq!(line_after(untimestamped, cutoff), None);
}
}