use crate::{LogEntry, LogLevel, SyslogFacility};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum OutputFormat {
#[default]
Text,
Json,
Syslog,
JsonLines,
}
pub trait LogFormatter {
fn format(&self, entry: &LogEntry) -> String;
}
#[derive(Debug, Clone)]
pub struct TextFormatter {
pub show_timestamp: bool,
pub show_participant: bool,
pub show_topic: bool,
pub use_colors: bool,
}
impl Default for TextFormatter {
fn default() -> Self {
Self {
show_timestamp: true,
show_participant: true,
show_topic: true,
use_colors: true,
}
}
}
impl TextFormatter {
#[cfg(test)]
fn no_colors() -> Self {
Self {
use_colors: false,
..Default::default()
}
}
fn level_color(&self, level: LogLevel) -> &'static str {
if !self.use_colors {
return "";
}
match level {
LogLevel::Unset => "\x1b[37m", LogLevel::Debug => "\x1b[36m", LogLevel::Info => "\x1b[32m", LogLevel::Warn => "\x1b[33m", LogLevel::Error => "\x1b[31m", LogLevel::Fatal => "\x1b[35;1m", }
}
fn reset(&self) -> &'static str {
if self.use_colors {
"\x1b[0m"
} else {
""
}
}
}
impl LogFormatter for TextFormatter {
fn format(&self, entry: &LogEntry) -> String {
let mut parts = Vec::new();
if self.show_timestamp {
parts.push(entry.timestamp.format("%Y-%m-%d %H:%M:%S%.3f").to_string());
}
let level_str = format!(
"{}[{:5}]{}",
self.level_color(entry.level),
entry.level.as_str(),
self.reset()
);
parts.push(level_str);
if self.show_participant {
parts.push(format!(
"[{}]",
&entry.participant_id[..8.min(entry.participant_id.len())]
));
}
if self.show_topic {
if let Some(ref topic) = entry.topic {
parts.push(format!("[{}]", topic));
}
}
if let Some(ref node) = entry.node_name {
parts.push(format!("[{}]", node));
}
parts.push(entry.message.clone());
parts.join(" ")
}
}
#[derive(Debug, Clone, Default)]
pub struct JsonFormatter {
pub pretty: bool,
}
impl JsonFormatter {
pub fn compact() -> Self {
Self { pretty: false }
}
}
#[derive(Debug, Serialize)]
struct JsonLogEntry<'a> {
#[serde(rename = "@timestamp")]
timestamp: String,
level: &'static str,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
topic: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
node: Option<&'a str>,
participant_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
file: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
function: Option<&'a str>,
source: &'static str,
}
impl LogFormatter for JsonFormatter {
fn format(&self, entry: &LogEntry) -> String {
let json_entry = JsonLogEntry {
timestamp: entry.timestamp.to_rfc3339(),
level: entry.level.as_str(),
message: &entry.message,
topic: entry.topic.as_deref(),
node: entry.node_name.as_deref(),
participant_id: &entry.participant_id,
file: entry.file.as_deref(),
line: entry.line,
function: entry.function.as_deref(),
source: "hdds-logger",
};
if self.pretty {
serde_json::to_string_pretty(&json_entry).unwrap_or_else(|_| entry.message.clone())
} else {
serde_json::to_string(&json_entry).unwrap_or_else(|_| entry.message.clone())
}
}
}
#[derive(Debug, Clone)]
pub struct SyslogFormatter {
pub facility: SyslogFacility,
pub app_name: String,
pub hostname: String,
}
impl Default for SyslogFormatter {
fn default() -> Self {
Self {
facility: SyslogFacility::Local0,
app_name: "hdds-logger".to_string(),
hostname: gethostname(),
}
}
}
impl SyslogFormatter {
#[cfg(test)]
fn with_facility(facility: SyslogFacility) -> Self {
Self {
facility,
..Default::default()
}
}
fn pri(&self, level: LogLevel) -> u8 {
self.facility.code() * 8 + level.syslog_severity()
}
}
impl LogFormatter for SyslogFormatter {
fn format(&self, entry: &LogEntry) -> String {
let pri = self.pri(entry.level);
let timestamp = entry.timestamp.format("%Y-%m-%dT%H:%M:%S%.6fZ");
let procid = std::process::id();
let msgid = entry.topic.as_deref().unwrap_or("-");
let sd = if let Some(ref node) = entry.node_name {
format!(
"[hdds node=\"{}\" participant=\"{}\"]",
node, entry.participant_id
)
} else {
format!("[hdds participant=\"{}\"]", entry.participant_id)
};
format!(
"<{}>1 {} {} {} {} {} {} {}",
pri, timestamp, self.hostname, self.app_name, procid, msgid, sd, entry.message
)
}
}
fn gethostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_else(|_| "localhost".to_string())
}
pub fn create_formatter(format: OutputFormat) -> Box<dyn LogFormatter + Send + Sync> {
match format {
OutputFormat::Text => Box::new(TextFormatter::default()),
OutputFormat::Json | OutputFormat::JsonLines => Box::new(JsonFormatter::compact()),
OutputFormat::Syslog => Box::new(SyslogFormatter::default()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
fn sample_entry() -> LogEntry {
LogEntry {
timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
level: LogLevel::Info,
message: "Test message".to_string(),
participant_id: "01020304-0506-0708-090a-0b0c0d0e0f10".to_string(),
topic: Some("rt/rosout".to_string()),
node_name: Some("/test_node".to_string()),
file: Some("test.cpp".to_string()),
line: Some(42),
function: Some("test_func".to_string()),
}
}
#[test]
fn test_text_formatter() {
let formatter = TextFormatter::no_colors();
let entry = sample_entry();
let output = formatter.format(&entry);
assert!(output.contains("2024-01-15"));
assert!(output.contains("[INFO ]"));
assert!(output.contains("Test message"));
assert!(output.contains("rt/rosout"));
}
#[test]
fn test_json_formatter() {
let formatter = JsonFormatter::compact();
let entry = sample_entry();
let output = formatter.format(&entry);
assert!(output.contains("\"@timestamp\""));
assert!(output.contains("\"level\":\"INFO\""));
assert!(output.contains("\"message\":\"Test message\""));
assert!(output.contains("\"topic\":\"rt/rosout\""));
}
#[test]
fn test_syslog_formatter() {
let formatter = SyslogFormatter::default();
let entry = sample_entry();
let output = formatter.format(&entry);
assert!(output.starts_with('<'));
assert!(output.contains(">1 "));
assert!(output.contains("[hdds"));
assert!(output.contains("Test message"));
}
#[test]
fn test_syslog_pri_calculation() {
let formatter = SyslogFormatter::with_facility(SyslogFacility::Local0);
assert_eq!(formatter.pri(LogLevel::Info), 134);
assert_eq!(formatter.pri(LogLevel::Error), 131);
}
}