Skip to main content

log_io/format/
human.rs

1//! Human-readable output format.
2//!
3//! A single-line, fixed-column format optimized for tailing logs in a
4//! terminal. Layout:
5//!
6//! ```text
7//! TIMESTAMP LEVEL [target] message key=value key=value
8//! ```
9//!
10//! Timestamps are RFC 3339 when present. Levels are upper-case and
11//! space-padded to a fixed width.
12
13use core::fmt;
14
15use super::Format;
16use crate::record::Record;
17use crate::value::Value;
18
19/// Human-readable formatter.
20///
21/// # Example
22///
23/// ```
24/// use log_io::format::{Format, HumanFormat};
25/// use log_io::{Field, Level, Metadata, Record, Value};
26///
27/// let fmt = HumanFormat::new();
28/// let fields = [Field::new("port", Value::U64(8080))];
29/// let record = Record::new(Metadata::new(Level::Info, "app"), "ok", &fields);
30///
31/// let mut out = String::new();
32/// fmt.write_record(&record, &mut out).unwrap();
33/// assert!(out.contains("INFO "));
34/// assert!(out.contains("[app]"));
35/// assert!(out.ends_with('\n'));
36/// ```
37#[derive(Debug, Clone, Copy)]
38pub struct HumanFormat {
39    show_target: bool,
40    show_source_location: bool,
41}
42
43impl Default for HumanFormat {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl HumanFormat {
50    /// New formatter with sensible defaults: target shown, source
51    /// location hidden.
52    pub const fn new() -> Self {
53        Self {
54            show_target: true,
55            show_source_location: false,
56        }
57    }
58
59    /// Toggle the `[target]` column.
60    pub const fn show_target(mut self, enabled: bool) -> Self {
61        self.show_target = enabled;
62        self
63    }
64
65    /// Toggle inclusion of the `file:line` suffix when the record
66    /// carries source location metadata.
67    pub const fn show_source_location(mut self, enabled: bool) -> Self {
68        self.show_source_location = enabled;
69        self
70    }
71}
72
73impl Format for HumanFormat {
74    fn write_record<W: fmt::Write + ?Sized>(
75        &self,
76        record: &Record<'_>,
77        writer: &mut W,
78    ) -> fmt::Result {
79        if let Some(ts) = record.metadata.timestamp_unix_nanos {
80            crate::format::rfc3339::write_rfc3339_nanos(ts, writer)?;
81            writer.write_char(' ')?;
82        }
83        // 5 characters wide to align across all levels.
84        write!(writer, "{:<5} ", record.metadata.level.as_str_upper())?;
85        if self.show_target {
86            writer.write_char('[')?;
87            write_line_safe(writer, record.metadata.target)?;
88            writer.write_str("] ")?;
89        }
90        write_line_safe(writer, record.message)?;
91        for field in record.all_fields() {
92            writer.write_char(' ')?;
93            write_line_safe(writer, field.key)?;
94            writer.write_char('=')?;
95            write_value(writer, field.value)?;
96        }
97        if self.show_source_location {
98            if let (Some(file), Some(line)) = (record.metadata.file, record.metadata.line) {
99                write!(writer, " ({file}:{line})")?;
100            }
101        }
102        writer.write_char('\n')
103    }
104}
105
106/// Write `s` without ever emitting a literal newline, tab, or CR.
107/// These are escaped with `\n` / `\t` / `\r` so the human format
108/// remains one record per line.
109fn write_line_safe<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
110    for c in s.chars() {
111        match c {
112            '\n' => w.write_str("\\n")?,
113            '\r' => w.write_str("\\r")?,
114            '\t' => w.write_str("\\t")?,
115            c => w.write_char(c)?,
116        }
117    }
118    Ok(())
119}
120
121fn write_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
122    match v {
123        Value::Null => w.write_str("<null>"),
124        Value::Bool(b) => write!(w, "{b}"),
125        Value::I64(n) => write!(w, "{n}"),
126        Value::U64(n) => write!(w, "{n}"),
127        Value::F64(n) => write!(w, "{n}"),
128        Value::Str(s) => {
129            let needs_quote = s
130                .chars()
131                .any(|c| c.is_whitespace() || c == '"' || (c as u32) < 0x20);
132            if needs_quote {
133                w.write_char('"')?;
134                for c in s.chars() {
135                    match c {
136                        '"' => w.write_str("\\\"")?,
137                        '\\' => w.write_str("\\\\")?,
138                        '\n' => w.write_str("\\n")?,
139                        '\r' => w.write_str("\\r")?,
140                        '\t' => w.write_str("\\t")?,
141                        c if (c as u32) < 0x20 => write!(w, "\\x{:02x}", c as u32)?,
142                        c => w.write_char(c)?,
143                    }
144                }
145                w.write_char('"')
146            } else {
147                w.write_str(s)
148            }
149        }
150        Value::Char(c) => write!(w, "{c}"),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::record::{Field, Metadata};
158    use crate::Level;
159
160    fn fmt_one(fmt: HumanFormat, record: &Record<'_>) -> String {
161        let mut s = String::new();
162        fmt.write_record(record, &mut s).unwrap();
163        s
164    }
165
166    #[test]
167    fn level_is_uppercase_padded() {
168        let record = Record::new(Metadata::new(Level::Info, "app"), "hi", &[]);
169        let out = fmt_one(HumanFormat::new(), &record);
170        assert!(out.starts_with("INFO  ["));
171    }
172
173    #[test]
174    fn target_can_be_hidden() {
175        let record = Record::new(Metadata::new(Level::Warn, "app"), "hi", &[]);
176        let out = fmt_one(HumanFormat::new().show_target(false), &record);
177        assert!(!out.contains('['));
178        assert!(out.contains("WARN  hi"));
179    }
180
181    #[test]
182    fn fields_appended_to_line() {
183        let fields = [Field::new("port", Value::U64(80))];
184        let record = Record::new(Metadata::new(Level::Info, "app"), "ok", &fields);
185        let out = fmt_one(HumanFormat::new(), &record);
186        assert!(out.contains("ok port=80"));
187    }
188
189    #[test]
190    fn source_location_optional() {
191        let metadata = Metadata::new(Level::Info, "app").with_location("src/lib.rs", 42);
192        let record = Record::new(metadata, "ok", &[]);
193        let with = fmt_one(HumanFormat::new().show_source_location(true), &record);
194        let without = fmt_one(HumanFormat::new(), &record);
195        assert!(with.contains("(src/lib.rs:42)"), "{with}");
196        assert!(!without.contains("src/lib.rs"));
197    }
198
199    #[test]
200    fn timestamp_prepended_when_set() {
201        let metadata = Metadata::new(Level::Info, "app").with_timestamp(1_700_000_000_000_000_000);
202        let record = Record::new(metadata, "ok", &[]);
203        let out = fmt_one(HumanFormat::new(), &record);
204        assert!(out.starts_with("2023-11-14T"), "{out}");
205    }
206
207    #[test]
208    fn null_value_renders_as_placeholder() {
209        let fields = [Field::new("x", Value::Null)];
210        let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
211        let out = fmt_one(HumanFormat::new(), &record);
212        assert!(out.contains("x=<null>"));
213    }
214}