Skip to main content

hdds_logger/
formatter.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! Log formatters: JSON, text, syslog (RFC 5424).
5
6use crate::{LogEntry, LogLevel, SyslogFacility};
7use serde::{Deserialize, Serialize};
8
9/// Output format for log entries.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11pub enum OutputFormat {
12    /// Plain text format (human-readable).
13    #[default]
14    Text,
15    /// JSON format (ELK/structured logging ready).
16    Json,
17    /// Syslog RFC 5424 format.
18    Syslog,
19    /// JSON Lines format (one JSON object per line).
20    JsonLines,
21}
22
23/// Log formatter trait.
24pub trait LogFormatter {
25    /// Format a log entry to string.
26    fn format(&self, entry: &LogEntry) -> String;
27}
28
29/// Text formatter for human-readable output.
30#[derive(Debug, Clone)]
31pub struct TextFormatter {
32    /// Include timestamp.
33    pub show_timestamp: bool,
34    /// Include participant ID.
35    pub show_participant: bool,
36    /// Include topic name.
37    pub show_topic: bool,
38    /// Use colors (ANSI escape codes).
39    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    /// Create formatter without colors.
55    #[cfg(test)]
56    fn no_colors() -> Self {
57        Self {
58            use_colors: false,
59            ..Default::default()
60        }
61    }
62
63    /// Get ANSI color code for log level.
64    fn level_color(&self, level: LogLevel) -> &'static str {
65        if !self.use_colors {
66            return "";
67        }
68        match level {
69            LogLevel::Unset => "\x1b[37m",   // White
70            LogLevel::Debug => "\x1b[36m",   // Cyan
71            LogLevel::Info => "\x1b[32m",    // Green
72            LogLevel::Warn => "\x1b[33m",    // Yellow
73            LogLevel::Error => "\x1b[31m",   // Red
74            LogLevel::Fatal => "\x1b[35;1m", // Magenta bold
75        }
76    }
77
78    /// Get ANSI reset code.
79    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        // Level with color
97        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/// JSON formatter for structured logging.
129#[derive(Debug, Clone, Default)]
130pub struct JsonFormatter {
131    /// Pretty print JSON.
132    pub pretty: bool,
133}
134
135impl JsonFormatter {
136    /// Create compact JSON formatter.
137    pub fn compact() -> Self {
138        Self { pretty: false }
139    }
140}
141
142/// JSON log entry structure (ELK-compatible).
143#[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/// Syslog RFC 5424 formatter.
187#[derive(Debug, Clone)]
188pub struct SyslogFormatter {
189    /// Syslog facility.
190    pub facility: SyslogFacility,
191    /// Application name.
192    pub app_name: String,
193    /// Hostname (or "-" for nil).
194    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    /// Create with custom facility.
209    #[cfg(test)]
210    fn with_facility(facility: SyslogFacility) -> Self {
211        Self {
212            facility,
213            ..Default::default()
214        }
215    }
216
217    /// Calculate PRI value (facility * 8 + severity).
218    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        // RFC 5424 format:
226        // <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID SD MSG
227
228        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        // Structured data (optional)
234        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
250/// Get hostname or fallback to "localhost".
251fn gethostname() -> String {
252    std::env::var("HOSTNAME")
253        .or_else(|_| std::env::var("HOST"))
254        .unwrap_or_else(|_| "localhost".to_string())
255}
256
257/// Create a formatter for the given output format.
258pub 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        // Should start with PRI
318        assert!(output.starts_with('<'));
319        // Should contain version "1"
320        assert!(output.contains(">1 "));
321        // Should contain structured data
322        assert!(output.contains("[hdds"));
323        // Should contain message
324        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        // Local0 (16) * 8 + Info severity (6) = 134
332        assert_eq!(formatter.pri(LogLevel::Info), 134);
333
334        // Local0 (16) * 8 + Error severity (3) = 131
335        assert_eq!(formatter.pri(LogLevel::Error), 131);
336    }
337}