#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogfmtError {
pub position: usize,
pub message: &'static str,
}
impl fmt::Display for LogfmtError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"logfmt parse error at position {}: {}",
self.position, self.message
)
}
}
impl std::error::Error for LogfmtError {}
pub struct LogfmtParser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> LogfmtParser<'a> {
pub fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
fn skip_whitespace(&mut self) {
while self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b' ' {
self.pos += 1;
}
}
fn parse_bare(&mut self) -> &'a str {
let start = self.pos;
while self.pos < self.input.len() {
match self.input.as_bytes()[self.pos] {
b'=' | b' ' | b'"' => break,
_ => self.pos += 1,
}
}
&self.input[start..self.pos]
}
fn parse_quoted(&mut self) -> Result<&'a str, LogfmtError> {
debug_assert_eq!(self.input.as_bytes()[self.pos], b'"');
self.pos += 1; let start = self.pos;
loop {
if self.pos >= self.input.len() {
return Err(LogfmtError {
position: self.pos,
message: "unterminated quoted string",
});
}
match self.input.as_bytes()[self.pos] {
b'"' => {
let slice = &self.input[start..self.pos];
self.pos += 1; return Ok(slice);
}
b'\\' => {
self.pos += 1; if self.pos >= self.input.len() {
return Err(LogfmtError {
position: self.pos,
message: "unterminated escape sequence",
});
}
self.pos += 1; }
_ => self.pos += 1,
}
}
}
}
impl<'a> Iterator for LogfmtParser<'a> {
type Item = Result<(&'a str, &'a str), LogfmtError>;
fn next(&mut self) -> Option<Self::Item> {
self.skip_whitespace();
if self.pos >= self.input.len() {
return None;
}
let key = self.parse_bare();
if key.is_empty() {
return Some(Err(LogfmtError {
position: self.pos,
message: "expected key",
}));
}
if self.pos >= self.input.len() || self.input.as_bytes()[self.pos] != b'=' {
return Some(Ok((key, "")));
}
self.pos += 1;
if self.pos < self.input.len() && self.input.as_bytes()[self.pos] == b'"' {
Some(self.parse_quoted().map(|v| (key, v)))
} else {
Some(Ok((key, self.parse_bare())))
}
}
}
pub fn parse_to_map(input: &str) -> Result<HashMap<String, String>, LogfmtError> {
let mut map = HashMap::new();
let mut pos = 0usize;
let skip_ws = |p: &mut usize| {
while *p < input.len() && input.as_bytes()[*p] == b' ' {
*p += 1;
}
};
let parse_bare_owned = |p: &mut usize| -> String {
let start = *p;
while *p < input.len() {
match input.as_bytes()[*p] {
b'=' | b' ' | b'"' => break,
_ => *p += 1,
}
}
input[start..*p].to_owned()
};
loop {
skip_ws(&mut pos);
if pos >= input.len() {
break;
}
let key = parse_bare_owned(&mut pos);
if key.is_empty() {
return Err(LogfmtError {
position: pos,
message: "expected key",
});
}
if pos >= input.len() || input.as_bytes()[pos] != b'=' {
map.insert(key, String::new());
continue;
}
pos += 1;
let value = if pos < input.len() && input.as_bytes()[pos] == b'"' {
pos += 1; let mut val = String::new();
loop {
if pos >= input.len() {
return Err(LogfmtError {
position: pos,
message: "unterminated quoted string",
});
}
let ch = input[pos..].chars().next().unwrap();
let ch_len = ch.len_utf8();
if ch == '"' {
pos += ch_len;
break;
} else if ch == '\\' {
pos += ch_len;
if pos >= input.len() {
return Err(LogfmtError {
position: pos,
message: "unterminated escape sequence",
});
}
let esc = input[pos..].chars().next().unwrap();
let esc_len = esc.len_utf8();
match esc {
'"' => val.push('"'),
'\\' => val.push('\\'),
'n' => val.push('\n'),
't' => val.push('\t'),
other => {
val.push('\\');
val.push(other);
}
}
pos += esc_len;
} else {
val.push(ch);
pos += ch_len;
}
}
val
} else {
parse_bare_owned(&mut pos)
};
map.insert(key, value);
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_pairs() {
let pairs: Vec<_> = LogfmtParser::new("a=1 b=2")
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(pairs, vec![("a", "1"), ("b", "2")]);
}
#[test]
fn quoted_value() {
let pairs: Vec<_> = LogfmtParser::new(r#"msg="hello world""#)
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(pairs[0].0, "msg");
assert_eq!(pairs[0].1, "hello world");
}
#[test]
fn bare_key_no_value() {
let pairs: Vec<_> = LogfmtParser::new("flag").collect::<Result<_, _>>().unwrap();
assert_eq!(pairs, vec![("flag", "")]);
}
#[test]
fn mixed() {
let pairs: Vec<_> = LogfmtParser::new(r#"level=info flag msg="ok" count=3"#)
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(pairs[0], ("level", "info"));
assert_eq!(pairs[1], ("flag", ""));
assert_eq!(pairs[2], ("msg", "ok"));
assert_eq!(pairs[3], ("count", "3"));
}
#[test]
fn empty_input() {
let pairs: Vec<_> = LogfmtParser::new("").collect::<Result<_, _>>().unwrap();
assert!(pairs.is_empty());
}
#[test]
fn unterminated_quote_error() {
let result: Result<Vec<_>, _> = LogfmtParser::new(r#"msg="unclosed"#).collect();
assert!(result.is_err());
}
#[test]
fn parse_to_map_escapes() {
let map = parse_to_map(r#"msg="say \"hi\"" path="a\\b""#).unwrap();
assert_eq!(map["msg"], r#"say "hi""#);
assert_eq!(map["path"], r"a\b");
}
#[test]
fn parse_to_map_bare_key() {
let map = parse_to_map("enabled").unwrap();
assert_eq!(map["enabled"], "");
}
#[test]
fn parse_to_map_roundtrip() {
let input = r#"level=error msg="disk full" retries=3"#;
let map = parse_to_map(input).unwrap();
assert_eq!(map["level"], "error");
assert_eq!(map["msg"], "disk full");
assert_eq!(map["retries"], "3");
}
#[test]
fn parse_to_map_multibyte_utf8() {
let map = parse_to_map("msg=\"café ☕ résumé\"").unwrap();
assert_eq!(map["msg"], "café ☕ résumé");
}
#[test]
fn logfmt_parser_multibyte_utf8() {
let pairs: Vec<_> = LogfmtParser::new("msg=\"café ☕\"")
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(pairs[0], ("msg", "café ☕"));
}
}