Skip to main content

logdive_core/
entry.rs

1//! The `LogEntry` type — a single parsed log line.
2//!
3//! This is the canonical in-memory representation used by every layer of
4//! logdive: the parser produces `LogEntry` values, the indexer consumes them,
5//! and the executor returns them from queries.
6//!
7//! Field layout follows the hybrid storage decision in the project doc
8//! (decisions log, 2026-04-19): the four "known" fields (`timestamp`, `level`,
9//! `message`, `tag`) are first-class members that map to indexed SQLite
10//! columns, while everything else lives in the `fields` map and is persisted
11//! as a JSON blob queried via `json_extract()`.
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16/// A single structured log entry.
17///
18/// All four known fields are `Option<String>` because a JSON line may omit
19/// any of them and still be worth indexing — the parser's contract per the
20/// milestone 1 spec is "missing optional fields use `None` not panic".
21///
22/// `fields` holds only keys that are *not* among the known four. The parser
23/// is responsible for lifting known keys out of the JSON object and into
24/// their dedicated fields before populating this map.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct LogEntry {
27    /// The entry's timestamp as it appeared in the source line. Stored as a
28    /// string rather than a parsed `DateTime` so unusual formats survive
29    /// ingestion; time-range filtering is applied at query time.
30    pub timestamp: Option<String>,
31
32    /// Log severity ("error", "warn", "info", ...). Indexed column.
33    pub level: Option<String>,
34
35    /// The human-readable message body.
36    pub message: Option<String>,
37
38    /// Optional source tag supplied at ingestion time (the `--tag` CLI flag,
39    /// per the "Decisions log" entry on optional tags). `None` means
40    /// "untagged" and such entries match queries without a tag filter.
41    pub tag: Option<String>,
42
43    /// Arbitrary additional keys from the JSON object. Serialized to the
44    /// `fields TEXT` column and queried via SQLite's `json_extract()`.
45    pub fields: Map<String, Value>,
46
47    /// The original, unmodified source line. Feeds both the `raw` column
48    /// and the `blake3`-based dedup hash.
49    pub raw: String,
50}
51
52impl LogEntry {
53    /// The set of top-level JSON keys that are promoted to first-class
54    /// struct fields during parsing. Any key *not* in this set is preserved
55    /// in [`LogEntry::fields`].
56    ///
57    /// Kept in a single place so the parser and any future schema-related
58    /// code agree on which keys are "known".
59    pub const KNOWN_KEYS: &'static [&'static str] = &["timestamp", "level", "message", "tag"];
60
61    /// Construct a new entry from the raw source line, with all optional
62    /// fields unset and an empty `fields` map. The parser uses this as a
63    /// starting point and fills in the pieces it finds.
64    pub fn new(raw: impl Into<String>) -> Self {
65        Self {
66            timestamp: None,
67            level: None,
68            message: None,
69            tag: None,
70            fields: Map::new(),
71            raw: raw.into(),
72        }
73    }
74
75    /// Override the tag. Used by the indexer to apply the `--tag` flag
76    /// supplied at ingestion time: if the source JSON did not carry its own
77    /// `tag`, the CLI-provided one is substituted here.
78    pub fn with_tag(mut self, tag: Option<String>) -> Self {
79        if tag.is_some() {
80            self.tag = tag;
81        }
82        self
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn new_entry_has_no_known_fields_and_empty_map() {
92        let e = LogEntry::new("hello");
93        assert_eq!(e.raw, "hello");
94        assert!(e.timestamp.is_none());
95        assert!(e.level.is_none());
96        assert!(e.message.is_none());
97        assert!(e.tag.is_none());
98        assert!(e.fields.is_empty());
99    }
100
101    #[test]
102    fn with_tag_sets_when_some() {
103        let e = LogEntry::new("x").with_tag(Some("api".to_string()));
104        assert_eq!(e.tag.as_deref(), Some("api"));
105    }
106
107    #[test]
108    fn with_tag_is_a_noop_when_none() {
109        let e = LogEntry::new("x")
110            .with_tag(Some("first".to_string()))
111            .with_tag(None);
112        // An existing tag is NOT cleared by passing None — None means
113        // "no override supplied", not "clear the tag".
114        assert_eq!(e.tag.as_deref(), Some("first"));
115    }
116
117    #[test]
118    fn known_keys_are_the_four_documented_ones() {
119        // Guards against accidental drift — if someone adds a known field,
120        // this test makes them update both the constant and this assertion.
121        assert_eq!(
122            LogEntry::KNOWN_KEYS,
123            &["timestamp", "level", "message", "tag"]
124        );
125    }
126
127    #[test]
128    fn roundtrips_through_serde_json() {
129        let mut e = LogEntry::new(r#"{"level":"error","service":"pay"}"#);
130        e.level = Some("error".to_string());
131        e.fields
132            .insert("service".to_string(), Value::String("pay".to_string()));
133
134        let s = serde_json::to_string(&e).expect("serialize");
135        let back: LogEntry = serde_json::from_str(&s).expect("deserialize");
136        assert_eq!(e, back);
137    }
138}