1use crate::{LogEntry, LogLevel, SyslogFacility};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11pub enum OutputFormat {
12 #[default]
14 Text,
15 Json,
17 Syslog,
19 JsonLines,
21}
22
23pub trait LogFormatter {
25 fn format(&self, entry: &LogEntry) -> String;
27}
28
29#[derive(Debug, Clone)]
31pub struct TextFormatter {
32 pub show_timestamp: bool,
34 pub show_participant: bool,
36 pub show_topic: bool,
38 pub use_colors: bool,
40}
41
42impl Default for TextFormatter {
43 fn default() -> Self {
44 Self {
45 show_timestamp: true,
46 show_participant: true,
47 show_topic: true,
48 use_colors: true,
49 }
50 }
51}
52
53impl TextFormatter {
54 #[cfg(test)]
56 fn no_colors() -> Self {
57 Self {
58 use_colors: false,
59 ..Default::default()
60 }
61 }
62
63 fn level_color(&self, level: LogLevel) -> &'static str {
65 if !self.use_colors {
66 return "";
67 }
68 match level {
69 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", }
76 }
77
78 fn reset(&self) -> &'static str {
80 if self.use_colors {
81 "\x1b[0m"
82 } else {
83 ""
84 }
85 }
86}
87
88impl LogFormatter for TextFormatter {
89 fn format(&self, entry: &LogEntry) -> String {
90 let mut parts = Vec::new();
91
92 if self.show_timestamp {
93 parts.push(entry.timestamp.format("%Y-%m-%d %H:%M:%S%.3f").to_string());
94 }
95
96 let level_str = format!(
98 "{}[{:5}]{}",
99 self.level_color(entry.level),
100 entry.level.as_str(),
101 self.reset()
102 );
103 parts.push(level_str);
104
105 if self.show_participant {
106 parts.push(format!(
107 "[{}]",
108 &entry.participant_id[..8.min(entry.participant_id.len())]
109 ));
110 }
111
112 if self.show_topic {
113 if let Some(ref topic) = entry.topic {
114 parts.push(format!("[{}]", topic));
115 }
116 }
117
118 if let Some(ref node) = entry.node_name {
119 parts.push(format!("[{}]", node));
120 }
121
122 parts.push(entry.message.clone());
123
124 parts.join(" ")
125 }
126}
127
128#[derive(Debug, Clone, Default)]
130pub struct JsonFormatter {
131 pub pretty: bool,
133}
134
135impl JsonFormatter {
136 pub fn compact() -> Self {
138 Self { pretty: false }
139 }
140}
141
142#[derive(Debug, Serialize)]
144struct JsonLogEntry<'a> {
145 #[serde(rename = "@timestamp")]
146 timestamp: String,
147 level: &'static str,
148 message: &'a str,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 topic: Option<&'a str>,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 node: Option<&'a str>,
153 participant_id: &'a str,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 file: Option<&'a str>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 line: Option<u32>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 function: Option<&'a str>,
160 source: &'static str,
161}
162
163impl LogFormatter for JsonFormatter {
164 fn format(&self, entry: &LogEntry) -> String {
165 let json_entry = JsonLogEntry {
166 timestamp: entry.timestamp.to_rfc3339(),
167 level: entry.level.as_str(),
168 message: &entry.message,
169 topic: entry.topic.as_deref(),
170 node: entry.node_name.as_deref(),
171 participant_id: &entry.participant_id,
172 file: entry.file.as_deref(),
173 line: entry.line,
174 function: entry.function.as_deref(),
175 source: "hdds-logger",
176 };
177
178 if self.pretty {
179 serde_json::to_string_pretty(&json_entry).unwrap_or_else(|_| entry.message.clone())
180 } else {
181 serde_json::to_string(&json_entry).unwrap_or_else(|_| entry.message.clone())
182 }
183 }
184}
185
186#[derive(Debug, Clone)]
188pub struct SyslogFormatter {
189 pub facility: SyslogFacility,
191 pub app_name: String,
193 pub hostname: String,
195}
196
197impl Default for SyslogFormatter {
198 fn default() -> Self {
199 Self {
200 facility: SyslogFacility::Local0,
201 app_name: "hdds-logger".to_string(),
202 hostname: gethostname(),
203 }
204 }
205}
206
207impl SyslogFormatter {
208 #[cfg(test)]
210 fn with_facility(facility: SyslogFacility) -> Self {
211 Self {
212 facility,
213 ..Default::default()
214 }
215 }
216
217 fn pri(&self, level: LogLevel) -> u8 {
219 self.facility.code() * 8 + level.syslog_severity()
220 }
221}
222
223impl LogFormatter for SyslogFormatter {
224 fn format(&self, entry: &LogEntry) -> String {
225 let pri = self.pri(entry.level);
229 let timestamp = entry.timestamp.format("%Y-%m-%dT%H:%M:%S%.6fZ");
230 let procid = std::process::id();
231 let msgid = entry.topic.as_deref().unwrap_or("-");
232
233 let sd = if let Some(ref node) = entry.node_name {
235 format!(
236 "[hdds node=\"{}\" participant=\"{}\"]",
237 node, entry.participant_id
238 )
239 } else {
240 format!("[hdds participant=\"{}\"]", entry.participant_id)
241 };
242
243 format!(
244 "<{}>1 {} {} {} {} {} {} {}",
245 pri, timestamp, self.hostname, self.app_name, procid, msgid, sd, entry.message
246 )
247 }
248}
249
250fn gethostname() -> String {
252 std::env::var("HOSTNAME")
253 .or_else(|_| std::env::var("HOST"))
254 .unwrap_or_else(|_| "localhost".to_string())
255}
256
257pub fn create_formatter(format: OutputFormat) -> Box<dyn LogFormatter + Send + Sync> {
259 match format {
260 OutputFormat::Text => Box::new(TextFormatter::default()),
261 OutputFormat::Json | OutputFormat::JsonLines => Box::new(JsonFormatter::compact()),
262 OutputFormat::Syslog => Box::new(SyslogFormatter::default()),
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use chrono::{DateTime, Utc};
270
271 fn sample_entry() -> LogEntry {
272 LogEntry {
273 timestamp: DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
274 .unwrap()
275 .with_timezone(&Utc),
276 level: LogLevel::Info,
277 message: "Test message".to_string(),
278 participant_id: "01020304-0506-0708-090a-0b0c0d0e0f10".to_string(),
279 topic: Some("rt/rosout".to_string()),
280 node_name: Some("/test_node".to_string()),
281 file: Some("test.cpp".to_string()),
282 line: Some(42),
283 function: Some("test_func".to_string()),
284 }
285 }
286
287 #[test]
288 fn test_text_formatter() {
289 let formatter = TextFormatter::no_colors();
290 let entry = sample_entry();
291 let output = formatter.format(&entry);
292
293 assert!(output.contains("2024-01-15"));
294 assert!(output.contains("[INFO ]"));
295 assert!(output.contains("Test message"));
296 assert!(output.contains("rt/rosout"));
297 }
298
299 #[test]
300 fn test_json_formatter() {
301 let formatter = JsonFormatter::compact();
302 let entry = sample_entry();
303 let output = formatter.format(&entry);
304
305 assert!(output.contains("\"@timestamp\""));
306 assert!(output.contains("\"level\":\"INFO\""));
307 assert!(output.contains("\"message\":\"Test message\""));
308 assert!(output.contains("\"topic\":\"rt/rosout\""));
309 }
310
311 #[test]
312 fn test_syslog_formatter() {
313 let formatter = SyslogFormatter::default();
314 let entry = sample_entry();
315 let output = formatter.format(&entry);
316
317 assert!(output.starts_with('<'));
319 assert!(output.contains(">1 "));
321 assert!(output.contains("[hdds"));
323 assert!(output.contains("Test message"));
325 }
326
327 #[test]
328 fn test_syslog_pri_calculation() {
329 let formatter = SyslogFormatter::with_facility(SyslogFacility::Local0);
330
331 assert_eq!(formatter.pri(LogLevel::Info), 134);
333
334 assert_eq!(formatter.pri(LogLevel::Error), 131);
336 }
337}