1use crate::level::LogLevel;
2
3use std::fmt;
4
5#[non_exhaustive]
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum LineKind {
14 Full,
16 System,
18 UuidContinuation,
20 BareContinuation,
22 Truncated,
24 Empty,
26}
27
28impl fmt::Display for LineKind {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 LineKind::Full => f.pad("full"),
32 LineKind::System => f.pad("system"),
33 LineKind::UuidContinuation => f.pad("uuid-cont"),
34 LineKind::BareContinuation => f.pad("bare-cont"),
35 LineKind::Truncated => f.pad("truncated"),
36 LineKind::Empty => f.pad("empty"),
37 }
38 }
39}
40
41#[derive(Debug, PartialEq, Eq)]
47pub struct RawLine<'a> {
48 pub uuid: Option<&'a str>,
50 pub timestamp: Option<&'a str>,
52 pub idle_pct: Option<&'a str>,
54 pub level: Option<LogLevel>,
56 pub source: Option<&'a str>,
58 pub message: &'a str,
60 pub kind: LineKind,
62}
63
64pub(crate) fn is_uuid_at(bytes: &[u8], offset: usize) -> bool {
65 if bytes.len() < offset + 37 {
66 return false;
67 }
68 if bytes[offset + 36] != b' ' {
69 return false;
70 }
71 for (i, &b) in bytes[offset..offset + 36].iter().enumerate() {
72 match i {
73 8 | 13 | 18 | 23 => {
74 if b != b'-' {
75 return false;
76 }
77 }
78 _ => {
79 if !b.is_ascii_hexdigit() {
80 return false;
81 }
82 }
83 }
84 }
85 true
86}
87
88fn find_uuid_in(bytes: &[u8]) -> Option<usize> {
89 if bytes.len() < 37 {
90 return None;
91 }
92 let max_start = (bytes.len() - 37).min(50);
93 (1..=max_start).find(|&start| is_uuid_at(bytes, start))
94}
95
96pub(crate) fn is_date_at(bytes: &[u8], offset: usize) -> bool {
97 if bytes.len() < offset + 5 {
98 return false;
99 }
100 bytes[offset..offset + 4].iter().all(u8::is_ascii_digit) && bytes[offset + 4] == b'-'
101}
102
103pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
109 if bytes.len() < offset + 31 {
111 return false;
112 }
113 if !(bytes[offset..offset + 4].iter().all(u8::is_ascii_digit)
115 && bytes[offset + 4] == b'-'
116 && bytes[offset + 5..offset + 7].iter().all(u8::is_ascii_digit)
117 && bytes[offset + 7] == b'-'
118 && bytes[offset + 8..offset + 10]
119 .iter()
120 .all(u8::is_ascii_digit)
121 && bytes[offset + 10] == b' '
122 && bytes[offset + 11..offset + 13]
123 .iter()
124 .all(u8::is_ascii_digit)
125 && bytes[offset + 13] == b':'
126 && bytes[offset + 14..offset + 16]
127 .iter()
128 .all(u8::is_ascii_digit)
129 && bytes[offset + 16] == b':'
130 && bytes[offset + 17..offset + 19]
131 .iter()
132 .all(u8::is_ascii_digit)
133 && bytes[offset + 19] == b'.'
134 && bytes[offset + 20..offset + 26]
135 .iter()
136 .all(u8::is_ascii_digit)
137 && bytes[offset + 26] == b' ')
138 {
139 return false;
140 }
141 let rest = &bytes[offset + 27..];
143 if !rest[0].is_ascii_digit() {
144 return false;
145 }
146 let Some(pct_pos) = rest[..rest.len().min(7)].iter().position(|&b| b == b'%') else {
147 return false;
148 };
149 rest.len() > pct_pos + 2 && rest[pct_pos + 1] == b' ' && rest[pct_pos + 2] == b'['
150}
151
152fn parse_idle_pct(rest: &str) -> (Option<&str>, &str) {
161 let bytes = rest.as_bytes();
162 if bytes.is_empty() || !bytes[0].is_ascii_digit() {
163 return (None, rest);
164 }
165 let search_len = rest.len().min(7);
166 let pct_pos = match bytes[..search_len].iter().position(|&b| b == b'%') {
167 Some(p) => p,
168 None => return (None, rest),
169 };
170 if !bytes[..pct_pos]
171 .iter()
172 .all(|&b| b.is_ascii_digit() || b == b'.')
173 {
174 return (None, rest);
175 }
176 let idle_pct = &rest[0..=pct_pos];
177 let after = if rest.len() > pct_pos + 2 {
178 &rest[pct_pos + 2..]
179 } else {
180 ""
181 };
182 (Some(idle_pct), after)
183}
184
185fn parse_timestamped_fields(
186 s: &str,
187) -> (
188 Option<&str>,
189 Option<&str>,
190 Option<LogLevel>,
191 Option<&str>,
192 &str,
193) {
194 if s.len() < 27 {
195 return (None, None, None, None, s);
196 }
197 let timestamp = &s[0..26];
198 let rest = &s[27..];
199
200 let (idle_pct, rest) = parse_idle_pct(rest);
201
202 let bracket_end = match rest.find(']') {
203 Some(p) => p,
204 None => return (Some(timestamp), idle_pct, None, None, rest),
205 };
206 let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
207
208 if rest.len() < bracket_end + 3 {
209 return (Some(timestamp), idle_pct, level, None, "");
210 }
211 let rest = &rest[bracket_end + 2..];
212
213 let source_end = rest.find(' ').unwrap_or(rest.len());
214 let source = &rest[0..source_end];
215 let message = if source_end < rest.len() {
216 &rest[source_end + 1..]
217 } else {
218 ""
219 };
220
221 (Some(timestamp), idle_pct, level, Some(source), message)
222}
223
224pub fn parse_line(line: &str) -> RawLine<'_> {
230 if line.trim().is_empty() {
231 return RawLine {
232 uuid: None,
233 timestamp: None,
234 idle_pct: None,
235 level: None,
236 source: None,
237 message: line,
238 kind: LineKind::Empty,
239 };
240 }
241
242 let bytes = line.as_bytes();
243
244 if is_uuid_at(bytes, 0) {
245 let uuid = &line[0..36];
246 let after_uuid = &line[37..];
247
248 if is_date_at(bytes, 37) {
249 let (timestamp, idle_pct, level, source, message) =
250 parse_timestamped_fields(after_uuid);
251 return RawLine {
252 uuid: Some(uuid),
253 timestamp,
254 idle_pct,
255 level,
256 source,
257 message,
258 kind: LineKind::Full,
259 };
260 }
261
262 return RawLine {
263 uuid: Some(uuid),
264 timestamp: None,
265 idle_pct: None,
266 level: None,
267 source: None,
268 message: after_uuid,
269 kind: LineKind::UuidContinuation,
270 };
271 }
272
273 if is_date_at(bytes, 0) {
274 let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
275 let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
276 (Some(&message[0..36]), &message[37..])
277 } else {
278 (None, message)
279 };
280 return RawLine {
281 uuid,
282 timestamp,
283 idle_pct,
284 level,
285 source,
286 message,
287 kind: LineKind::System,
288 };
289 }
290
291 if let Some(uuid_start) = find_uuid_in(bytes) {
292 let uuid = &line[uuid_start..uuid_start + 36];
293 let message = if line.len() > uuid_start + 37 {
294 &line[uuid_start + 37..]
295 } else {
296 ""
297 };
298 return RawLine {
299 uuid: Some(uuid),
300 timestamp: None,
301 idle_pct: None,
302 level: None,
303 source: None,
304 message,
305 kind: LineKind::Truncated,
306 };
307 }
308
309 RawLine {
310 uuid: None,
311 timestamp: None,
312 idle_pct: None,
313 level: None,
314 source: None,
315 message: line,
316 kind: LineKind::BareContinuation,
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
325
326 #[test]
329 fn full_line_all_fields() {
330 let line = format!(
331 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
332 );
333 let parsed = parse_line(&line);
334 assert_eq!(parsed.kind, LineKind::Full);
335 assert_eq!(parsed.uuid, Some(UUID1));
336 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
337 assert_eq!(parsed.idle_pct, Some("95.97%"));
338 assert_eq!(parsed.level, Some(LogLevel::Debug));
339 assert_eq!(parsed.source, Some("sofia.c:100"));
340 assert_eq!(parsed.message, "Test message here");
341 }
342
343 #[test]
344 fn full_line_each_level() {
345 for (name, expected) in [
346 ("DEBUG", LogLevel::Debug),
347 ("INFO", LogLevel::Info),
348 ("NOTICE", LogLevel::Notice),
349 ("WARNING", LogLevel::Warning),
350 ("ERR", LogLevel::Err),
351 ("CRIT", LogLevel::Crit),
352 ("ALERT", LogLevel::Alert),
353 ("CONSOLE", LogLevel::Console),
354 ] {
355 let line =
356 format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
357 let parsed = parse_line(&line);
358 assert_eq!(parsed.kind, LineKind::Full);
359 assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
360 }
361 }
362
363 #[test]
364 fn full_line_high_idle() {
365 let line =
366 format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
367 let parsed = parse_line(&line);
368 assert_eq!(parsed.idle_pct, Some("99.99%"));
369 }
370
371 #[test]
372 fn full_line_low_idle() {
373 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
374 let parsed = parse_line(&line);
375 assert_eq!(parsed.idle_pct, Some("0.00%"));
376 }
377
378 #[test]
379 fn full_line_long_message() {
380 let line = format!(
381 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
382 );
383 let parsed = parse_line(&line);
384 assert_eq!(
385 parsed.message,
386 "Channel [sofia/internal] key=val:123 (test) {braces}"
387 );
388 }
389
390 #[test]
393 fn system_line_no_uuid() {
394 let line =
395 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
396 let parsed = parse_line(line);
397 assert_eq!(parsed.kind, LineKind::System);
398 assert_eq!(parsed.uuid, None);
399 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
400 assert_eq!(parsed.idle_pct, Some("95.97%"));
401 assert_eq!(parsed.level, Some(LogLevel::Info));
402 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
403 assert_eq!(parsed.message, "Event Socket command");
404 }
405
406 #[test]
407 fn system_line_with_embedded_uuid() {
408 let line = format!(
409 "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
410 );
411 let parsed = parse_line(&line);
412 assert_eq!(parsed.kind, LineKind::System);
413 assert_eq!(parsed.uuid, Some(UUID1));
414 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
415 assert_eq!(parsed.level, Some(LogLevel::Debug));
416 assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
417 assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
418 }
419
420 #[test]
421 fn system_line_with_embedded_uuid_empty_message() {
422 let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
423 let parsed = parse_line(&line);
424 assert_eq!(parsed.kind, LineKind::System);
425 assert_eq!(parsed.uuid, Some(UUID1));
426 assert_eq!(parsed.message, "");
427 }
428
429 #[test]
430 fn system_line_without_embedded_uuid() {
431 let line =
432 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
433 let parsed = parse_line(line);
434 assert_eq!(parsed.kind, LineKind::System);
435 assert_eq!(parsed.uuid, None);
436 assert_eq!(parsed.message, "Event Socket command");
437 }
438
439 #[test]
440 fn system_line_event_socket() {
441 let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
442 let parsed = parse_line(line);
443 assert_eq!(parsed.kind, LineKind::System);
444 assert_eq!(parsed.level, Some(LogLevel::Notice));
445 assert_eq!(parsed.message, "New log started.");
446 }
447
448 #[test]
451 fn uuid_continuation_dialplan() {
452 let line =
453 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
454 let parsed = parse_line(&line);
455 assert_eq!(parsed.kind, LineKind::UuidContinuation);
456 assert_eq!(parsed.uuid, Some(UUID1));
457 assert_eq!(parsed.timestamp, None);
458 assert_eq!(parsed.level, None);
459 assert_eq!(
460 parsed.message,
461 "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
462 );
463 }
464
465 #[test]
466 fn uuid_continuation_execute() {
467 let line =
468 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
469 let parsed = parse_line(&line);
470 assert_eq!(parsed.kind, LineKind::UuidContinuation);
471 assert_eq!(parsed.uuid, Some(UUID1));
472 assert_eq!(
473 parsed.message,
474 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
475 );
476 }
477
478 #[test]
479 fn uuid_continuation_channel_var() {
480 let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
481 let parsed = parse_line(&line);
482 assert_eq!(parsed.kind, LineKind::UuidContinuation);
483 assert_eq!(parsed.uuid, Some(UUID1));
484 assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
485 }
486
487 #[test]
488 fn uuid_continuation_variable() {
489 let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
490 let parsed = parse_line(&line);
491 assert_eq!(parsed.kind, LineKind::UuidContinuation);
492 assert_eq!(parsed.uuid, Some(UUID1));
493 assert_eq!(parsed.message, "variable_sip_call_id: [test123@192.0.2.1]");
494 }
495
496 #[test]
497 fn uuid_continuation_blank() {
498 let line = format!("{UUID1} ");
499 let parsed = parse_line(&line);
500 assert_eq!(parsed.kind, LineKind::UuidContinuation);
501 assert_eq!(parsed.uuid, Some(UUID1));
502 assert_eq!(parsed.message, "");
503 }
504
505 #[test]
508 fn bare_variable() {
509 let line = "variable_foo: [bar]";
510 let parsed = parse_line(line);
511 assert_eq!(parsed.kind, LineKind::BareContinuation);
512 assert_eq!(parsed.uuid, None);
513 assert_eq!(parsed.message, "variable_foo: [bar]");
514 }
515
516 #[test]
517 fn bare_sdp_origin() {
518 let line = "o=- 1234 5678 IN IP4 192.0.2.1";
519 let parsed = parse_line(line);
520 assert_eq!(parsed.kind, LineKind::BareContinuation);
521 assert_eq!(parsed.message, line);
522 }
523
524 #[test]
525 fn bare_sdp_media() {
526 let line = "m=audio 47758 RTP/AVP 0 101";
527 let parsed = parse_line(line);
528 assert_eq!(parsed.kind, LineKind::BareContinuation);
529 assert_eq!(parsed.message, line);
530 }
531
532 #[test]
533 fn bare_sdp_attribute() {
534 let line = "a=rtpmap:0 PCMU/8000";
535 let parsed = parse_line(line);
536 assert_eq!(parsed.kind, LineKind::BareContinuation);
537 assert_eq!(parsed.message, line);
538 }
539
540 #[test]
541 fn bare_closing_bracket() {
542 let line = "]";
543 let parsed = parse_line(line);
544 assert_eq!(parsed.kind, LineKind::BareContinuation);
545 assert_eq!(parsed.message, "]");
546 }
547
548 #[test]
549 fn bare_empty_line() {
550 let parsed = parse_line("");
551 assert_eq!(parsed.kind, LineKind::Empty);
552 assert_eq!(parsed.message, "");
553 }
554
555 #[test]
558 fn truncated_varia_prefix() {
559 let line = format!(
560 "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
561 );
562 let parsed = parse_line(&line);
563 assert_eq!(parsed.kind, LineKind::Truncated);
564 assert_eq!(parsed.uuid, Some(UUID1));
565 assert_eq!(
566 parsed.message,
567 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
568 );
569 }
570
571 #[test]
572 fn truncated_variab_prefix() {
573 let line = format!(
574 "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
575 );
576 let parsed = parse_line(&line);
577 assert_eq!(parsed.kind, LineKind::Truncated);
578 assert_eq!(parsed.uuid, Some(UUID1));
579 }
580
581 #[test]
582 fn truncated_var_prefix() {
583 let line =
584 format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
585 let parsed = parse_line(&line);
586 assert_eq!(parsed.kind, LineKind::Truncated);
587 assert_eq!(parsed.uuid, Some(UUID1));
588 }
589
590 #[test]
591 fn truncated_variable_prefix() {
592 let line = format!(
593 "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
594 );
595 let parsed = parse_line(&line);
596 assert_eq!(parsed.kind, LineKind::Truncated);
597 assert_eq!(parsed.uuid, Some(UUID1));
598 }
599
600 #[test]
603 fn full_line_no_idle_pct() {
604 let line = format!(
605 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
606 );
607 let parsed = parse_line(&line);
608 assert_eq!(parsed.kind, LineKind::Full);
609 assert_eq!(parsed.uuid, Some(UUID1));
610 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
611 assert_eq!(parsed.idle_pct, None);
612 assert_eq!(parsed.level, Some(LogLevel::Notice));
613 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
614 assert_eq!(parsed.message, "Session 3178948 ended");
615 }
616
617 #[test]
618 fn full_line_no_idle_pct_url_encoded_percent() {
619 let line = format!(
620 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
621 );
622 let parsed = parse_line(&line);
623 assert_eq!(parsed.kind, LineKind::Full);
624 assert_eq!(parsed.uuid, Some(UUID1));
625 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
626 assert_eq!(parsed.idle_pct, None);
627 assert_eq!(parsed.level, Some(LogLevel::Notice));
628 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
629 assert_eq!(
630 parsed.message,
631 "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
632 );
633 }
634
635 #[test]
636 fn system_line_no_idle_pct() {
637 let line =
638 "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
639 let parsed = parse_line(line);
640 assert_eq!(parsed.kind, LineKind::System);
641 assert_eq!(parsed.uuid, None);
642 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
643 assert_eq!(parsed.idle_pct, None);
644 assert_eq!(parsed.level, Some(LogLevel::Info));
645 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
646 assert_eq!(parsed.message, "Event Socket command");
647 }
648
649 #[test]
650 fn full_line_no_idle_pct_hangup_url_encoded() {
651 let line = format!(
652 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] sofia.c:1089 Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
653 );
654 let parsed = parse_line(&line);
655 assert_eq!(parsed.kind, LineKind::Full);
656 assert_eq!(parsed.idle_pct, None);
657 assert_eq!(parsed.level, Some(LogLevel::Notice));
658 assert_eq!(parsed.source, Some("sofia.c:1089"));
659 assert_eq!(
660 parsed.message,
661 "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
662 );
663 }
664
665 #[test]
668 fn not_uuid_36_chars() {
669 let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
670 let parsed = parse_line(line);
671 assert_eq!(parsed.kind, LineKind::BareContinuation);
672 assert_eq!(parsed.message, line);
673 }
674
675 #[test]
676 fn uuid_in_message_not_prefix() {
677 let line =
678 format!("This is some log message body with extra context then {UUID1} appears here");
679 let parsed = parse_line(&line);
680 assert_eq!(parsed.kind, LineKind::BareContinuation);
681 assert_eq!(parsed.message, line.as_str());
682 }
683
684 #[test]
685 fn whitespace_only_is_empty() {
686 let parsed = parse_line(" \t ");
687 assert_eq!(parsed.kind, LineKind::Empty);
688 }
689}