Skip to main content

tpt_logfmt_parse/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4//! Zero-copy logfmt parser. See [`LogfmtParser`] for the streaming iterator API
5//! and [`parse_to_map`] for the convenience owned-map API.
6
7use std::collections::HashMap;
8use std::fmt;
9
10/// An error produced during logfmt parsing.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LogfmtError {
13    /// Byte offset in the input where the error occurred.
14    pub position: usize,
15    /// Human-readable description of what went wrong.
16    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
31/// Zero-copy streaming logfmt parser.
32///
33/// Yields `(&str, &str)` key-value pairs borrowed directly from the input.
34/// Keys without a `=` are yielded with an empty-string value.
35///
36/// # Example
37///
38/// ```
39/// use tpt_logfmt_parse::LogfmtParser;
40///
41/// let input = r#"level=info msg="hello world" latency=42ms"#;
42/// let pairs: Vec<_> = LogfmtParser::new(input).collect::<Result<_, _>>().unwrap();
43/// assert_eq!(pairs[0], ("level", "info"));
44/// assert_eq!(pairs[1], ("msg", "hello world"));
45/// assert_eq!(pairs[2], ("latency", "42ms"));
46/// ```
47pub struct LogfmtParser<'a> {
48    input: &'a str,
49    pos: usize,
50}
51
52impl<'a> LogfmtParser<'a> {
53    /// Create a new parser for the given logfmt line.
54    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    /// Parse an unquoted token (key or bare value): everything up to `=`, `"`, or space.
65    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    /// Parse a quoted string value, returning the content between the quotes.
77    ///
78    /// The returned slice is borrowed directly from the raw input (true
79    /// zero-copy); escape sequences such as `\"` and `\\` are skipped over so
80    /// the slice's byte range encompasses the escaped characters, meaning the
81    /// returned `&str` may still contain literal backslashes. Callers needing
82    /// unescaped content should use [`parse_to_map`], which allocates and
83    /// resolves escape sequences.
84    fn parse_quoted(&mut self) -> Result<&'a str, LogfmtError> {
85        debug_assert_eq!(self.input.as_bytes()[self.pos], b'"');
86        self.pos += 1; // consume opening quote
87        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; // consume closing quote
99                    return Ok(slice);
100                }
101                b'\\' => {
102                    self.pos += 1; // skip escape char
103                    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; // skip escaped char
110                }
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        // No `=` — bare key with empty value
135        if self.pos >= self.input.len() || self.input.as_bytes()[self.pos] != b'=' {
136            return Some(Ok((key, "")));
137        }
138
139        self.pos += 1; // consume `=`
140
141        // Value: quoted or bare
142        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
150/// Parse a logfmt string into an owned `HashMap<String, String>`.
151///
152/// Escape sequences inside quoted values (`\"`, `\\`) are resolved.
153///
154/// # Example
155///
156/// ```
157/// use tpt_logfmt_parse::parse_to_map;
158///
159/// let map = parse_to_map(r#"level=error msg="disk full" retries=3"#).unwrap();
160/// assert_eq!(map["level"], "error");
161/// assert_eq!(map["msg"], "disk full");
162/// assert_eq!(map["retries"], "3");
163/// ```
164pub 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        // Parse key (always bare)
192        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; // consume `=`
205
206        let value = if pos < input.len() && input.as_bytes()[pos] == b'"' {
207            pos += 1; // consume opening quote
208            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}