Skip to main content

log_io/format/
json.rs

1//! JSON output format.
2//!
3//! Emits one JSON object per record terminated by a newline. The
4//! field ordering is stable so consumers can pattern-match on prefixes:
5//! `timestamp` (if set) -> `level` -> `target` -> `message` -> source
6//! location -> context fields -> structured fields.
7
8use core::fmt;
9
10use super::Format;
11use crate::record::Record;
12use crate::value::Value;
13
14/// JSON formatter.
15///
16/// The default configuration emits compact JSON with a trailing
17/// newline. Set [`Self::pretty`] for an indented form intended for
18/// developer eyeballs, not machine ingestion.
19///
20/// # Example
21///
22/// ```
23/// use log_io::format::{Format, JsonFormat};
24/// use log_io::{Field, Level, Metadata, Record, Value};
25///
26/// let fmt = JsonFormat::new();
27/// let fields = [Field::new("port", Value::U64(8080))];
28/// let record = Record::new(Metadata::new(Level::Info, "app"), "started", &fields);
29///
30/// let mut out = String::new();
31/// fmt.write_record(&record, &mut out).unwrap();
32///
33/// assert!(out.starts_with('{'));
34/// assert!(out.ends_with("}\n"));
35/// assert!(out.contains("\"port\":8080"));
36/// ```
37#[derive(Debug, Clone, Copy, Default)]
38pub struct JsonFormat {
39    pretty: bool,
40    timestamp_rfc3339: bool,
41}
42
43impl JsonFormat {
44    /// New compact JSON formatter. Equivalent to `Self::default()`.
45    pub const fn new() -> Self {
46        Self {
47            pretty: false,
48            timestamp_rfc3339: false,
49        }
50    }
51
52    /// Toggle pretty (multi-line, indented) output.
53    pub const fn pretty(mut self, enabled: bool) -> Self {
54        self.pretty = enabled;
55        self
56    }
57
58    /// Emit timestamps as RFC 3339 strings instead of unix nanoseconds.
59    ///
60    /// When enabled and a record's timestamp is present, the output
61    /// looks like `"timestamp":"2026-05-18T13:24:01.123456789Z"`. When
62    /// disabled (default), it is `"timestamp":1700000000000000000`.
63    pub const fn timestamp_rfc3339(mut self, enabled: bool) -> Self {
64        self.timestamp_rfc3339 = enabled;
65        self
66    }
67}
68
69impl Format for JsonFormat {
70    fn write_record<W: fmt::Write + ?Sized>(
71        &self,
72        record: &Record<'_>,
73        writer: &mut W,
74    ) -> fmt::Result {
75        let nl = if self.pretty { "\n" } else { "" };
76        let ind = if self.pretty { "  " } else { "" };
77        let sp = if self.pretty { " " } else { "" };
78
79        writer.write_char('{')?;
80        writer.write_str(nl)?;
81
82        let mut first = true;
83
84        if let Some(ts) = record.metadata.timestamp_unix_nanos {
85            comma(writer, &mut first, nl, ind)?;
86            writer.write_str("\"timestamp\":")?;
87            writer.write_str(sp)?;
88            if self.timestamp_rfc3339 {
89                writer.write_char('"')?;
90                crate::format::rfc3339::write_rfc3339_nanos(ts, writer)?;
91                writer.write_char('"')?;
92            } else {
93                write!(writer, "{ts}")?;
94            }
95        }
96
97        comma(writer, &mut first, nl, ind)?;
98        writer.write_str("\"level\":")?;
99        writer.write_str(sp)?;
100        writer.write_char('"')?;
101        writer.write_str(record.metadata.level.as_str())?;
102        writer.write_char('"')?;
103
104        comma(writer, &mut first, nl, ind)?;
105        writer.write_str("\"target\":")?;
106        writer.write_str(sp)?;
107        write_json_str(writer, record.metadata.target)?;
108
109        comma(writer, &mut first, nl, ind)?;
110        writer.write_str("\"message\":")?;
111        writer.write_str(sp)?;
112        write_json_str(writer, record.message)?;
113
114        if let Some(file) = record.metadata.file {
115            comma(writer, &mut first, nl, ind)?;
116            writer.write_str("\"file\":")?;
117            writer.write_str(sp)?;
118            write_json_str(writer, file)?;
119        }
120        if let Some(line) = record.metadata.line {
121            comma(writer, &mut first, nl, ind)?;
122            writer.write_str("\"line\":")?;
123            writer.write_str(sp)?;
124            write!(writer, "{line}")?;
125        }
126
127        for field in record.all_fields() {
128            comma(writer, &mut first, nl, ind)?;
129            write_json_str(writer, field.key)?;
130            writer.write_char(':')?;
131            writer.write_str(sp)?;
132            write_json_value(writer, field.value)?;
133        }
134
135        if self.pretty {
136            writer.write_char('\n')?;
137        }
138        writer.write_char('}')?;
139        writer.write_char('\n')?;
140        Ok(())
141    }
142}
143
144fn comma<W: fmt::Write + ?Sized>(w: &mut W, first: &mut bool, nl: &str, ind: &str) -> fmt::Result {
145    if *first {
146        *first = false;
147    } else {
148        w.write_char(',')?;
149        w.write_str(nl)?;
150    }
151    w.write_str(ind)?;
152    Ok(())
153}
154
155fn write_json_str<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
156    w.write_char('"')?;
157    // Batched escape: scan the string for the next byte that needs
158    // escaping, write the clean run as one chunk, then emit the
159    // escape, then repeat. UTF-8 is preserved because the bytes that
160    // need escaping (ASCII control + " + \) are all single-byte.
161    let bytes = s.as_bytes();
162    let mut start = 0;
163    let mut i = 0;
164    while i < bytes.len() {
165        let b = bytes[i];
166        let escape: Option<&'static str> = match b {
167            b'"' => Some("\\\""),
168            b'\\' => Some("\\\\"),
169            b'\n' => Some("\\n"),
170            b'\r' => Some("\\r"),
171            b'\t' => Some("\\t"),
172            0x08 => Some("\\b"),
173            0x0c => Some("\\f"),
174            0x00..=0x1F => None, // generic \u00XX path below
175            _ => {
176                i += 1;
177                continue;
178            }
179        };
180        if start < i {
181            // SAFETY-equivalent: the clean run is valid UTF-8 because
182            // we only skipped past non-escape bytes; all such bytes
183            // are either ASCII (single byte) or UTF-8 continuation /
184            // leading bytes that we copied as a contiguous slice.
185            w.write_str(&s[start..i])?;
186        }
187        if let Some(seq) = escape {
188            w.write_str(seq)?;
189        } else {
190            write!(w, "\\u{:04x}", b)?;
191        }
192        i += 1;
193        start = i;
194    }
195    if start < bytes.len() {
196        w.write_str(&s[start..])?;
197    }
198    w.write_char('"')
199}
200
201fn write_json_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
202    match v {
203        Value::Null => w.write_str("null"),
204        Value::Bool(true) => w.write_str("true"),
205        Value::Bool(false) => w.write_str("false"),
206        Value::I64(n) => write!(w, "{n}"),
207        Value::U64(n) => write!(w, "{n}"),
208        Value::F64(n) => {
209            if n.is_finite() {
210                // Use Rust's default float formatting; it round-trips
211                // and never emits trailing dots.
212                write!(w, "{n}")
213            } else {
214                w.write_str("null")
215            }
216        }
217        Value::Str(s) => write_json_str(w, s),
218        Value::Char(c) => {
219            // One-character string. Use the string path so escaping
220            // applies (the char might be a quote or control byte).
221            let mut buf = [0u8; 4];
222            let s: &str = c.encode_utf8(&mut buf);
223            write_json_str(w, s)
224        }
225    }
226}
227
228#[cfg(all(test, feature = "std"))]
229mod tests {
230    use super::*;
231    use crate::record::{Field, Metadata};
232    use crate::Level;
233
234    fn fmt_one(json: JsonFormat, record: &Record<'_>) -> String {
235        let mut s = String::new();
236        json.write_record(record, &mut s).unwrap();
237        s
238    }
239
240    #[test]
241    fn compact_object_with_trailing_newline() {
242        let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
243        let out = fmt_one(JsonFormat::new(), &record);
244        assert_eq!(
245            out,
246            "{\"level\":\"info\",\"target\":\"app\",\"message\":\"hello\"}\n"
247        );
248    }
249
250    #[test]
251    fn fields_appear_after_message() {
252        let fields = [
253            Field::new("port", Value::U64(8080)),
254            Field::new("host", Value::Str("0.0.0.0")),
255        ];
256        let record = Record::new(Metadata::new(Level::Info, "app"), "up", &fields);
257        let out = fmt_one(JsonFormat::new(), &record);
258        let msg_pos = out.find("\"message\":\"up\"").unwrap();
259        let port_pos = out.find("\"port\":8080").unwrap();
260        assert!(msg_pos < port_pos);
261        assert!(out.contains("\"host\":\"0.0.0.0\""));
262    }
263
264    #[test]
265    fn context_appears_before_fields() {
266        let ctx = [Field::new("trace_id", Value::Str("abc"))];
267        let fields = [Field::new("port", Value::U64(80))];
268        let record = Record::with_context(Metadata::new(Level::Info, "app"), "msg", &fields, &ctx);
269        let out = fmt_one(JsonFormat::new(), &record);
270        let trace_pos = out.find("\"trace_id\"").unwrap();
271        let port_pos = out.find("\"port\"").unwrap();
272        assert!(trace_pos < port_pos);
273    }
274
275    #[test]
276    fn escapes_control_and_quotes() {
277        let fields = [Field::new("data", Value::Str("a\"b\nc\td"))];
278        let record = Record::new(Metadata::new(Level::Info, "t"), "x", &fields);
279        let out = fmt_one(JsonFormat::new(), &record);
280        assert!(out.contains(r#""data":"a\"b\nc\td""#), "{out}");
281    }
282
283    #[test]
284    fn escapes_low_codepoint_to_unicode() {
285        let fields = [Field::new("data", Value::Str("\x01"))];
286        let record = Record::new(Metadata::new(Level::Info, "t"), "x", &fields);
287        let out = fmt_one(JsonFormat::new(), &record);
288        let backslash = "\\";
289        let expected = format!("\"data\":\"{}u0001\"", backslash);
290        assert!(out.contains(&expected), "{}", out);
291    }
292
293    #[test]
294    fn nan_inf_become_null() {
295        let fields = [
296            Field::new("a", Value::F64(f64::NAN)),
297            Field::new("b", Value::F64(f64::INFINITY)),
298            Field::new("c", Value::F64(1.5)),
299        ];
300        let record = Record::new(Metadata::new(Level::Info, "t"), "x", &fields);
301        let out = fmt_one(JsonFormat::new(), &record);
302        assert!(out.contains("\"a\":null"));
303        assert!(out.contains("\"b\":null"));
304        assert!(out.contains("\"c\":1.5"));
305    }
306
307    #[test]
308    fn timestamp_unix_nanos_default() {
309        let metadata = Metadata::new(Level::Info, "t").with_timestamp(1_700_000_000_000_000_000);
310        let record = Record::new(metadata, "x", &[]);
311        let out = fmt_one(JsonFormat::new(), &record);
312        assert!(out.contains("\"timestamp\":1700000000000000000"));
313    }
314
315    #[test]
316    fn timestamp_rfc3339_when_enabled() {
317        let metadata = Metadata::new(Level::Info, "t").with_timestamp(1_700_000_000_000_000_000);
318        let record = Record::new(metadata, "x", &[]);
319        let out = fmt_one(JsonFormat::new().timestamp_rfc3339(true), &record);
320        assert!(out.contains("\"timestamp\":\"2023-11-14T22:13:20"), "{out}");
321    }
322
323    #[test]
324    fn pretty_indents_and_breaks_lines() {
325        let fields = [Field::new("port", Value::U64(80))];
326        let record = Record::new(Metadata::new(Level::Info, "t"), "x", &fields);
327        let out = fmt_one(JsonFormat::new().pretty(true), &record);
328        assert!(out.contains("\n  \"level\": \"info\""), "{out}");
329        assert!(out.ends_with("}\n"));
330    }
331}