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}