1use core::fmt;
10
11use super::Format;
12use crate::record::Record;
13use crate::value::Value;
14
15#[derive(Debug, Clone, Copy, Default)]
33pub struct LogfmtFormat {
34 _private: (),
35}
36
37impl LogfmtFormat {
38 pub const fn new() -> Self {
40 Self { _private: () }
41 }
42}
43
44impl Format for LogfmtFormat {
45 fn write_record<W: fmt::Write + ?Sized>(
46 &self,
47 record: &Record<'_>,
48 writer: &mut W,
49 ) -> fmt::Result {
50 let mut first = true;
51 if let Some(ts) = record.metadata.timestamp_unix_nanos {
52 kv_prefix(writer, &mut first)?;
53 write!(writer, "timestamp={ts}")?;
54 }
55 kv_prefix(writer, &mut first)?;
56 write!(writer, "level={}", record.metadata.level.as_str())?;
57 kv_prefix(writer, &mut first)?;
58 writer.write_str("target=")?;
59 write_logfmt_str(writer, record.metadata.target)?;
60 kv_prefix(writer, &mut first)?;
61 writer.write_str("message=")?;
62 write_logfmt_str(writer, record.message)?;
63 if let Some(file) = record.metadata.file {
64 kv_prefix(writer, &mut first)?;
65 writer.write_str("file=")?;
66 write_logfmt_str(writer, file)?;
67 }
68 if let Some(line) = record.metadata.line {
69 kv_prefix(writer, &mut first)?;
70 write!(writer, "line={line}")?;
71 }
72 for field in record.all_fields() {
73 kv_prefix(writer, &mut first)?;
74 write_logfmt_key(writer, field.key)?;
75 writer.write_char('=')?;
76 write_logfmt_value(writer, field.value)?;
77 }
78 writer.write_char('\n')
79 }
80}
81
82fn kv_prefix<W: fmt::Write + ?Sized>(w: &mut W, first: &mut bool) -> fmt::Result {
83 if *first {
84 *first = false;
85 Ok(())
86 } else {
87 w.write_char(' ')
88 }
89}
90
91fn needs_quotes(s: &str) -> bool {
92 s.is_empty()
93 || s.chars()
94 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\') || (c as u32) < 0x20)
95}
96
97fn write_logfmt_str<W: fmt::Write + ?Sized>(w: &mut W, s: &str) -> fmt::Result {
98 if needs_quotes(s) {
99 w.write_char('"')?;
100 for c in s.chars() {
101 match c {
102 '"' => w.write_str("\\\"")?,
103 '\\' => w.write_str("\\\\")?,
104 '\n' => w.write_str("\\n")?,
105 '\r' => w.write_str("\\r")?,
106 '\t' => w.write_str("\\t")?,
107 c if (c as u32) < 0x20 => write!(w, "\\x{:02x}", c as u32)?,
108 c => w.write_char(c)?,
109 }
110 }
111 w.write_char('"')
112 } else {
113 w.write_str(s)
114 }
115}
116
117fn write_logfmt_key<W: fmt::Write + ?Sized>(w: &mut W, key: &str) -> fmt::Result {
118 for c in key.chars() {
121 if c.is_whitespace() || c == '=' || c == '"' {
122 w.write_char('_')?;
123 } else {
124 w.write_char(c)?;
125 }
126 }
127 Ok(())
128}
129
130fn write_logfmt_value<W: fmt::Write + ?Sized>(w: &mut W, v: Value<'_>) -> fmt::Result {
131 match v {
132 Value::Null => Ok(()),
133 Value::Bool(true) => w.write_str("true"),
134 Value::Bool(false) => w.write_str("false"),
135 Value::I64(n) => write!(w, "{n}"),
136 Value::U64(n) => write!(w, "{n}"),
137 Value::F64(n) => write!(w, "{n}"),
138 Value::Str(s) => write_logfmt_str(w, s),
139 Value::Char(c) => {
140 let mut buf = [0u8; 4];
141 let s: &str = c.encode_utf8(&mut buf);
142 write_logfmt_str(w, s)
143 }
144 }
145}
146
147#[cfg(all(test, feature = "std"))]
148mod tests {
149 use super::*;
150 use crate::record::{Field, Metadata};
151 use crate::Level;
152
153 fn fmt_one(record: &Record<'_>) -> String {
154 let mut s = String::new();
155 LogfmtFormat::new().write_record(record, &mut s).unwrap();
156 s
157 }
158
159 #[test]
160 fn simple_record_format() {
161 let record = Record::new(Metadata::new(Level::Info, "app"), "hello", &[]);
162 let out = fmt_one(&record);
163 assert_eq!(out, "level=info target=app message=hello\n");
164 }
165
166 #[test]
167 fn message_with_spaces_is_quoted() {
168 let record = Record::new(Metadata::new(Level::Info, "app"), "hi there", &[]);
169 let out = fmt_one(&record);
170 assert!(out.contains(r#"message="hi there""#), "{out}");
171 }
172
173 #[test]
174 fn null_value_emits_empty() {
175 let fields = [Field::new("x", Value::Null)];
176 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
177 let out = fmt_one(&record);
178 assert!(out.contains("x="));
179 assert!(out.ends_with("x=\n"));
180 }
181
182 #[test]
183 fn fields_after_message() {
184 let fields = [Field::new("port", Value::U64(80))];
185 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
186 let out = fmt_one(&record);
187 let msg_pos = out.find("message=").unwrap();
188 let port_pos = out.find("port=").unwrap();
189 assert!(msg_pos < port_pos);
190 }
191
192 #[test]
193 fn quotes_in_value_are_escaped() {
194 let fields = [Field::new("data", Value::Str("a\"b"))];
195 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
196 let out = fmt_one(&record);
197 assert!(out.contains(r#"data="a\"b""#), "{out}");
198 }
199
200 #[test]
201 fn key_whitespace_replaced() {
202 let fields = [Field::new("the key", Value::U64(1))];
203 let record = Record::new(Metadata::new(Level::Info, "t"), "m", &fields);
204 let out = fmt_one(&record);
205 assert!(out.contains("the_key=1"), "{out}");
206 }
207}