ant_core/node/daemon/forward/
parse.rs1use serde::{Deserialize, Serialize};
19
20use super::config::LogLevel;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct LogEvent {
25 pub timestamp: String,
27 pub level: LogLevel,
28 pub target: Option<String>,
30 pub message: String,
32 pub peer_id: Option<String>,
34 pub version: Option<String>,
36 pub commit: Option<String>,
38}
39
40impl LogEvent {
41 pub fn push_continuation(&mut self, line: &str) {
43 self.message.push('\n');
44 self.message.push_str(line);
45 }
46
47 #[must_use]
53 pub fn index_date(&self) -> Option<String> {
54 index_date_from_timestamp(&self.timestamp)
55 }
56}
57
58#[must_use]
60pub fn index_date_from_timestamp(timestamp: &str) -> Option<String> {
61 let date = timestamp.get(..10)?;
62 let bytes = date.as_bytes();
63 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
64 return None;
65 }
66 if !bytes
67 .iter()
68 .enumerate()
69 .all(|(i, b)| matches!(i, 4 | 7) || b.is_ascii_digit())
70 {
71 return None;
72 }
73 Some(format!("{}.{}.{}", &date[..4], &date[5..7], &date[8..10]))
74}
75
76#[must_use]
78pub fn parse_line(line: &str) -> Option<LogEvent> {
79 let trimmed = line.trim_end_matches(['\r', '\n']);
80 if trimmed.trim().is_empty() {
81 return None;
82 }
83 if trimmed.trim_start().starts_with('{') {
84 parse_json_line(trimmed)
85 } else {
86 parse_text_line(trimmed)
87 }
88}
89
90fn parse_json_line(line: &str) -> Option<LogEvent> {
95 let value: serde_json::Value = serde_json::from_str(line).ok()?;
96 let object = value.as_object()?;
97
98 let level = LogLevel::parse(object.get("level")?.as_str()?)?;
99 let timestamp = object.get("timestamp")?.as_str()?.to_string();
100 index_date_from_timestamp(×tamp)?;
103
104 let message = object
105 .get("message")
106 .and_then(|m| {
107 m.as_str()
108 .map(str::to_string)
109 .or_else(|| Some(m.to_string()))
110 })
111 .unwrap_or_default();
112
113 Some(LogEvent {
114 timestamp,
115 level,
116 target: object
117 .get("target")
118 .and_then(|t| t.as_str())
119 .map(str::to_string),
120 message,
121 peer_id: json_string_field(object, "peer_id"),
122 version: json_string_field(object, "version"),
123 commit: json_string_field(object, "commit"),
124 })
125}
126
127fn json_string_field(
129 object: &serde_json::Map<String, serde_json::Value>,
130 key: &str,
131) -> Option<String> {
132 match object.get(key)? {
133 serde_json::Value::String(s) => Some(s.clone()),
134 serde_json::Value::Null => None,
135 other => Some(other.to_string()),
136 }
137}
138
139fn parse_text_line(line: &str) -> Option<LogEvent> {
146 let mut parts = line.splitn(2, char::is_whitespace);
147 let timestamp = parts.next()?.to_string();
148 index_date_from_timestamp(×tamp)?;
151
152 let rest = parts.next()?.trim_start();
153 let (level_token, rest) = rest.split_once(char::is_whitespace)?;
154 let level = LogLevel::parse(level_token)?;
155
156 let (target, message) = split_target_and_message(rest.trim_start());
157
158 Some(LogEvent {
159 timestamp,
160 level,
161 target,
162 peer_id: scan_field(line, "peer_id"),
163 version: scan_field(line, "version"),
164 commit: scan_field(line, "commit"),
165 message,
166 })
167}
168
169fn split_target_and_message(rest: &str) -> (Option<String>, String) {
170 let mut remaining = rest;
171 let mut target = None;
172
173 while let Some(index) = remaining.find(": ") {
174 let head = &remaining[..index];
175 if !looks_like_span_or_target(head) {
176 break;
177 }
178 if looks_like_target(head) {
179 target = Some(head.to_string());
180 }
181 remaining = &remaining[index + 2..];
182 }
183
184 (target, remaining.to_string())
185}
186
187fn looks_like_span_or_target(candidate: &str) -> bool {
189 !candidate.is_empty() && !candidate.contains(char::is_whitespace)
190}
191
192fn looks_like_target(candidate: &str) -> bool {
194 !candidate.is_empty()
195 && candidate
196 .chars()
197 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
198}
199
200fn scan_field(line: &str, key: &str) -> Option<String> {
206 let needle = format!("{key}=");
207 let mut search_from = 0;
208
209 while let Some(offset) = line[search_from..].find(&needle) {
210 let start = search_from + offset;
211 let preceded_by_boundary = start == 0
212 || line[..start]
213 .chars()
214 .next_back()
215 .is_some_and(char::is_whitespace);
216
217 if preceded_by_boundary {
218 let value = &line[start + needle.len()..];
219 let value = value
220 .split_whitespace()
221 .next()
222 .unwrap_or_default()
223 .trim_end_matches(',')
224 .trim_matches('"');
225 if !value.is_empty() {
226 return Some(value.to_string());
227 }
228 }
229 search_from = start + needle.len();
230 }
231
232 None
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 const TEXT_LINE: &str =
240 "2026-08-19T20:50:00.123456Z INFO ant_node::node: connected to the network peers=3";
241
242 #[test]
243 fn parses_the_text_layout() {
244 let event = parse_line(TEXT_LINE).unwrap();
245 assert_eq!(event.timestamp, "2026-08-19T20:50:00.123456Z");
246 assert_eq!(event.level, LogLevel::Info);
247 assert_eq!(event.target.as_deref(), Some("ant_node::node"));
248 assert_eq!(event.message, "connected to the network peers=3");
249 }
250
251 #[test]
252 fn parses_the_json_layout() {
253 let line = r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"WARN","target":"ant_node::node","message":"peer unreachable","peer_id":"12D3KooWabc"}"#;
254 let event = parse_line(line).unwrap();
255 assert_eq!(event.timestamp, "2026-08-19T20:50:00.123456Z");
256 assert_eq!(event.level, LogLevel::Warn);
257 assert_eq!(event.target.as_deref(), Some("ant_node::node"));
258 assert_eq!(event.message, "peer unreachable");
259 assert_eq!(event.peer_id.as_deref(), Some("12D3KooWabc"));
260 }
261
262 #[test]
263 fn every_level_round_trips_through_the_text_layout() {
264 for (token, expected) in [
265 ("TRACE", LogLevel::Trace),
266 ("DEBUG", LogLevel::Debug),
267 ("INFO", LogLevel::Info),
268 ("WARN", LogLevel::Warn),
269 ("ERROR", LogLevel::Error),
270 ] {
271 let line = format!("2026-08-19T20:50:00.123456Z {token} ant_node: hello");
272 assert_eq!(parse_line(&line).unwrap().level, expected, "{token}");
273 }
274 }
275
276 #[test]
279 fn tolerates_the_level_column_padding() {
280 let padded = "2026-08-19T20:50:00.123456Z INFO ant_node: hello";
281 let unpadded = "2026-08-19T20:50:00.123456Z ERROR ant_node: hello";
282 assert_eq!(parse_line(padded).unwrap().level, LogLevel::Info);
283 assert_eq!(parse_line(unpadded).unwrap().level, LogLevel::Error);
284 }
285
286 #[test]
288 fn a_colon_in_the_message_does_not_become_a_target() {
289 let line = "2026-08-19T20:50:00.123456Z INFO ant_node: dialing peer: 12D3KooWabc";
290 let event = parse_line(line).unwrap();
291 assert_eq!(event.target.as_deref(), Some("ant_node"));
292 assert_eq!(event.message, "dialing peer: 12D3KooWabc");
293 }
294
295 #[test]
296 fn a_span_scope_before_the_target_is_skipped() {
297 let line = "2026-08-19T20:50:00.123456Z INFO upload{id=1}: ant_node::store: stored chunk";
298 let event = parse_line(line).unwrap();
299 assert_eq!(event.target.as_deref(), Some("ant_node::store"));
300 assert_eq!(event.message, "stored chunk");
301 }
302
303 #[test]
304 fn a_message_with_no_target_still_parses() {
305 let line = "2026-08-19T20:50:00.123456Z INFO started with no target at all";
306 let event = parse_line(line).unwrap();
307 assert_eq!(event.target, None);
308 assert_eq!(event.message, "started with no target at all");
309 }
310
311 #[test]
312 fn lines_without_a_timestamp_are_continuations() {
313 assert!(parse_line(" at src/node.rs:42").is_none());
314 assert!(parse_line("thread 'main' panicked").is_none());
315 assert!(parse_line("").is_none());
316 assert!(parse_line(" ").is_none());
317 }
318
319 #[test]
320 fn a_line_with_an_unknown_level_is_treated_as_a_continuation() {
321 let line = "2026-08-19T20:50:00.123456Z NOISE ant_node: hello";
322 assert!(parse_line(line).is_none());
323 }
324
325 #[test]
326 fn malformed_json_is_treated_as_a_continuation_rather_than_guessed_at() {
327 assert!(parse_line(r#"{"level":"INFO""#).is_none());
328 assert!(parse_line(r#"{"level":"INFO","target":"x"}"#).is_none());
329 }
330
331 #[test]
333 fn a_json_line_with_an_unusable_timestamp_is_rejected() {
334 let line = r#"{"timestamp":"not-a-date","level":"INFO","message":"m"}"#;
335 assert!(parse_line(line).is_none());
336 }
337
338 #[test]
339 fn every_parsed_event_can_name_its_index() {
340 for line in [
341 TEXT_LINE,
342 r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"INFO","message":"m"}"#,
343 ] {
344 assert!(parse_line(line).unwrap().index_date().is_some(), "{line}");
345 }
346 }
347
348 #[test]
349 fn continuations_are_appended_to_the_event_above_them() {
350 let mut event = parse_line(TEXT_LINE).unwrap();
351 event.push_continuation("thread 'main' panicked");
352 event.push_continuation(" at src/node.rs:42");
353 assert_eq!(
354 event.message,
355 "connected to the network peers=3\nthread 'main' panicked\n at src/node.rs:42"
356 );
357 }
358
359 #[test]
360 fn lifts_peer_id_version_and_commit_from_a_text_line() {
361 let line = "2026-08-19T20:50:00.123456Z INFO ant_node: starting version=0.17.2 commit=abc1234 peer_id=12D3KooWabc";
362 let event = parse_line(line).unwrap();
363 assert_eq!(event.version.as_deref(), Some("0.17.2"));
364 assert_eq!(event.commit.as_deref(), Some("abc1234"));
365 assert_eq!(event.peer_id.as_deref(), Some("12D3KooWabc"));
366 }
367
368 #[test]
369 fn field_scanning_ignores_a_key_that_is_only_a_suffix_of_another() {
370 let line = "2026-08-19T20:50:00.123456Z INFO ant_node: hello node_version=9.9.9";
371 assert_eq!(parse_line(line).unwrap().version, None);
372 }
373
374 #[test]
375 fn field_scanning_strips_quotes_and_trailing_commas() {
376 let line = r#"2026-08-19T20:50:00.123456Z INFO ant_node: hello peer_id="12D3KooWabc","#;
377 assert_eq!(
378 parse_line(line).unwrap().peer_id.as_deref(),
379 Some("12D3KooWabc")
380 );
381 }
382
383 #[test]
384 fn a_json_field_logged_as_a_number_still_reads_as_a_string() {
385 let line = r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"INFO","message":"m","peer_id":42}"#;
386 assert_eq!(parse_line(line).unwrap().peer_id.as_deref(), Some("42"));
387 }
388
389 #[test]
390 fn index_date_is_derived_from_the_events_own_timestamp() {
391 let event = parse_line(TEXT_LINE).unwrap();
392 assert_eq!(event.index_date().as_deref(), Some("2026.08.19"));
393 }
394
395 #[test]
396 fn index_date_rejects_a_timestamp_it_cannot_trust() {
397 assert_eq!(index_date_from_timestamp("nonsense"), None);
398 assert_eq!(index_date_from_timestamp("2026/08/19T00:00:00Z"), None);
399 assert_eq!(index_date_from_timestamp("20xx-08-19T00:00:00Z"), None);
400 assert_eq!(index_date_from_timestamp("2026-08"), None);
401 }
402
403 #[test]
404 fn trailing_newlines_are_stripped_from_the_message() {
405 let event = parse_line(&format!("{TEXT_LINE}\r\n")).unwrap();
406 assert_eq!(event.message, "connected to the network peers=3");
407 }
408}