kaptein_viewmodel/
logparse.rs1use serde_json::Value;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, PartialEq)]
18pub enum LogCell {
19 Text(String),
20 Number(i64),
21 Float(f64),
22 Bool(bool),
23 Null,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct ParsedLogLine {
29 pub raw: String,
31 pub columns: BTreeMap<String, LogCell>,
33}
34
35pub fn parse_json_line(line: &str) -> Option<BTreeMap<String, LogCell>> {
38 let value: Value = serde_json::from_str(line).ok()?;
39 let obj = value.as_object()?;
40 let mut columns = BTreeMap::new();
41 for (key, val) in obj {
42 columns.insert(key.clone(), json_to_cell(val));
43 }
44 Some(columns)
45}
46
47pub fn infer_columns(parsed: &[ParsedLogLine]) -> Vec<String> {
50 let mut keys = BTreeMap::new();
51 for line in parsed {
52 for key in line.columns.keys() {
53 keys.entry(key.clone()).or_insert(());
54 }
55 }
56 keys.into_keys().collect()
57}
58
59fn json_to_cell(value: &Value) -> LogCell {
60 match value {
61 Value::String(s) => LogCell::Text(s.clone()),
62 Value::Number(n) => {
63 if let Some(i) = n.as_i64() {
64 LogCell::Number(i)
65 } else {
66 LogCell::Float(n.as_f64().unwrap_or(0.0))
67 }
68 }
69 Value::Bool(b) => LogCell::Bool(*b),
70 Value::Null => LogCell::Null,
71 other => LogCell::Text(other.to_string()),
73 }
74}
75
76pub fn parse_log_stream(lines: impl IntoIterator<Item = String>) -> Vec<ParsedLogLine> {
79 lines
80 .into_iter()
81 .map(|raw| {
82 let columns = parse_json_line(&raw).unwrap_or_default();
83 ParsedLogLine { raw, columns }
84 })
85 .collect()
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn json_line_parses_typed_columns() {
94 let parsed =
95 parse_json_line(r#"{"level":"info","ts":123,"ratio":0.5,"ok":true,"meta":null}"#)
96 .unwrap();
97 assert_eq!(parsed["level"], LogCell::Text("info".into()));
98 assert_eq!(parsed["ts"], LogCell::Number(123));
99 assert_eq!(parsed["ratio"], LogCell::Float(0.5));
100 assert_eq!(parsed["ok"], LogCell::Bool(true));
101 assert_eq!(parsed["meta"], LogCell::Null);
102 }
103
104 #[test]
105 fn non_json_line_returns_none() {
106 assert!(parse_json_line("plain text, not json").is_none());
107 assert!(parse_json_line("{not json}").is_none());
108 }
109
110 #[test]
111 fn infer_columns_union_across_lines() {
112 let lines = parse_log_stream(vec![
113 r#"{"a":1,"b":"x"}"#.to_string(),
114 r#"{"b":"y","c":true}"#.to_string(),
115 "plain line".to_string(),
116 ]);
117 let cols = infer_columns(&lines);
118 assert_eq!(cols, vec!["a", "b", "c"]);
119 }
120
121 #[test]
122 fn nested_json_collapses_to_text() {
123 let parsed = parse_json_line(r#"{"obj":{"x":1}}"#).unwrap();
124 assert!(matches!(parsed["obj"], LogCell::Text(_)));
125 }
126}