1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::collections::HashMap;
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LogfmtError {
13 pub position: usize,
15 pub message: &'static str,
17}
18
19impl fmt::Display for LogfmtError {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 write!(
22 f,
23 "logfmt parse error at position {}: {}",
24 self.position, self.message
25 )
26 }
27}
28
29impl std::error::Error for LogfmtError {}
30
31pub struct LogfmtParser<'a> {
48 input: &'a str,
49 pos: usize,
50}
51
52impl<'a> LogfmtParser<'a> {
53 pub fn new(input: &'a str) -> Self {
55 Self { input, pos: 0 }
56 }
57
58 fn skip_whitespace(&mut self) {
59 while self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b' ' {
60 self.pos += 1;
61 }
62 }
63
64 fn parse_bare(&mut self) -> &'a str {
66 let start = self.pos;
67 while self.pos < self.input.len() {
68 match self.input.as_bytes()[self.pos] {
69 b'=' | b' ' | b'"' => break,
70 _ => self.pos += 1,
71 }
72 }
73 &self.input[start..self.pos]
74 }
75
76 fn parse_quoted(&mut self) -> Result<&'a str, LogfmtError> {
85 debug_assert_eq!(self.input.as_bytes()[self.pos], b'"');
86 self.pos += 1; let start = self.pos;
88 loop {
89 if self.pos >= self.input.len() {
90 return Err(LogfmtError {
91 position: self.pos,
92 message: "unterminated quoted string",
93 });
94 }
95 match self.input.as_bytes()[self.pos] {
96 b'"' => {
97 let slice = &self.input[start..self.pos];
98 self.pos += 1; return Ok(slice);
100 }
101 b'\\' => {
102 self.pos += 1; if self.pos >= self.input.len() {
104 return Err(LogfmtError {
105 position: self.pos,
106 message: "unterminated escape sequence",
107 });
108 }
109 self.pos += 1; }
111 _ => self.pos += 1,
112 }
113 }
114 }
115}
116
117impl<'a> Iterator for LogfmtParser<'a> {
118 type Item = Result<(&'a str, &'a str), LogfmtError>;
119
120 fn next(&mut self) -> Option<Self::Item> {
121 self.skip_whitespace();
122 if self.pos >= self.input.len() {
123 return None;
124 }
125
126 let key = self.parse_bare();
127 if key.is_empty() {
128 return Some(Err(LogfmtError {
129 position: self.pos,
130 message: "expected key",
131 }));
132 }
133
134 if self.pos >= self.input.len() || self.input.as_bytes()[self.pos] != b'=' {
136 return Some(Ok((key, "")));
137 }
138
139 self.pos += 1; if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b'"' {
143 Some(self.parse_quoted().map(|v| (key, v)))
144 } else {
145 Some(Ok((key, self.parse_bare())))
146 }
147 }
148}
149
150pub fn parse_to_map(input: &str) -> Result<HashMap<String, String>, LogfmtError> {
165 let mut map = HashMap::new();
166 let mut pos = 0usize;
167
168 let skip_ws = |p: &mut usize| {
169 while *p < input.len() && input.as_bytes()[*p] == b' ' {
170 *p += 1;
171 }
172 };
173
174 let parse_bare_owned = |p: &mut usize| -> String {
175 let start = *p;
176 while *p < input.len() {
177 match input.as_bytes()[*p] {
178 b'=' | b' ' | b'"' => break,
179 _ => *p += 1,
180 }
181 }
182 input[start..*p].to_owned()
183 };
184
185 loop {
186 skip_ws(&mut pos);
187 if pos >= input.len() {
188 break;
189 }
190
191 let key = parse_bare_owned(&mut pos);
193 if key.is_empty() {
194 return Err(LogfmtError {
195 position: pos,
196 message: "expected key",
197 });
198 }
199
200 if pos >= input.len() || input.as_bytes()[pos] != b'=' {
201 map.insert(key, String::new());
202 continue;
203 }
204 pos += 1; let value = if pos < input.len() && input.as_bytes()[pos] == b'"' {
207 pos += 1; let mut val = String::new();
209 loop {
210 if pos >= input.len() {
211 return Err(LogfmtError {
212 position: pos,
213 message: "unterminated quoted string",
214 });
215 }
216 let ch = input[pos..].chars().next().unwrap();
217 let ch_len = ch.len_utf8();
218 if ch == '"' {
219 pos += ch_len;
220 break;
221 } else if ch == '\\' {
222 pos += ch_len;
223 if pos >= input.len() {
224 return Err(LogfmtError {
225 position: pos,
226 message: "unterminated escape sequence",
227 });
228 }
229 let esc = input[pos..].chars().next().unwrap();
230 let esc_len = esc.len_utf8();
231 match esc {
232 '"' => val.push('"'),
233 '\\' => val.push('\\'),
234 'n' => val.push('\n'),
235 't' => val.push('\t'),
236 other => {
237 val.push('\\');
238 val.push(other);
239 }
240 }
241 pos += esc_len;
242 } else {
243 val.push(ch);
244 pos += ch_len;
245 }
246 }
247 val
248 } else {
249 parse_bare_owned(&mut pos)
250 };
251
252 map.insert(key, value);
253 }
254
255 Ok(map)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn basic_pairs() {
264 let pairs: Vec<_> = LogfmtParser::new("a=1 b=2")
265 .collect::<Result<_, _>>()
266 .unwrap();
267 assert_eq!(pairs, vec![("a", "1"), ("b", "2")]);
268 }
269
270 #[test]
271 fn quoted_value() {
272 let pairs: Vec<_> = LogfmtParser::new(r#"msg="hello world""#)
273 .collect::<Result<_, _>>()
274 .unwrap();
275 assert_eq!(pairs[0].0, "msg");
276 assert_eq!(pairs[0].1, "hello world");
277 }
278
279 #[test]
280 fn bare_key_no_value() {
281 let pairs: Vec<_> = LogfmtParser::new("flag").collect::<Result<_, _>>().unwrap();
282 assert_eq!(pairs, vec![("flag", "")]);
283 }
284
285 #[test]
286 fn mixed() {
287 let pairs: Vec<_> = LogfmtParser::new(r#"level=info flag msg="ok" count=3"#)
288 .collect::<Result<_, _>>()
289 .unwrap();
290 assert_eq!(pairs[0], ("level", "info"));
291 assert_eq!(pairs[1], ("flag", ""));
292 assert_eq!(pairs[2], ("msg", "ok"));
293 assert_eq!(pairs[3], ("count", "3"));
294 }
295
296 #[test]
297 fn empty_input() {
298 let pairs: Vec<_> = LogfmtParser::new("").collect::<Result<_, _>>().unwrap();
299 assert!(pairs.is_empty());
300 }
301
302 #[test]
303 fn unterminated_quote_error() {
304 let result: Result<Vec<_>, _> = LogfmtParser::new(r#"msg="unclosed"#).collect();
305 assert!(result.is_err());
306 }
307
308 #[test]
309 fn parse_to_map_escapes() {
310 let map = parse_to_map(r#"msg="say \"hi\"" path="a\\b""#).unwrap();
311 assert_eq!(map["msg"], r#"say "hi""#);
312 assert_eq!(map["path"], r"a\b");
313 }
314
315 #[test]
316 fn parse_to_map_bare_key() {
317 let map = parse_to_map("enabled").unwrap();
318 assert_eq!(map["enabled"], "");
319 }
320
321 #[test]
322 fn parse_to_map_roundtrip() {
323 let input = r#"level=error msg="disk full" retries=3"#;
324 let map = parse_to_map(input).unwrap();
325 assert_eq!(map["level"], "error");
326 assert_eq!(map["msg"], "disk full");
327 assert_eq!(map["retries"], "3");
328 }
329
330 #[test]
331 fn parse_to_map_multibyte_utf8() {
332 let map = parse_to_map("msg=\"café ☕ résumé\"").unwrap();
333 assert_eq!(map["msg"], "café ☕ résumé");
334 }
335
336 #[test]
337 fn logfmt_parser_multibyte_utf8() {
338 let pairs: Vec<_> = LogfmtParser::new("msg=\"café ☕\"")
339 .collect::<Result<_, _>>()
340 .unwrap();
341 assert_eq!(pairs[0], ("msg", "café ☕"));
342 }
343}