use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub enum LogCell {
Text(String),
Number(i64),
Float(f64),
Bool(bool),
Null,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedLogLine {
pub raw: String,
pub columns: BTreeMap<String, LogCell>,
}
pub fn parse_json_line(line: &str) -> Option<BTreeMap<String, LogCell>> {
let value: Value = serde_json::from_str(line).ok()?;
let obj = value.as_object()?;
let mut columns = BTreeMap::new();
for (key, val) in obj {
columns.insert(key.clone(), json_to_cell(val));
}
Some(columns)
}
pub fn infer_columns(parsed: &[ParsedLogLine]) -> Vec<String> {
let mut keys = BTreeMap::new();
for line in parsed {
for key in line.columns.keys() {
keys.entry(key.clone()).or_insert(());
}
}
keys.into_keys().collect()
}
fn json_to_cell(value: &Value) -> LogCell {
match value {
Value::String(s) => LogCell::Text(s.clone()),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
LogCell::Number(i)
} else {
LogCell::Float(n.as_f64().unwrap_or(0.0))
}
}
Value::Bool(b) => LogCell::Bool(*b),
Value::Null => LogCell::Null,
other => LogCell::Text(other.to_string()),
}
}
pub fn parse_log_stream(lines: impl IntoIterator<Item = String>) -> Vec<ParsedLogLine> {
lines
.into_iter()
.map(|raw| {
let columns = parse_json_line(&raw).unwrap_or_default();
ParsedLogLine { raw, columns }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_line_parses_typed_columns() {
let parsed =
parse_json_line(r#"{"level":"info","ts":123,"ratio":0.5,"ok":true,"meta":null}"#)
.unwrap();
assert_eq!(parsed["level"], LogCell::Text("info".into()));
assert_eq!(parsed["ts"], LogCell::Number(123));
assert_eq!(parsed["ratio"], LogCell::Float(0.5));
assert_eq!(parsed["ok"], LogCell::Bool(true));
assert_eq!(parsed["meta"], LogCell::Null);
}
#[test]
fn non_json_line_returns_none() {
assert!(parse_json_line("plain text, not json").is_none());
assert!(parse_json_line("{not json}").is_none());
}
#[test]
fn infer_columns_union_across_lines() {
let lines = parse_log_stream(vec![
r#"{"a":1,"b":"x"}"#.to_string(),
r#"{"b":"y","c":true}"#.to_string(),
"plain line".to_string(),
]);
let cols = infer_columns(&lines);
assert_eq!(cols, vec!["a", "b", "c"]);
}
#[test]
fn nested_json_collapses_to_text() {
let parsed = parse_json_line(r#"{"obj":{"x":1}}"#).unwrap();
assert!(matches!(parsed["obj"], LogCell::Text(_)));
}
}