diff --git a/crates/core/src/parser/json.rs b/crates/core/src/parser/json.rs index 25d05d0..0e7227b 100644 --- a/crates/core/src/parser/json.rs +++ b/crates/core/src/parser/json.rs @@ -1,8 +1,158 @@ -use crate::types::LogEntry; +use std::collections::HashMap; -pub fn parse_line(_line: &str) -> Option { - todo!() +use serde_json::Value; + +use crate::types::{LogEntry, LogLevel}; + +pub fn detect_json_log(line: &str) -> bool { + matches!(serde_json::from_str::(line), Ok(Value::Object(_))) } -pub fn detect_json_log(_line: &str) -> bool { - todo!() + +pub fn parse_line(line: &str) -> Option { + if line.trim().is_empty() { + return None; + } + + let mut fields: HashMap = serde_json::from_str(line).ok()?; + + let raw_line = line.to_string(); + + let timestamp = ["timestamp", "time", "ts", "@timestamp"] + .iter() + .find_map(|key| fields.remove(*key)) + .and_then(|v| v.as_str().map(String::from)); + + let level = ["level", "lvl", "severity"] + .iter() + .find_map(|key| fields.remove(*key)) + .and_then(|v| v.as_str().map(String::from)) + .map(|s| s.parse::().unwrap()); + + Some(LogEntry { + line_number: 0, + raw_line, + timestamp, + level, + fields, + }) +} + +pub fn parse_line_with_number(line: &str, line_number: u64) -> Option { + parse_line(line).map(|mut entry| { + entry.line_number = line_number; + entry + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_json_log() { + let line = r#"{"level":"INFO","message":"hello","timestamp":"2024-01-01T00:00:00Z"}"#; + let entry = parse_line(line).unwrap(); + assert_eq!(entry.raw_line, line); + assert_eq!(entry.timestamp, Some("2024-01-01T00:00:00Z".to_string())); + assert_eq!(entry.level, Some(LogLevel::Info)); + assert_eq!( + entry.fields.get("message"), + Some(&Value::String("hello".to_string())) + ); + assert_eq!(entry.line_number, 0); + } + + #[test] + fn test_json_missing_fields() { + let line = r#"{"message":"just a message"}"#; + let entry = parse_line(line).unwrap(); + assert!(entry.timestamp.is_none()); + assert!(entry.level.is_none()); + assert_eq!( + entry.fields.get("message"), + Some(&Value::String("just a message".to_string())) + ); + } + + #[test] + fn test_plain_text() { + assert!(parse_line("hello world").is_none()); + } + + #[test] + fn test_empty_line() { + assert!(parse_line("").is_none()); + } + + #[test] + fn test_whitespace_line() { + assert!(parse_line(" ").is_none()); + } + + #[test] + fn test_json_array() { + assert!(!detect_json_log("[1,2,3]")); + } + + #[test] + fn test_json_string() { + assert!(!detect_json_log("\"hello\"")); + } + + #[test] + fn test_json_number() { + assert!(!detect_json_log("42")); + } + + #[test] + fn test_json_null() { + assert!(!detect_json_log("null")); + } + + #[test] + fn test_json_bool() { + assert!(!detect_json_log("true")); + } + + #[test] + fn test_level_variants() { + let info_line = r#"{"level":"info"}"#; + assert_eq!(parse_line(info_line).unwrap().level, Some(LogLevel::Info)); + + let error_line = r#"{"level":"ERROR"}"#; + assert_eq!(parse_line(error_line).unwrap().level, Some(LogLevel::Error)); + + let warn_line = r#"{"level":"warn"}"#; + assert_eq!(parse_line(warn_line).unwrap().level, Some(LogLevel::Warn)); + } + + #[test] + fn test_timestamp_key_names() { + for key in &["timestamp", "time", "ts", "@timestamp"] { + let line = format!(r#"{{"{key}":"2024-01-01T00:00:00Z"}}"#); + let entry = parse_line(&line).unwrap(); + assert_eq!( + entry.timestamp, + Some("2024-01-01T00:00:00Z".to_string()), + "failed for key: {key}" + ); + } + } + + #[test] + fn test_parse_line_with_number() { + let line = r#"{"level":"DEBUG","message":"test"}"#; + let entry = parse_line_with_number(line, 42).unwrap(); + assert_eq!(entry.line_number, 42); + assert_eq!(entry.level, Some(LogLevel::Debug)); + } + + #[test] + fn test_level_key_variants() { + for key in &["level", "lvl", "severity"] { + let line = format!(r#"{{"{key}":"INFO"}}"#); + let entry = parse_line(&line).unwrap(); + assert_eq!(entry.level, Some(LogLevel::Info), "failed for key: {key}"); + } + } }