1pub(crate) use crate::uuid::{is_uuid_at, UUID_PREFIX_LEN};
2use freeswitch_types::LogLevel;
3
4use crate::mask::Mask;
5use crate::uuid::{find_uuid_in, UUID_LEN};
6
7use std::fmt;
8use std::ops::Range;
9
10#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum LineKind {
19 Full,
21 System,
23 UuidContinuation,
25 BareContinuation,
27 Truncated,
29 Empty,
31}
32
33impl fmt::Display for LineKind {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 LineKind::Full => f.pad("full"),
37 LineKind::System => f.pad("system"),
38 LineKind::UuidContinuation => f.pad("uuid-cont"),
39 LineKind::BareContinuation => f.pad("bare-cont"),
40 LineKind::Truncated => f.pad("truncated"),
41 LineKind::Empty => f.pad("empty"),
42 }
43 }
44}
45
46#[derive(Debug, PartialEq, Eq)]
52pub struct RawLine<'a> {
53 pub uuid: Option<&'a str>,
55 pub timestamp: Option<&'a str>,
57 pub idle_pct: Option<&'a str>,
59 pub level: Option<LogLevel>,
61 pub source: Option<&'a str>,
63 pub message: &'a str,
65 pub kind: LineKind,
67}
68
69pub(crate) fn is_date_at(bytes: &[u8], offset: usize) -> bool {
70 if bytes.len() < offset + 5 {
71 return false;
72 }
73 bytes[offset..offset + 4].iter().all(u8::is_ascii_digit) && bytes[offset + 4] == b'-'
74}
75
76const TIMESTAMP_LEN: usize = 26;
78
79const TIMESTAMP_MASK: Mask = Mask {
80 len: TIMESTAMP_LEN,
81 separators: &[
82 (4, b'-'),
83 (7, b'-'),
84 (10, b' '),
85 (13, b':'),
86 (16, b':'),
87 (19, b'.'),
88 ],
89 filler: u8::is_ascii_digit,
90};
91
92const MAX_IDLE_PCT_LEN: usize = 7;
94
95pub(crate) struct HeaderSpan {
98 timestamp: Range<usize>,
99 idle_pct: Option<Range<usize>>,
100 level: usize,
102}
103
104fn idle_pct_span(bytes: &[u8], from: usize) -> Option<Range<usize>> {
107 let end = (from + MAX_IDLE_PCT_LEN).min(bytes.len());
108 let window = bytes.get(from..end)?;
109 let pct = window.iter().position(|&b| b == b'%')?;
110 if !window[0].is_ascii_digit()
111 || !window[..pct]
112 .iter()
113 .all(|&b| b.is_ascii_digit() || b == b'.')
114 {
115 return None;
116 }
117 (bytes.get(from + pct + 1) == Some(&b' ')).then(|| from..from + pct + 1)
118}
119
120pub(crate) fn header_at(bytes: &[u8], offset: usize) -> Option<HeaderSpan> {
128 if !TIMESTAMP_MASK.matches_at(bytes, offset) || bytes.get(offset + TIMESTAMP_LEN) != Some(&b' ')
129 {
130 return None;
131 }
132
133 let after_stamp = offset + TIMESTAMP_LEN + 1;
134 let idle_pct = idle_pct_span(bytes, after_stamp);
135 let level = idle_pct.as_ref().map_or(after_stamp, |r| r.end + 1);
136 if bytes.get(level) != Some(&b'[') {
137 return None;
138 }
139
140 Some(HeaderSpan {
141 timestamp: offset..offset + TIMESTAMP_LEN,
142 idle_pct,
143 level,
144 })
145}
146
147const MAX_LEVEL_LEN: usize = 7;
149
150fn level_field_closes(bytes: &[u8], level: usize) -> bool {
152 let end = (level + MAX_LEVEL_LEN + 2).min(bytes.len());
153 bytes
154 .get(level + 1..end)
155 .and_then(|w| w.iter().position(|&b| b == b']'))
156 .is_some_and(|p| p > 0)
157}
158
159pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
167 header_at(bytes, offset).is_some_and(|h| level_field_closes(bytes, h.level))
168}
169
170pub fn level_from_bracketed(s: &str) -> Option<LogLevel> {
177 let inner = s.strip_prefix('[')?.strip_suffix(']')?;
178 LogLevel::ALL
179 .iter()
180 .find(|l| l.as_str().eq_ignore_ascii_case(inner))
181 .copied()
182}
183
184fn header_boundaries_ok(s: &str) -> bool {
188 s.len() < 27 || (s.is_char_boundary(26) && s.is_char_boundary(27))
189}
190
191struct LineHeader<'a> {
194 timestamp: Option<&'a str>,
195 idle_pct: Option<&'a str>,
196 level: Option<LogLevel>,
197 source: Option<&'a str>,
198 message: &'a str,
199}
200
201fn parse_timestamped_fields(s: &str) -> LineHeader<'_> {
202 if s.len() < TIMESTAMP_LEN + 1 {
203 return LineHeader {
204 timestamp: None,
205 idle_pct: None,
206 level: None,
207 source: None,
208 message: s,
209 };
210 }
211 let (timestamp, idle_pct, rest) = match header_at(s.as_bytes(), 0) {
212 Some(h) => (&s[h.timestamp], h.idle_pct.map(|r| &s[r]), &s[h.level..]),
213 None => (&s[..TIMESTAMP_LEN], None, &s[TIMESTAMP_LEN + 1..]),
214 };
215 let timestamp = Some(timestamp);
216
217 let bracket_end = match rest.find(']') {
218 Some(p) => p,
219 None => {
220 return LineHeader {
221 timestamp,
222 idle_pct,
223 level: None,
224 source: None,
225 message: rest,
226 }
227 }
228 };
229 let level = level_from_bracketed(&rest[0..=bracket_end]);
230
231 if rest.len() < bracket_end + 3 || !rest.is_char_boundary(bracket_end + 2) {
232 return LineHeader {
233 timestamp,
234 idle_pct,
235 level,
236 source: None,
237 message: "",
238 };
239 }
240 let rest = &rest[bracket_end + 2..];
241
242 let source_end = rest.find(' ').unwrap_or(rest.len());
243 let message = if source_end < rest.len() {
244 &rest[source_end + 1..]
245 } else {
246 ""
247 };
248
249 LineHeader {
250 timestamp,
251 idle_pct,
252 level,
253 source: Some(&rest[0..source_end]),
254 message,
255 }
256}
257
258pub fn parse_line(line: &str) -> RawLine<'_> {
264 if line.trim().is_empty() {
265 return RawLine {
266 uuid: None,
267 timestamp: None,
268 idle_pct: None,
269 level: None,
270 source: None,
271 message: line,
272 kind: LineKind::Empty,
273 };
274 }
275
276 let bytes = line.as_bytes();
277
278 if is_uuid_at(bytes, 0) {
279 let uuid = &line[0..UUID_LEN];
280 let after_uuid = &line[UUID_PREFIX_LEN..];
281
282 if is_date_at(bytes, UUID_PREFIX_LEN) && header_boundaries_ok(after_uuid) {
283 let h = parse_timestamped_fields(after_uuid);
284 return RawLine {
285 uuid: Some(uuid),
286 timestamp: h.timestamp,
287 idle_pct: h.idle_pct,
288 level: h.level,
289 source: h.source,
290 message: h.message,
291 kind: LineKind::Full,
292 };
293 }
294
295 return RawLine {
296 uuid: Some(uuid),
297 timestamp: None,
298 idle_pct: None,
299 level: None,
300 source: None,
301 message: after_uuid,
302 kind: LineKind::UuidContinuation,
303 };
304 }
305
306 if is_date_at(bytes, 0) && header_boundaries_ok(line) {
307 let h = parse_timestamped_fields(line);
308 let (uuid, message) = if is_uuid_at(h.message.as_bytes(), 0) {
309 (Some(&h.message[0..UUID_LEN]), &h.message[UUID_PREFIX_LEN..])
310 } else {
311 (None, h.message)
312 };
313 return RawLine {
314 uuid,
315 timestamp: h.timestamp,
316 idle_pct: h.idle_pct,
317 level: h.level,
318 source: h.source,
319 message,
320 kind: LineKind::System,
321 };
322 }
323
324 if let Some(uuid_start) = find_uuid_in(bytes) {
325 let uuid = &line[uuid_start..uuid_start + UUID_LEN];
326 let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
327 &line[uuid_start + UUID_PREFIX_LEN..]
328 } else {
329 ""
330 };
331 return RawLine {
332 uuid: Some(uuid),
333 timestamp: None,
334 idle_pct: None,
335 level: None,
336 source: None,
337 message,
338 kind: LineKind::Truncated,
339 };
340 }
341
342 RawLine {
343 uuid: None,
344 timestamp: None,
345 idle_pct: None,
346 level: None,
347 source: None,
348 message: line,
349 kind: LineKind::BareContinuation,
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
358
359 #[test]
362 fn full_line_all_fields() {
363 let line = format!(
364 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
365 );
366 let parsed = parse_line(&line);
367 assert_eq!(parsed.kind, LineKind::Full);
368 assert_eq!(parsed.uuid, Some(UUID1));
369 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
370 assert_eq!(parsed.idle_pct, Some("95.97%"));
371 assert_eq!(parsed.level, Some(LogLevel::Debug));
372 assert_eq!(parsed.source, Some("sofia.c:100"));
373 assert_eq!(parsed.message, "Test message here");
374 }
375
376 #[test]
377 fn full_line_each_level() {
378 for (name, expected) in [
379 ("DEBUG", LogLevel::Debug),
380 ("INFO", LogLevel::Info),
381 ("NOTICE", LogLevel::Notice),
382 ("WARNING", LogLevel::Warning),
383 ("ERR", LogLevel::Error),
384 ("CRIT", LogLevel::Crit),
385 ("ALERT", LogLevel::Alert),
386 ("CONSOLE", LogLevel::Console),
387 ] {
388 let line =
389 format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
390 let parsed = parse_line(&line);
391 assert_eq!(parsed.kind, LineKind::Full);
392 assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
393 }
394 }
395
396 #[test]
397 fn full_line_high_idle() {
398 let line =
399 format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
400 let parsed = parse_line(&line);
401 assert_eq!(parsed.idle_pct, Some("99.99%"));
402 }
403
404 #[test]
405 fn full_line_low_idle() {
406 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
407 let parsed = parse_line(&line);
408 assert_eq!(parsed.idle_pct, Some("0.00%"));
409 }
410
411 #[test]
414 fn idle_pct_requires_the_space_after_the_sign() {
415 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 9%X[DEBUG] sofia.c:100 Message");
416 assert_eq!(parse_line(&line).idle_pct, None);
417 }
418
419 #[test]
420 fn full_line_long_message() {
421 let line = format!(
422 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
423 );
424 let parsed = parse_line(&line);
425 assert_eq!(
426 parsed.message,
427 "Channel [sofia/internal] key=val:123 (test) {braces}"
428 );
429 }
430
431 #[test]
434 fn system_line_no_uuid() {
435 let line =
436 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
437 let parsed = parse_line(line);
438 assert_eq!(parsed.kind, LineKind::System);
439 assert_eq!(parsed.uuid, None);
440 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
441 assert_eq!(parsed.idle_pct, Some("95.97%"));
442 assert_eq!(parsed.level, Some(LogLevel::Info));
443 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
444 assert_eq!(parsed.message, "Event Socket command");
445 }
446
447 #[test]
448 fn system_line_with_embedded_uuid() {
449 let line = format!(
450 "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
451 );
452 let parsed = parse_line(&line);
453 assert_eq!(parsed.kind, LineKind::System);
454 assert_eq!(parsed.uuid, Some(UUID1));
455 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
456 assert_eq!(parsed.level, Some(LogLevel::Debug));
457 assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
458 assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
459 }
460
461 #[test]
462 fn system_line_with_embedded_uuid_empty_message() {
463 let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
464 let parsed = parse_line(&line);
465 assert_eq!(parsed.kind, LineKind::System);
466 assert_eq!(parsed.uuid, Some(UUID1));
467 assert_eq!(parsed.message, "");
468 }
469
470 #[test]
471 fn system_line_without_embedded_uuid() {
472 let line =
473 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
474 let parsed = parse_line(line);
475 assert_eq!(parsed.kind, LineKind::System);
476 assert_eq!(parsed.uuid, None);
477 assert_eq!(parsed.message, "Event Socket command");
478 }
479
480 #[test]
481 fn system_line_event_socket() {
482 let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
483 let parsed = parse_line(line);
484 assert_eq!(parsed.kind, LineKind::System);
485 assert_eq!(parsed.level, Some(LogLevel::Notice));
486 assert_eq!(parsed.message, "New log started.");
487 }
488
489 #[test]
492 fn uuid_continuation_dialplan() {
493 let line =
494 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
495 let parsed = parse_line(&line);
496 assert_eq!(parsed.kind, LineKind::UuidContinuation);
497 assert_eq!(parsed.uuid, Some(UUID1));
498 assert_eq!(parsed.timestamp, None);
499 assert_eq!(parsed.level, None);
500 assert_eq!(
501 parsed.message,
502 "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
503 );
504 }
505
506 #[test]
507 fn uuid_continuation_execute() {
508 let line =
509 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
510 let parsed = parse_line(&line);
511 assert_eq!(parsed.kind, LineKind::UuidContinuation);
512 assert_eq!(parsed.uuid, Some(UUID1));
513 assert_eq!(
514 parsed.message,
515 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
516 );
517 }
518
519 #[test]
520 fn uuid_continuation_channel_var() {
521 let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
522 let parsed = parse_line(&line);
523 assert_eq!(parsed.kind, LineKind::UuidContinuation);
524 assert_eq!(parsed.uuid, Some(UUID1));
525 assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
526 }
527
528 #[test]
529 fn uuid_continuation_variable() {
530 let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
531 let parsed = parse_line(&line);
532 assert_eq!(parsed.kind, LineKind::UuidContinuation);
533 assert_eq!(parsed.uuid, Some(UUID1));
534 assert_eq!(parsed.message, "variable_sip_call_id: [test123@192.0.2.1]");
535 }
536
537 #[test]
538 fn uuid_continuation_blank() {
539 let line = format!("{UUID1} ");
540 let parsed = parse_line(&line);
541 assert_eq!(parsed.kind, LineKind::UuidContinuation);
542 assert_eq!(parsed.uuid, Some(UUID1));
543 assert_eq!(parsed.message, "");
544 }
545
546 #[test]
549 fn bare_variable() {
550 let line = "variable_foo: [bar]";
551 let parsed = parse_line(line);
552 assert_eq!(parsed.kind, LineKind::BareContinuation);
553 assert_eq!(parsed.uuid, None);
554 assert_eq!(parsed.message, "variable_foo: [bar]");
555 }
556
557 #[test]
558 fn bare_sdp_origin() {
559 let line = "o=- 1234 5678 IN IP4 192.0.2.1";
560 let parsed = parse_line(line);
561 assert_eq!(parsed.kind, LineKind::BareContinuation);
562 assert_eq!(parsed.message, line);
563 }
564
565 #[test]
566 fn bare_sdp_media() {
567 let line = "m=audio 47758 RTP/AVP 0 101";
568 let parsed = parse_line(line);
569 assert_eq!(parsed.kind, LineKind::BareContinuation);
570 assert_eq!(parsed.message, line);
571 }
572
573 #[test]
574 fn bare_sdp_attribute() {
575 let line = "a=rtpmap:0 PCMU/8000";
576 let parsed = parse_line(line);
577 assert_eq!(parsed.kind, LineKind::BareContinuation);
578 assert_eq!(parsed.message, line);
579 }
580
581 #[test]
582 fn bare_closing_bracket() {
583 let line = "]";
584 let parsed = parse_line(line);
585 assert_eq!(parsed.kind, LineKind::BareContinuation);
586 assert_eq!(parsed.message, "]");
587 }
588
589 #[test]
590 fn bare_empty_line() {
591 let parsed = parse_line("");
592 assert_eq!(parsed.kind, LineKind::Empty);
593 assert_eq!(parsed.message, "");
594 }
595
596 #[test]
599 fn truncated_varia_prefix() {
600 let line = format!(
601 "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
602 );
603 let parsed = parse_line(&line);
604 assert_eq!(parsed.kind, LineKind::Truncated);
605 assert_eq!(parsed.uuid, Some(UUID1));
606 assert_eq!(
607 parsed.message,
608 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
609 );
610 }
611
612 #[test]
613 fn truncated_variab_prefix() {
614 let line = format!(
615 "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
616 );
617 let parsed = parse_line(&line);
618 assert_eq!(parsed.kind, LineKind::Truncated);
619 assert_eq!(parsed.uuid, Some(UUID1));
620 }
621
622 #[test]
623 fn truncated_var_prefix() {
624 let line =
625 format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
626 let parsed = parse_line(&line);
627 assert_eq!(parsed.kind, LineKind::Truncated);
628 assert_eq!(parsed.uuid, Some(UUID1));
629 }
630
631 #[test]
632 fn truncated_variable_prefix() {
633 let line = format!(
634 "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
635 );
636 let parsed = parse_line(&line);
637 assert_eq!(parsed.kind, LineKind::Truncated);
638 assert_eq!(parsed.uuid, Some(UUID1));
639 }
640
641 #[test]
644 fn bracketed_level_folds_the_log_spelling() {
645 assert_eq!(level_from_bracketed("[DEBUG]"), Some(LogLevel::Debug));
646 assert_eq!(level_from_bracketed("[ERR]"), Some(LogLevel::Error));
647 assert_eq!(level_from_bracketed("[CONSOLE]"), Some(LogLevel::Console));
648 }
649
650 #[test]
651 fn bracketed_level_reads_any_case() {
652 assert_eq!(level_from_bracketed("[debug]"), Some(LogLevel::Debug));
653 assert_eq!(level_from_bracketed("[Warning]"), Some(LogLevel::Warning));
654 }
655
656 #[test]
657 fn bracketed_level_rejects_malformed() {
658 assert_eq!(level_from_bracketed("[FAKE]"), None);
659 assert_eq!(level_from_bracketed("DEBUG"), None);
660 assert_eq!(level_from_bracketed("[]"), None);
661 assert_eq!(level_from_bracketed("["), None);
662 assert_eq!(level_from_bracketed(""), None);
663 }
664
665 #[test]
667 fn levels_order_by_switch_log_level_numbering() {
668 assert!(LogLevel::Console < LogLevel::Alert);
669 assert!(LogLevel::Error < LogLevel::Warning);
670 assert!(LogLevel::Info < LogLevel::Debug);
671 }
672
673 #[test]
676 fn log_header_with_idle_pct() {
677 let line = "2024-04-02 10:31:28.785679 98.03% [NOTICE] sofia.c:1114 Hangup";
678 assert!(is_log_header_at(line.as_bytes(), 0));
679 }
680
681 #[test]
682 fn log_header_no_idle_pct() {
683 let line = "2024-04-02 10:31:28.785679 [NOTICE] sofia.c:1114 Hangup";
685 assert!(is_log_header_at(line.as_bytes(), 0));
686 }
687
688 #[test]
689 fn log_header_no_idle_pct_at_offset() {
690 let line = "Session does not exist, aborting REFER.2024-04-02 10:31:28.785679 [WARNING] sofia_presence.c:4546 x";
691 let offset = line.find("2024").unwrap();
692 assert!(is_log_header_at(line.as_bytes(), offset));
693 }
694
695 #[test]
699 fn idle_pct_rejects_non_numeric_run() {
700 let line = "2025-01-15 10:30:45.123456 ab% [DEBUG] sofia.c:100 Message";
701 assert!(!is_log_header_at(line.as_bytes(), 0));
702 assert_eq!(parse_line(line).idle_pct, None);
703 }
704
705 #[test]
708 fn idle_pct_needs_the_level_after_it() {
709 let line = "2025-01-15 10:30:45.123456 95.97%";
710 assert!(!is_log_header_at(line.as_bytes(), 0));
711 assert_eq!(parse_line(line).idle_pct, None);
712 }
713
714 #[test]
717 fn a_header_cut_inside_the_level_starts_no_record() {
718 for line in [
719 "2025-01-15 10:30:45.123456 95.97% [",
720 "2025-01-15 10:30:45.123456 95.97% [DEB",
721 "2025-01-15 10:30:45.123456 [",
722 ] {
723 assert!(!is_log_header_at(line.as_bytes(), 0), "split on {line}");
724 }
725 let cut = "2025-01-15 10:30:45.123456 95.97% [";
726 assert_eq!(parse_line(cut).idle_pct, Some("95.97%"));
727 }
728
729 #[test]
730 fn log_header_rejects_non_header() {
731 let line = "2024-04-02 not a real timestamp here";
732 assert!(!is_log_header_at(line.as_bytes(), 0));
733 }
734
735 #[test]
738 fn full_line_no_idle_pct() {
739 let line = format!(
740 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
741 );
742 let parsed = parse_line(&line);
743 assert_eq!(parsed.kind, LineKind::Full);
744 assert_eq!(parsed.uuid, Some(UUID1));
745 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
746 assert_eq!(parsed.idle_pct, None);
747 assert_eq!(parsed.level, Some(LogLevel::Notice));
748 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
749 assert_eq!(parsed.message, "Session 3178948 ended");
750 }
751
752 #[test]
753 fn full_line_no_idle_pct_url_encoded_percent() {
754 let line = format!(
755 "{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"
756 );
757 let parsed = parse_line(&line);
758 assert_eq!(parsed.kind, LineKind::Full);
759 assert_eq!(parsed.uuid, Some(UUID1));
760 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
761 assert_eq!(parsed.idle_pct, None);
762 assert_eq!(parsed.level, Some(LogLevel::Notice));
763 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
764 assert_eq!(
765 parsed.message,
766 "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
767 );
768 }
769
770 #[test]
771 fn system_line_no_idle_pct() {
772 let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
773 let parsed = parse_line(line);
774 assert_eq!(parsed.kind, LineKind::System);
775 assert_eq!(parsed.uuid, None);
776 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
777 assert_eq!(parsed.idle_pct, None);
778 assert_eq!(parsed.level, Some(LogLevel::Info));
779 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
780 assert_eq!(parsed.message, "Event Socket command");
781 }
782
783 #[test]
784 fn full_line_no_idle_pct_hangup_url_encoded() {
785 let line = format!(
786 "{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]"
787 );
788 let parsed = parse_line(&line);
789 assert_eq!(parsed.kind, LineKind::Full);
790 assert_eq!(parsed.idle_pct, None);
791 assert_eq!(parsed.level, Some(LogLevel::Notice));
792 assert_eq!(parsed.source, Some("sofia.c:1089"));
793 assert_eq!(
794 parsed.message,
795 "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
796 );
797 }
798
799 #[test]
802 fn not_uuid_36_chars() {
803 let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
804 let parsed = parse_line(line);
805 assert_eq!(parsed.kind, LineKind::BareContinuation);
806 assert_eq!(parsed.message, line);
807 }
808
809 #[test]
810 fn uuid_in_message_not_prefix() {
811 let line =
812 format!("This is some log message body with extra context then {UUID1} appears here");
813 let parsed = parse_line(&line);
814 assert_eq!(parsed.kind, LineKind::BareContinuation);
815 assert_eq!(parsed.message, line.as_str());
816 }
817
818 #[test]
819 fn whitespace_only_is_empty() {
820 let parsed = parse_line(" \t ");
821 assert_eq!(parsed.kind, LineKind::Empty);
822 }
823
824 #[test]
827 fn multibyte_straddling_timestamp_end_not_system() {
828 let line = "2025-01-15 10:30:45.12345é more content following here";
830 assert!(!line.is_char_boundary(26));
831 let parsed = parse_line(line);
832 assert_eq!(parsed.kind, LineKind::BareContinuation);
833 assert_eq!(parsed.timestamp, None);
834 assert_eq!(parsed.message, line);
835 }
836
837 #[test]
838 fn multibyte_after_timestamp_not_system() {
839 let line = "2025-01-15 10:30:45.123456é more content following here";
841 assert!(!line.is_char_boundary(27));
842 let parsed = parse_line(line);
843 assert_eq!(parsed.kind, LineKind::BareContinuation);
844 assert_eq!(parsed.timestamp, None);
845 assert_eq!(parsed.message, line);
846 }
847
848 #[test]
849 fn multibyte_after_uuid_timestamp_is_continuation() {
850 let line = format!("{UUID1} 2025-01-15 10:30:45.123456é more content");
851 let parsed = parse_line(&line);
852 assert_eq!(parsed.kind, LineKind::UuidContinuation);
853 assert_eq!(parsed.uuid, Some(UUID1));
854 assert_eq!(parsed.timestamp, None);
855 assert_eq!(parsed.message, "2025-01-15 10:30:45.123456é more content");
856 }
857
858 #[test]
859 fn multibyte_after_idle_pct_degrades() {
860 let line = "2025-01-15 10:30:45.123456 9%é[DEBUG] x";
862 let parsed = parse_line(line);
863 assert_eq!(parsed.kind, LineKind::System);
864 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
865 assert_eq!(parsed.idle_pct, None);
866 assert_eq!(parsed.level, None);
867 }
868
869 #[test]
870 fn multibyte_after_level_bracket_degrades() {
871 let line = "2025-01-15 10:30:45.123456 95.97% [DEBUG]éxx";
873 let parsed = parse_line(line);
874 assert_eq!(parsed.kind, LineKind::System);
875 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
876 assert_eq!(parsed.idle_pct, Some("95.97%"));
877 assert_eq!(parsed.level, Some(LogLevel::Debug));
878 assert_eq!(parsed.source, None);
879 assert_eq!(parsed.message, "");
880 }
881}