1use crate::level::LogLevel;
2
3use std::fmt;
4
5pub(crate) const UUID_LEN: usize = 36;
7
8pub(crate) const UUID_PREFIX_LEN: usize = UUID_LEN + 1;
11
12#[non_exhaustive]
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LineKind {
21 Full,
23 System,
25 UuidContinuation,
27 BareContinuation,
29 Truncated,
31 Empty,
33}
34
35impl fmt::Display for LineKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 LineKind::Full => f.pad("full"),
39 LineKind::System => f.pad("system"),
40 LineKind::UuidContinuation => f.pad("uuid-cont"),
41 LineKind::BareContinuation => f.pad("bare-cont"),
42 LineKind::Truncated => f.pad("truncated"),
43 LineKind::Empty => f.pad("empty"),
44 }
45 }
46}
47
48#[derive(Debug, PartialEq, Eq)]
54pub struct RawLine<'a> {
55 pub uuid: Option<&'a str>,
57 pub timestamp: Option<&'a str>,
59 pub idle_pct: Option<&'a str>,
61 pub level: Option<LogLevel>,
63 pub source: Option<&'a str>,
65 pub message: &'a str,
67 pub kind: LineKind,
69}
70
71pub(crate) fn is_uuid_body_at(bytes: &[u8], offset: usize) -> bool {
74 let Some(uuid) = bytes.get(offset..offset + UUID_LEN) else {
75 return false;
76 };
77 uuid.iter().enumerate().all(|(i, &b)| match i {
78 8 | 13 | 18 | 23 => b == b'-',
79 _ => b.is_ascii_hexdigit(),
80 })
81}
82
83pub(crate) fn is_uuid_at(bytes: &[u8], offset: usize) -> bool {
86 bytes.get(offset + UUID_LEN) == Some(&b' ') && is_uuid_body_at(bytes, offset)
87}
88
89fn find_uuid_in(bytes: &[u8]) -> Option<usize> {
90 if bytes.len() < UUID_PREFIX_LEN {
91 return None;
92 }
93 let max_start = (bytes.len() - UUID_PREFIX_LEN).min(50);
94 (1..=max_start).find(|&start| is_uuid_at(bytes, start))
95}
96
97pub(crate) fn is_date_at(bytes: &[u8], offset: usize) -> bool {
98 if bytes.len() < offset + 5 {
99 return false;
100 }
101 bytes[offset..offset + 4].iter().all(u8::is_ascii_digit) && bytes[offset + 4] == b'-'
102}
103
104pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
114 if bytes.len() < offset + 31 {
116 return false;
117 }
118 if !(bytes[offset..offset + 4].iter().all(u8::is_ascii_digit)
120 && bytes[offset + 4] == b'-'
121 && bytes[offset + 5..offset + 7].iter().all(u8::is_ascii_digit)
122 && bytes[offset + 7] == b'-'
123 && bytes[offset + 8..offset + 10]
124 .iter()
125 .all(u8::is_ascii_digit)
126 && bytes[offset + 10] == b' '
127 && bytes[offset + 11..offset + 13]
128 .iter()
129 .all(u8::is_ascii_digit)
130 && bytes[offset + 13] == b':'
131 && bytes[offset + 14..offset + 16]
132 .iter()
133 .all(u8::is_ascii_digit)
134 && bytes[offset + 16] == b':'
135 && bytes[offset + 17..offset + 19]
136 .iter()
137 .all(u8::is_ascii_digit)
138 && bytes[offset + 19] == b'.'
139 && bytes[offset + 20..offset + 26]
140 .iter()
141 .all(u8::is_ascii_digit)
142 && bytes[offset + 26] == b' ')
143 {
144 return false;
145 }
146 let rest = &bytes[offset + 27..];
149 if rest[0] == b'[' {
150 return true;
151 }
152 if !rest[0].is_ascii_digit() {
154 return false;
155 }
156 let Some(pct_pos) = rest[..rest.len().min(7)].iter().position(|&b| b == b'%') else {
157 return false;
158 };
159 rest.len() > pct_pos + 2 && rest[pct_pos + 1] == b' ' && rest[pct_pos + 2] == b'['
160}
161
162fn parse_idle_pct(rest: &str) -> (Option<&str>, &str) {
171 let bytes = rest.as_bytes();
172 if bytes.is_empty() || !bytes[0].is_ascii_digit() {
173 return (None, rest);
174 }
175 let search_len = rest.len().min(7);
176 let pct_pos = match bytes[..search_len].iter().position(|&b| b == b'%') {
177 Some(p) => p,
178 None => return (None, rest),
179 };
180 if !bytes[..pct_pos]
181 .iter()
182 .all(|&b| b.is_ascii_digit() || b == b'.')
183 {
184 return (None, rest);
185 }
186 if rest.len() > pct_pos + 1 && bytes[pct_pos + 1] != b' ' {
190 return (None, rest);
191 }
192 let idle_pct = &rest[0..=pct_pos];
193 let after = if rest.len() > pct_pos + 2 {
194 &rest[pct_pos + 2..]
195 } else {
196 ""
197 };
198 (Some(idle_pct), after)
199}
200
201fn header_boundaries_ok(s: &str) -> bool {
205 s.len() < 27 || (s.is_char_boundary(26) && s.is_char_boundary(27))
206}
207
208fn parse_timestamped_fields(
209 s: &str,
210) -> (
211 Option<&str>,
212 Option<&str>,
213 Option<LogLevel>,
214 Option<&str>,
215 &str,
216) {
217 if s.len() < 27 {
218 return (None, None, None, None, s);
219 }
220 let timestamp = &s[0..26];
221 let rest = &s[27..];
222
223 let (idle_pct, rest) = parse_idle_pct(rest);
224
225 let bracket_end = match rest.find(']') {
226 Some(p) => p,
227 None => return (Some(timestamp), idle_pct, None, None, rest),
228 };
229 let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
230
231 if rest.len() < bracket_end + 3 || !rest.is_char_boundary(bracket_end + 2) {
232 return (Some(timestamp), idle_pct, level, None, "");
233 }
234 let rest = &rest[bracket_end + 2..];
235
236 let source_end = rest.find(' ').unwrap_or(rest.len());
237 let source = &rest[0..source_end];
238 let message = if source_end < rest.len() {
239 &rest[source_end + 1..]
240 } else {
241 ""
242 };
243
244 (Some(timestamp), idle_pct, level, Some(source), message)
245}
246
247pub fn parse_line(line: &str) -> RawLine<'_> {
253 if line.trim().is_empty() {
254 return RawLine {
255 uuid: None,
256 timestamp: None,
257 idle_pct: None,
258 level: None,
259 source: None,
260 message: line,
261 kind: LineKind::Empty,
262 };
263 }
264
265 let bytes = line.as_bytes();
266
267 if is_uuid_at(bytes, 0) {
268 let uuid = &line[0..UUID_LEN];
269 let after_uuid = &line[UUID_PREFIX_LEN..];
270
271 if is_date_at(bytes, UUID_PREFIX_LEN) && header_boundaries_ok(after_uuid) {
272 let (timestamp, idle_pct, level, source, message) =
273 parse_timestamped_fields(after_uuid);
274 return RawLine {
275 uuid: Some(uuid),
276 timestamp,
277 idle_pct,
278 level,
279 source,
280 message,
281 kind: LineKind::Full,
282 };
283 }
284
285 return RawLine {
286 uuid: Some(uuid),
287 timestamp: None,
288 idle_pct: None,
289 level: None,
290 source: None,
291 message: after_uuid,
292 kind: LineKind::UuidContinuation,
293 };
294 }
295
296 if is_date_at(bytes, 0) && header_boundaries_ok(line) {
297 let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
298 let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
299 (Some(&message[0..UUID_LEN]), &message[UUID_PREFIX_LEN..])
300 } else {
301 (None, message)
302 };
303 return RawLine {
304 uuid,
305 timestamp,
306 idle_pct,
307 level,
308 source,
309 message,
310 kind: LineKind::System,
311 };
312 }
313
314 if let Some(uuid_start) = find_uuid_in(bytes) {
315 let uuid = &line[uuid_start..uuid_start + UUID_LEN];
316 let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
317 &line[uuid_start + UUID_PREFIX_LEN..]
318 } else {
319 ""
320 };
321 return RawLine {
322 uuid: Some(uuid),
323 timestamp: None,
324 idle_pct: None,
325 level: None,
326 source: None,
327 message,
328 kind: LineKind::Truncated,
329 };
330 }
331
332 RawLine {
333 uuid: None,
334 timestamp: None,
335 idle_pct: None,
336 level: None,
337 source: None,
338 message: line,
339 kind: LineKind::BareContinuation,
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
348
349 #[test]
352 fn full_line_all_fields() {
353 let line = format!(
354 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
355 );
356 let parsed = parse_line(&line);
357 assert_eq!(parsed.kind, LineKind::Full);
358 assert_eq!(parsed.uuid, Some(UUID1));
359 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
360 assert_eq!(parsed.idle_pct, Some("95.97%"));
361 assert_eq!(parsed.level, Some(LogLevel::Debug));
362 assert_eq!(parsed.source, Some("sofia.c:100"));
363 assert_eq!(parsed.message, "Test message here");
364 }
365
366 #[test]
367 fn full_line_each_level() {
368 for (name, expected) in [
369 ("DEBUG", LogLevel::Debug),
370 ("INFO", LogLevel::Info),
371 ("NOTICE", LogLevel::Notice),
372 ("WARNING", LogLevel::Warning),
373 ("ERR", LogLevel::Err),
374 ("CRIT", LogLevel::Crit),
375 ("ALERT", LogLevel::Alert),
376 ("CONSOLE", LogLevel::Console),
377 ] {
378 let line =
379 format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
380 let parsed = parse_line(&line);
381 assert_eq!(parsed.kind, LineKind::Full);
382 assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
383 }
384 }
385
386 #[test]
387 fn full_line_high_idle() {
388 let line =
389 format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
390 let parsed = parse_line(&line);
391 assert_eq!(parsed.idle_pct, Some("99.99%"));
392 }
393
394 #[test]
395 fn full_line_low_idle() {
396 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
397 let parsed = parse_line(&line);
398 assert_eq!(parsed.idle_pct, Some("0.00%"));
399 }
400
401 #[test]
404 fn idle_pct_requires_the_space_after_the_sign() {
405 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 9%X[DEBUG] sofia.c:100 Message");
406 assert_eq!(parse_line(&line).idle_pct, None);
407 }
408
409 #[test]
410 fn full_line_long_message() {
411 let line = format!(
412 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
413 );
414 let parsed = parse_line(&line);
415 assert_eq!(
416 parsed.message,
417 "Channel [sofia/internal] key=val:123 (test) {braces}"
418 );
419 }
420
421 #[test]
424 fn system_line_no_uuid() {
425 let line =
426 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
427 let parsed = parse_line(line);
428 assert_eq!(parsed.kind, LineKind::System);
429 assert_eq!(parsed.uuid, None);
430 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
431 assert_eq!(parsed.idle_pct, Some("95.97%"));
432 assert_eq!(parsed.level, Some(LogLevel::Info));
433 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
434 assert_eq!(parsed.message, "Event Socket command");
435 }
436
437 #[test]
438 fn system_line_with_embedded_uuid() {
439 let line = format!(
440 "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
441 );
442 let parsed = parse_line(&line);
443 assert_eq!(parsed.kind, LineKind::System);
444 assert_eq!(parsed.uuid, Some(UUID1));
445 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
446 assert_eq!(parsed.level, Some(LogLevel::Debug));
447 assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
448 assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
449 }
450
451 #[test]
452 fn system_line_with_embedded_uuid_empty_message() {
453 let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
454 let parsed = parse_line(&line);
455 assert_eq!(parsed.kind, LineKind::System);
456 assert_eq!(parsed.uuid, Some(UUID1));
457 assert_eq!(parsed.message, "");
458 }
459
460 #[test]
461 fn system_line_without_embedded_uuid() {
462 let line =
463 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
464 let parsed = parse_line(line);
465 assert_eq!(parsed.kind, LineKind::System);
466 assert_eq!(parsed.uuid, None);
467 assert_eq!(parsed.message, "Event Socket command");
468 }
469
470 #[test]
471 fn system_line_event_socket() {
472 let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
473 let parsed = parse_line(line);
474 assert_eq!(parsed.kind, LineKind::System);
475 assert_eq!(parsed.level, Some(LogLevel::Notice));
476 assert_eq!(parsed.message, "New log started.");
477 }
478
479 #[test]
482 fn uuid_continuation_dialplan() {
483 let line =
484 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
485 let parsed = parse_line(&line);
486 assert_eq!(parsed.kind, LineKind::UuidContinuation);
487 assert_eq!(parsed.uuid, Some(UUID1));
488 assert_eq!(parsed.timestamp, None);
489 assert_eq!(parsed.level, None);
490 assert_eq!(
491 parsed.message,
492 "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
493 );
494 }
495
496 #[test]
497 fn uuid_continuation_execute() {
498 let line =
499 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
500 let parsed = parse_line(&line);
501 assert_eq!(parsed.kind, LineKind::UuidContinuation);
502 assert_eq!(parsed.uuid, Some(UUID1));
503 assert_eq!(
504 parsed.message,
505 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
506 );
507 }
508
509 #[test]
510 fn uuid_continuation_channel_var() {
511 let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
512 let parsed = parse_line(&line);
513 assert_eq!(parsed.kind, LineKind::UuidContinuation);
514 assert_eq!(parsed.uuid, Some(UUID1));
515 assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
516 }
517
518 #[test]
519 fn uuid_continuation_variable() {
520 let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
521 let parsed = parse_line(&line);
522 assert_eq!(parsed.kind, LineKind::UuidContinuation);
523 assert_eq!(parsed.uuid, Some(UUID1));
524 assert_eq!(parsed.message, "variable_sip_call_id: [test123@192.0.2.1]");
525 }
526
527 #[test]
528 fn uuid_continuation_blank() {
529 let line = format!("{UUID1} ");
530 let parsed = parse_line(&line);
531 assert_eq!(parsed.kind, LineKind::UuidContinuation);
532 assert_eq!(parsed.uuid, Some(UUID1));
533 assert_eq!(parsed.message, "");
534 }
535
536 #[test]
539 fn bare_variable() {
540 let line = "variable_foo: [bar]";
541 let parsed = parse_line(line);
542 assert_eq!(parsed.kind, LineKind::BareContinuation);
543 assert_eq!(parsed.uuid, None);
544 assert_eq!(parsed.message, "variable_foo: [bar]");
545 }
546
547 #[test]
548 fn bare_sdp_origin() {
549 let line = "o=- 1234 5678 IN IP4 192.0.2.1";
550 let parsed = parse_line(line);
551 assert_eq!(parsed.kind, LineKind::BareContinuation);
552 assert_eq!(parsed.message, line);
553 }
554
555 #[test]
556 fn bare_sdp_media() {
557 let line = "m=audio 47758 RTP/AVP 0 101";
558 let parsed = parse_line(line);
559 assert_eq!(parsed.kind, LineKind::BareContinuation);
560 assert_eq!(parsed.message, line);
561 }
562
563 #[test]
564 fn bare_sdp_attribute() {
565 let line = "a=rtpmap:0 PCMU/8000";
566 let parsed = parse_line(line);
567 assert_eq!(parsed.kind, LineKind::BareContinuation);
568 assert_eq!(parsed.message, line);
569 }
570
571 #[test]
572 fn bare_closing_bracket() {
573 let line = "]";
574 let parsed = parse_line(line);
575 assert_eq!(parsed.kind, LineKind::BareContinuation);
576 assert_eq!(parsed.message, "]");
577 }
578
579 #[test]
580 fn bare_empty_line() {
581 let parsed = parse_line("");
582 assert_eq!(parsed.kind, LineKind::Empty);
583 assert_eq!(parsed.message, "");
584 }
585
586 #[test]
589 fn truncated_varia_prefix() {
590 let line = format!(
591 "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
592 );
593 let parsed = parse_line(&line);
594 assert_eq!(parsed.kind, LineKind::Truncated);
595 assert_eq!(parsed.uuid, Some(UUID1));
596 assert_eq!(
597 parsed.message,
598 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
599 );
600 }
601
602 #[test]
603 fn truncated_variab_prefix() {
604 let line = format!(
605 "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
606 );
607 let parsed = parse_line(&line);
608 assert_eq!(parsed.kind, LineKind::Truncated);
609 assert_eq!(parsed.uuid, Some(UUID1));
610 }
611
612 #[test]
613 fn truncated_var_prefix() {
614 let line =
615 format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
616 let parsed = parse_line(&line);
617 assert_eq!(parsed.kind, LineKind::Truncated);
618 assert_eq!(parsed.uuid, Some(UUID1));
619 }
620
621 #[test]
622 fn truncated_variable_prefix() {
623 let line = format!(
624 "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
625 );
626 let parsed = parse_line(&line);
627 assert_eq!(parsed.kind, LineKind::Truncated);
628 assert_eq!(parsed.uuid, Some(UUID1));
629 }
630
631 #[test]
634 fn log_header_with_idle_pct() {
635 let line = "2024-04-02 10:31:28.785679 98.03% [NOTICE] sofia.c:1114 Hangup";
636 assert!(is_log_header_at(line.as_bytes(), 0));
637 }
638
639 #[test]
640 fn log_header_no_idle_pct() {
641 let line = "2024-04-02 10:31:28.785679 [NOTICE] sofia.c:1114 Hangup";
643 assert!(is_log_header_at(line.as_bytes(), 0));
644 }
645
646 #[test]
647 fn log_header_no_idle_pct_at_offset() {
648 let line = "Session does not exist, aborting REFER.2024-04-02 10:31:28.785679 [WARNING] sofia_presence.c:4546 x";
649 let offset = line.find("2024").unwrap();
650 assert!(is_log_header_at(line.as_bytes(), offset));
651 }
652
653 #[test]
654 fn log_header_rejects_non_header() {
655 let line = "2024-04-02 not a real timestamp here";
656 assert!(!is_log_header_at(line.as_bytes(), 0));
657 }
658
659 #[test]
662 fn full_line_no_idle_pct() {
663 let line = format!(
664 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
665 );
666 let parsed = parse_line(&line);
667 assert_eq!(parsed.kind, LineKind::Full);
668 assert_eq!(parsed.uuid, Some(UUID1));
669 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
670 assert_eq!(parsed.idle_pct, None);
671 assert_eq!(parsed.level, Some(LogLevel::Notice));
672 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
673 assert_eq!(parsed.message, "Session 3178948 ended");
674 }
675
676 #[test]
677 fn full_line_no_idle_pct_url_encoded_percent() {
678 let line = format!(
679 "{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"
680 );
681 let parsed = parse_line(&line);
682 assert_eq!(parsed.kind, LineKind::Full);
683 assert_eq!(parsed.uuid, Some(UUID1));
684 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
685 assert_eq!(parsed.idle_pct, None);
686 assert_eq!(parsed.level, Some(LogLevel::Notice));
687 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
688 assert_eq!(
689 parsed.message,
690 "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
691 );
692 }
693
694 #[test]
695 fn system_line_no_idle_pct() {
696 let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
697 let parsed = parse_line(line);
698 assert_eq!(parsed.kind, LineKind::System);
699 assert_eq!(parsed.uuid, None);
700 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
701 assert_eq!(parsed.idle_pct, None);
702 assert_eq!(parsed.level, Some(LogLevel::Info));
703 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
704 assert_eq!(parsed.message, "Event Socket command");
705 }
706
707 #[test]
708 fn full_line_no_idle_pct_hangup_url_encoded() {
709 let line = format!(
710 "{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]"
711 );
712 let parsed = parse_line(&line);
713 assert_eq!(parsed.kind, LineKind::Full);
714 assert_eq!(parsed.idle_pct, None);
715 assert_eq!(parsed.level, Some(LogLevel::Notice));
716 assert_eq!(parsed.source, Some("sofia.c:1089"));
717 assert_eq!(
718 parsed.message,
719 "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
720 );
721 }
722
723 #[test]
726 fn not_uuid_36_chars() {
727 let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
728 let parsed = parse_line(line);
729 assert_eq!(parsed.kind, LineKind::BareContinuation);
730 assert_eq!(parsed.message, line);
731 }
732
733 #[test]
734 fn uuid_in_message_not_prefix() {
735 let line =
736 format!("This is some log message body with extra context then {UUID1} appears here");
737 let parsed = parse_line(&line);
738 assert_eq!(parsed.kind, LineKind::BareContinuation);
739 assert_eq!(parsed.message, line.as_str());
740 }
741
742 #[test]
743 fn whitespace_only_is_empty() {
744 let parsed = parse_line(" \t ");
745 assert_eq!(parsed.kind, LineKind::Empty);
746 }
747
748 #[test]
751 fn multibyte_straddling_timestamp_end_not_system() {
752 let line = "2025-01-15 10:30:45.12345é more content following here";
754 assert!(!line.is_char_boundary(26));
755 let parsed = parse_line(line);
756 assert_eq!(parsed.kind, LineKind::BareContinuation);
757 assert_eq!(parsed.timestamp, None);
758 assert_eq!(parsed.message, line);
759 }
760
761 #[test]
762 fn multibyte_after_timestamp_not_system() {
763 let line = "2025-01-15 10:30:45.123456é more content following here";
765 assert!(!line.is_char_boundary(27));
766 let parsed = parse_line(line);
767 assert_eq!(parsed.kind, LineKind::BareContinuation);
768 assert_eq!(parsed.timestamp, None);
769 assert_eq!(parsed.message, line);
770 }
771
772 #[test]
773 fn multibyte_after_uuid_timestamp_is_continuation() {
774 let line = format!("{UUID1} 2025-01-15 10:30:45.123456é more content");
775 let parsed = parse_line(&line);
776 assert_eq!(parsed.kind, LineKind::UuidContinuation);
777 assert_eq!(parsed.uuid, Some(UUID1));
778 assert_eq!(parsed.timestamp, None);
779 assert_eq!(parsed.message, "2025-01-15 10:30:45.123456é more content");
780 }
781
782 #[test]
783 fn multibyte_after_idle_pct_degrades() {
784 let line = "2025-01-15 10:30:45.123456 9%é[DEBUG] x";
786 let parsed = parse_line(line);
787 assert_eq!(parsed.kind, LineKind::System);
788 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
789 assert_eq!(parsed.idle_pct, None);
790 assert_eq!(parsed.level, None);
791 }
792
793 #[test]
794 fn multibyte_after_level_bracket_degrades() {
795 let line = "2025-01-15 10:30:45.123456 95.97% [DEBUG]éxx";
797 let parsed = parse_line(line);
798 assert_eq!(parsed.kind, LineKind::System);
799 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
800 assert_eq!(parsed.idle_pct, Some("95.97%"));
801 assert_eq!(parsed.level, Some(LogLevel::Debug));
802 assert_eq!(parsed.source, None);
803 assert_eq!(parsed.message, "");
804 }
805}