use chrono::{DateTime, Utc};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub(crate) fn from_str_loose(s: &str) -> Self {
if s.eq_ignore_ascii_case("ERROR")
|| s.eq_ignore_ascii_case("ERR")
|| s.eq_ignore_ascii_case("FATAL")
|| s.eq_ignore_ascii_case("CRITICAL")
{
Self::Error
} else if s.eq_ignore_ascii_case("WARN") || s.eq_ignore_ascii_case("WARNING") {
Self::Warn
} else if s.eq_ignore_ascii_case("INFO") {
Self::Info
} else if s.eq_ignore_ascii_case("DEBUG") || s.eq_ignore_ascii_case("DBG") {
Self::Debug
} else if s.eq_ignore_ascii_case("TRACE") {
Self::Trace
} else {
Self::Info
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Trace => "TRACE",
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
}
impl std::fmt::Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub(crate) struct LogEntry {
pub timestamp: Option<DateTime<Utc>>,
pub level: LogLevel,
pub service: String,
pub message: String,
pub raw: String,
}
#[derive(Debug, Deserialize)]
struct JsonLog {
#[serde(alias = "lvl", alias = "severity")]
level: Option<String>,
#[serde(alias = "msg", alias = "message")]
#[serde(default)]
msg: String,
#[serde(alias = "time", alias = "ts", alias = "@timestamp")]
timestamp: Option<String>,
#[serde(alias = "service", alias = "component")]
service: Option<String>,
}
pub(crate) fn parse_line(raw: &str, default_service: &str) -> LogEntry {
let trimmed = raw.trim();
if let Some((svc, rest)) = try_docker_prefix(trimmed) {
if let Some(mut entry) = try_json(rest) {
if entry.service.is_empty() {
entry.service = svc;
}
entry.raw = raw.to_string();
return entry;
}
if let Some(mut entry) = try_rust_log(rest) {
if entry.service.is_empty() {
entry.service = svc;
}
entry.raw = raw.to_string();
return entry;
}
return LogEntry {
timestamp: None,
level: guess_level(rest),
service: svc,
message: rest.to_string(),
raw: raw.to_string(),
};
}
if let Some(entry) = try_json(trimmed) {
return LogEntry {
raw: raw.to_string(),
..entry
};
}
if let Some(entry) = try_rust_log(trimmed) {
return LogEntry {
raw: raw.to_string(),
..entry
};
}
LogEntry {
timestamp: None,
level: guess_level(trimmed),
service: default_service.to_string(),
message: trimmed.to_string(),
raw: raw.to_string(),
}
}
fn try_docker_prefix(line: &str) -> Option<(String, &str)> {
let pipe_pos = line.find(" | ")?;
let svc = line[..pipe_pos].trim();
if svc.is_empty() || svc.contains(' ') {
return None;
}
let rest = &line[pipe_pos + 3..];
let svc = svc.strip_prefix("resq-").unwrap_or(svc);
Some((svc.to_string(), rest))
}
fn try_json(line: &str) -> Option<LogEntry> {
if !line.starts_with('{') {
return None;
}
let parsed: JsonLog = serde_json::from_str(line).ok()?;
let level = parsed
.level
.as_deref()
.map_or(LogLevel::Info, LogLevel::from_str_loose);
let timestamp = parsed
.timestamp
.as_deref()
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.map(|dt| dt.with_timezone(&Utc));
Some(LogEntry {
timestamp,
level,
service: parsed.service.unwrap_or_default(),
message: parsed.msg,
raw: line.to_string(),
})
}
fn try_rust_log(line: &str) -> Option<LogEntry> {
if line.len() < 25 || !line.as_bytes()[4].is_ascii_punctuation() {
return None;
}
let ts_end = line.find(' ')?;
let ts_str = &line[..ts_end];
let ts = DateTime::parse_from_rfc3339(ts_str)
.ok()
.map(|dt| dt.with_timezone(&Utc))?;
let rest = line[ts_end + 1..].trim_start();
let level_end = rest.find(' ').unwrap_or(rest.len());
let level = LogLevel::from_str_loose(&rest[..level_end]);
let after_level = rest[level_end..].trim_start();
let (service, message) = if let Some(colon_pos) = after_level.find(": ") {
let module = &after_level[..colon_pos];
let msg = &after_level[colon_pos + 2..];
(module.to_string(), msg.to_string())
} else {
(String::new(), after_level.to_string())
};
Some(LogEntry {
timestamp: Some(ts),
level,
service,
message,
raw: line.to_string(),
})
}
fn guess_level(line: &str) -> LogLevel {
let upper = line.to_ascii_uppercase();
if upper.contains("ERROR") || upper.contains("FATAL") || upper.contains("PANIC") {
LogLevel::Error
} else if upper.contains("WARN") {
LogLevel::Warn
} else if upper.contains("DEBUG") {
LogLevel::Debug
} else if upper.contains("TRACE") {
LogLevel::Trace
} else {
LogLevel::Info
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docker_prefix() {
let line = "resq-coordination-hce | Server started on port 3001";
let entry = parse_line(line, "unknown");
assert_eq!(entry.service, "coordination-hce");
assert_eq!(entry.message, "Server started on port 3001");
}
#[test]
fn test_json_log() {
let line =
r#"{"level":"error","msg":"connection refused","timestamp":"2026-02-09T12:00:00Z"}"#;
let entry = parse_line(line, "test");
assert_eq!(entry.level, LogLevel::Error);
assert_eq!(entry.message, "connection refused");
}
#[test]
fn test_plain_fallback() {
let line = "Something happened with an ERROR here";
let entry = parse_line(line, "default-svc");
assert_eq!(entry.service, "default-svc");
assert_eq!(entry.level, LogLevel::Error);
}
}