use serde_json::Value;
use crate::entry::LogEntry;
pub fn parse_line(line: &str) -> Option<LogEntry> {
if line.trim().is_empty() {
return None;
}
let pairs = parse_pairs(line)?;
if pairs.is_empty() {
return None;
}
let mut entry = LogEntry::new(line);
for (key, value) in pairs {
match key.as_str() {
"timestamp" => entry.timestamp = Some(value),
"level" => entry.level = Some(value),
"message" => entry.message = Some(value),
"tag" => entry.tag = Some(value),
_ => {
entry.fields.insert(key, Value::String(value));
}
}
}
Some(entry)
}
fn parse_pairs(line: &str) -> Option<Vec<(String, String)>> {
let bytes = line.as_bytes();
let mut i = 0;
let mut pairs: Vec<(String, String)> = Vec::new();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if !is_key_start(bytes[i]) {
i = skip_to_whitespace(bytes, i);
continue;
}
let key_start = i;
while i < bytes.len() && is_key_continue(bytes[i]) {
i += 1;
}
let key = std::str::from_utf8(&bytes[key_start..i])
.expect("key is ASCII")
.to_string();
if i >= bytes.len() || bytes[i].is_ascii_whitespace() {
pairs.push((key, "true".to_string()));
continue;
}
if bytes[i] != b'=' {
i = skip_to_whitespace(bytes, i);
continue;
}
i += 1;
if i >= bytes.len() || bytes[i].is_ascii_whitespace() {
pairs.push((key, String::new()));
continue;
}
if bytes[i] == b'"' {
i += 1; let value = read_quoted_value(bytes, &mut i)?;
pairs.push((key, value));
} else {
let value_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let value = std::str::from_utf8(&bytes[value_start..i])
.expect("UTF-8 input slice along ASCII boundaries is UTF-8")
.to_string();
pairs.push((key, value));
}
}
Some(pairs)
}
fn read_quoted_value(bytes: &[u8], i: &mut usize) -> Option<String> {
let mut buf: Vec<u8> = Vec::new();
while *i < bytes.len() {
let c = bytes[*i];
match c {
b'"' => {
*i += 1; return Some(String::from_utf8(buf).expect("UTF-8 boundary preserved"));
}
b'\\' => {
if *i + 1 >= bytes.len() {
return None;
}
let next = bytes[*i + 1];
if next == b'"' || next == b'\\' {
buf.push(next);
} else {
buf.push(b'\\');
buf.push(next);
}
*i += 2;
}
_ => {
buf.push(c);
*i += 1;
}
}
}
None
}
fn skip_to_whitespace(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
i
}
fn is_key_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_key_continue(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fields_get<'a>(e: &'a LogEntry, key: &str) -> Option<&'a Value> {
e.fields.get(key)
}
#[test]
fn single_pair_with_bare_value() {
let e = parse_line("level=info").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert!(e.fields.is_empty());
}
#[test]
fn multiple_pairs_separated_by_space() {
let e = parse_line("level=info service=payments req_id=42").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert_eq!(fields_get(&e, "service"), Some(&json!("payments")));
assert_eq!(fields_get(&e, "req_id"), Some(&json!("42")));
}
#[test]
fn multiple_spaces_between_pairs_are_tolerated() {
let e = parse_line("level=info service=payments").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert_eq!(fields_get(&e, "service"), Some(&json!("payments")));
}
#[test]
fn tabs_separate_pairs() {
let e = parse_line("level=info\tservice=payments").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert_eq!(fields_get(&e, "service"), Some(&json!("payments")));
}
#[test]
fn leading_whitespace_is_skipped() {
let e = parse_line(" level=info").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
}
#[test]
fn quoted_value_with_spaces_preserved() {
let e = parse_line(r#"message="hello world""#).expect("should parse");
assert_eq!(e.message.as_deref(), Some("hello world"));
}
#[test]
fn quoted_value_with_escaped_quote() {
let e = parse_line(r#"message="say \"hi\"""#).expect("should parse");
assert_eq!(e.message.as_deref(), Some(r#"say "hi""#));
}
#[test]
fn quoted_value_with_escaped_backslash() {
let e = parse_line(r#"path="C:\\Users""#).expect("should parse");
assert_eq!(fields_get(&e, "path"), Some(&json!(r"C:\Users")));
}
#[test]
fn quoted_value_with_unknown_escape_kept_literal() {
let e = parse_line(r#"message="line\nbreak""#).expect("should parse");
assert_eq!(e.message.as_deref(), Some(r"line\nbreak"));
}
#[test]
fn quoted_value_can_contain_equals_signs() {
let e = parse_line(r#"query="SELECT * WHERE k=v""#).expect("should parse");
assert_eq!(fields_get(&e, "query"), Some(&json!("SELECT * WHERE k=v")));
}
#[test]
fn empty_quoted_value() {
let e = parse_line(r#"key="""#).expect("should parse");
assert_eq!(fields_get(&e, "key"), Some(&json!("")));
}
#[test]
fn bareword_boolean_alone_becomes_true() {
let e = parse_line("debug").expect("should parse");
assert_eq!(fields_get(&e, "debug"), Some(&json!("true")));
}
#[test]
fn bareword_boolean_mixed_with_kv_pairs() {
let e = parse_line("level=info debug service=api").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert_eq!(fields_get(&e, "debug"), Some(&json!("true")));
assert_eq!(fields_get(&e, "service"), Some(&json!("api")));
}
#[test]
fn multiple_bareword_booleans() {
let e = parse_line("debug verbose dry_run").expect("should parse");
assert_eq!(fields_get(&e, "debug"), Some(&json!("true")));
assert_eq!(fields_get(&e, "verbose"), Some(&json!("true")));
assert_eq!(fields_get(&e, "dry_run"), Some(&json!("true")));
}
#[test]
fn empty_value_after_equals_is_empty_string_not_true() {
let e = parse_line("key= other=v").expect("should parse");
assert_eq!(fields_get(&e, "key"), Some(&json!("")));
assert_eq!(fields_get(&e, "other"), Some(&json!("v")));
}
#[test]
fn empty_line_returns_none() {
assert!(parse_line("").is_none());
}
#[test]
fn whitespace_only_line_returns_none() {
assert!(parse_line(" \t ").is_none());
}
#[test]
fn line_with_only_unparseable_junk_returns_none() {
assert!(parse_line("123 =foo 456=789").is_none());
}
#[test]
fn junk_token_between_valid_pairs_is_skipped() {
let e = parse_line("level=info 123abc=foo service=api").expect("should parse");
assert_eq!(e.level.as_deref(), Some("info"));
assert_eq!(fields_get(&e, "service"), Some(&json!("api")));
assert!(!e.fields.contains_key("123abc"));
}
#[test]
fn duplicate_unknown_key_last_wins() {
let e = parse_line("status=pending status=failed").expect("should parse");
assert_eq!(fields_get(&e, "status"), Some(&json!("failed")));
}
#[test]
fn duplicate_known_key_last_wins() {
let e = parse_line("level=info level=error").expect("should parse");
assert_eq!(e.level.as_deref(), Some("error"));
}
#[test]
fn timestamp_is_promoted_to_struct_field() {
let e = parse_line("timestamp=2026-04-15T09:00:00Z level=info").expect("should parse");
assert_eq!(e.timestamp.as_deref(), Some("2026-04-15T09:00:00Z"));
assert!(!e.fields.contains_key("timestamp"));
}
#[test]
fn message_is_promoted_when_quoted() {
let e =
parse_line(r#"timestamp=2026-04-15T09:00:00Z message="hello world""#).expect("parse");
assert_eq!(e.message.as_deref(), Some("hello world"));
}
#[test]
fn tag_is_promoted_to_struct_field() {
let e = parse_line("tag=api level=info").expect("should parse");
assert_eq!(e.tag.as_deref(), Some("api"));
assert!(!e.fields.contains_key("tag"));
}
#[test]
fn all_four_known_keys_promoted_together() {
let e = parse_line(
r#"timestamp=2026-04-15T09:00:00Z level=error message="boom" tag=api service=payments"#,
)
.expect("should parse");
assert_eq!(e.timestamp.as_deref(), Some("2026-04-15T09:00:00Z"));
assert_eq!(e.level.as_deref(), Some("error"));
assert_eq!(e.message.as_deref(), Some("boom"));
assert_eq!(e.tag.as_deref(), Some("api"));
assert_eq!(e.fields.len(), 1);
assert_eq!(fields_get(&e, "service"), Some(&json!("payments")));
}
#[test]
fn numeric_looking_value_stays_as_string() {
let e = parse_line("duration_ms=1234").expect("should parse");
assert_eq!(fields_get(&e, "duration_ms"), Some(&json!("1234")));
assert_ne!(fields_get(&e, "duration_ms"), Some(&json!(1234)));
}
#[test]
fn boolean_looking_value_stays_as_string() {
let e = parse_line("ok=true failed=false").expect("should parse");
assert_eq!(fields_get(&e, "ok"), Some(&json!("true")));
assert_eq!(fields_get(&e, "failed"), Some(&json!("false")));
}
#[test]
fn hyphenated_key_is_accepted() {
let e = parse_line("request-id=abc-123 method=GET").expect("should parse");
assert_eq!(fields_get(&e, "request-id"), Some(&json!("abc-123")));
assert_eq!(fields_get(&e, "method"), Some(&json!("GET")));
}
#[test]
fn dotted_key_is_accepted() {
let e = parse_line("user.id=42").expect("should parse");
assert_eq!(fields_get(&e, "user.id"), Some(&json!("42")));
}
#[test]
fn underscore_leading_key_is_accepted() {
let e = parse_line("_internal=true level=info").expect("should parse");
assert_eq!(fields_get(&e, "_internal"), Some(&json!("true")));
assert_eq!(e.level.as_deref(), Some("info"));
}
#[test]
fn unicode_in_bare_value() {
let e = parse_line("city=北京 lang=zh").expect("should parse");
assert_eq!(fields_get(&e, "city"), Some(&json!("北京")));
assert_eq!(fields_get(&e, "lang"), Some(&json!("zh")));
}
#[test]
fn unicode_in_quoted_value() {
let e = parse_line(r#"message="café résumé""#).expect("should parse");
assert_eq!(e.message.as_deref(), Some("café résumé"));
}
#[test]
fn unterminated_quoted_value_drops_line() {
assert!(parse_line(r#"level=info message="oops"#).is_none());
}
#[test]
fn dangling_backslash_at_end_drops_line() {
assert!(parse_line(r#"key="value\"#).is_none());
}
#[test]
fn raw_is_preserved_verbatim() {
let line = " level=info service=payments ";
let e = parse_line(line).expect("should parse");
assert_eq!(e.raw, line);
}
}