Skip to main content

kaptein_viewmodel/
logparse.rs

1//! Log-line parsing — "JSON → columns" (M1.2).
2//!
3//! Multi-pod/multi-container log streaming with a regex filter lands the raw line; this
4//! module turns **structured** (JSON) log lines into typed columns so a frontend can show
5//! a real table instead of a monochrome string. It is renderer-agnostic: the view-model
6//! owns *meaning* (which columns exist and their typed values); the frontend owns
7//! *geometry* (column widths, truncation).
8//!
9//! The schema is **inferred** from the first JSON line's keys (stable, first-seen order).
10//! Non-JSON lines are returned as a single `_raw` column. This is deliberately cheap —
11//! the full OpenAPI/CRD schema validation lands in Phase 2 with the lens engine.
12
13use serde_json::Value;
14use std::collections::BTreeMap;
15
16/// A single typed log cell.
17#[derive(Debug, Clone, PartialEq)]
18pub enum LogCell {
19    Text(String),
20    Number(i64),
21    Float(f64),
22    Bool(bool),
23    Null,
24}
25
26/// One parsed log row: the raw line plus a typed column map (empty for non-JSON lines).
27#[derive(Debug, Clone, PartialEq)]
28pub struct ParsedLogLine {
29    /// The original (unmodified) line.
30    pub raw: String,
31    /// Typed columns for JSON lines; empty for plain-text lines.
32    pub columns: BTreeMap<String, LogCell>,
33}
34
35/// Parse a JSON log line into typed columns. Returns `None` for non-JSON lines (the
36/// caller keeps the raw line for a `_raw` column).
37pub 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
47/// The inferred column schema for a set of parsed lines (first-seen key order is stable
48/// because `BTreeMap` sorts keys).
49pub 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        // Nested objects/arrays collapse to their JSON representation as a text cell.
72        other => LogCell::Text(other.to_string()),
73    }
74}
75
76/// Parse a stream of log lines: JSON lines become typed columns, plain lines keep only
77/// the raw text.
78pub 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}