Skip to main content

freeswitch_log_parser/
line.rs

1use crate::level::LogLevel;
2
3use std::fmt;
4
5/// Length of a session UUID in canonical 8-4-4-4-12 hex form.
6pub(crate) const UUID_LEN: usize = 36;
7
8/// Length of a UUID followed by its trailing space — the prefix
9/// `mod_logfile` prepends to every line when `log_uuid=true`.
10pub(crate) const UUID_PREFIX_LEN: usize = UUID_LEN + 1;
11
12/// Classification of a single log line's structural format.
13///
14/// FreeSWITCH's `switch_log_printf` emits five distinct line shapes depending
15/// on whether a session UUID is active, whether the line has a timestamp, and
16/// whether a buffer collision truncated the output. See `docs/design-rationale.md`
17/// for the full anatomy.
18#[non_exhaustive]
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LineKind {
21    /// Format A — UUID, timestamp, idle%, level, source, and message.
22    Full,
23    /// Format B — same as `Full` but without a UUID prefix (system/global events).
24    System,
25    /// Format C — UUID and message only, no timestamp or level.
26    UuidContinuation,
27    /// Format D — raw text with no UUID or timestamp; inherits context from the previous entry.
28    BareContinuation,
29    /// Format E — buffer collision produced a garbage prefix before the UUID.
30    Truncated,
31    /// Blank or whitespace-only line.
32    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/// Zero-copy result of parsing a single log line.
49///
50/// Fields are `None` when the line's format doesn't include them (e.g. a
51/// `BareContinuation` has no `uuid`, `timestamp`, `level`, or `source`).
52/// The `message` field always contains the remaining text.
53#[derive(Debug, PartialEq, Eq)]
54pub struct RawLine<'a> {
55    /// Session UUID, present for `Full`, `UuidContinuation`, and `Truncated` lines.
56    pub uuid: Option<&'a str>,
57    /// Microsecond-precision timestamp, present only for `Full` and `System` lines.
58    pub timestamp: Option<&'a str>,
59    /// Core scheduler idle percentage (e.g. `"95.97%"`), a system health indicator.
60    pub idle_pct: Option<&'a str>,
61    /// Log severity, present only for `Full` and `System` lines.
62    pub level: Option<LogLevel>,
63    /// Source file and line (e.g. `"sofia.c:7624"`), present only for `Full` and `System` lines.
64    pub source: Option<&'a str>,
65    /// The message text after all structured fields have been consumed.
66    pub message: &'a str,
67    /// Which of the five line formats this line matched.
68    pub kind: LineKind,
69}
70
71pub(crate) fn is_uuid_at(bytes: &[u8], offset: usize) -> bool {
72    if bytes.len() < offset + UUID_PREFIX_LEN {
73        return false;
74    }
75    if bytes[offset + UUID_LEN] != b' ' {
76        return false;
77    }
78    for (i, &b) in bytes[offset..offset + UUID_LEN].iter().enumerate() {
79        match i {
80            8 | 13 | 18 | 23 => {
81                if b != b'-' {
82                    return false;
83                }
84            }
85            _ => {
86                if !b.is_ascii_hexdigit() {
87                    return false;
88                }
89            }
90        }
91    }
92    true
93}
94
95fn find_uuid_in(bytes: &[u8]) -> Option<usize> {
96    if bytes.len() < UUID_PREFIX_LEN {
97        return None;
98    }
99    let max_start = (bytes.len() - UUID_PREFIX_LEN).min(50);
100    (1..=max_start).find(|&start| is_uuid_at(bytes, start))
101}
102
103pub(crate) fn is_date_at(bytes: &[u8], offset: usize) -> bool {
104    if bytes.len() < offset + 5 {
105        return false;
106    }
107    bytes[offset..offset + 4].iter().all(u8::is_ascii_digit) && bytes[offset + 4] == b'-'
108}
109
110/// Check for a full FreeSWITCH log header at `offset`:
111/// `YYYY-MM-DD HH:MM:SS.UUUUUU [D+.D+% ][`
112///
113/// The idle percentage is optional — older/eSInet FS builds omit it and emit
114/// `[LEVEL]` directly after the timestamp.
115///
116/// Used by Layer 2 to detect same-line collisions where multiple log entries
117/// were concatenated without a newline (thread contention on file write, or a
118/// caller format string missing its trailing `\n`).
119pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
120    // Minimum: 27-byte timestamp + space + "0% [" = 31 bytes
121    if bytes.len() < offset + 31 {
122        return false;
123    }
124    // YYYY-MM-DD HH:MM:SS.UUUUUU (26 bytes + space)
125    if !(bytes[offset..offset + 4].iter().all(u8::is_ascii_digit)
126        && bytes[offset + 4] == b'-'
127        && bytes[offset + 5..offset + 7].iter().all(u8::is_ascii_digit)
128        && bytes[offset + 7] == b'-'
129        && bytes[offset + 8..offset + 10]
130            .iter()
131            .all(u8::is_ascii_digit)
132        && bytes[offset + 10] == b' '
133        && bytes[offset + 11..offset + 13]
134            .iter()
135            .all(u8::is_ascii_digit)
136        && bytes[offset + 13] == b':'
137        && bytes[offset + 14..offset + 16]
138            .iter()
139            .all(u8::is_ascii_digit)
140        && bytes[offset + 16] == b':'
141        && bytes[offset + 17..offset + 19]
142            .iter()
143            .all(u8::is_ascii_digit)
144        && bytes[offset + 19] == b'.'
145        && bytes[offset + 20..offset + 26]
146            .iter()
147            .all(u8::is_ascii_digit)
148        && bytes[offset + 26] == b' ')
149    {
150        return false;
151    }
152    // Idle percentage is optional — older/eSInet FS builds emit "[LEVEL]"
153    // directly after the microsecond timestamp (switch_log.c version difference).
154    let rest = &bytes[offset + 27..];
155    if rest[0] == b'[' {
156        return true;
157    }
158    // Otherwise it starts with the idle %: digit, % within 6 bytes, then " ["
159    if !rest[0].is_ascii_digit() {
160        return false;
161    }
162    let Some(pct_pos) = rest[..rest.len().min(7)].iter().position(|&b| b == b'%') else {
163        return false;
164    };
165    rest.len() > pct_pos + 2 && rest[pct_pos + 1] == b' ' && rest[pct_pos + 2] == b'['
166}
167
168/// Try to parse idle percentage from the start of `rest`.
169///
170/// The idle percentage appears immediately after the timestamp, starts with a
171/// digit, contains only digits and dots, and the `%` falls within the first 7
172/// bytes (max value: `"100.00%"`). When absent (some FS versions/configurations
173/// omit it), `rest` starts with `[LEVEL]` instead.
174///
175/// Returns `(Some(idle_pct), remaining)` on success, or `(None, rest)` unchanged.
176fn parse_idle_pct(rest: &str) -> (Option<&str>, &str) {
177    let bytes = rest.as_bytes();
178    if bytes.is_empty() || !bytes[0].is_ascii_digit() {
179        return (None, rest);
180    }
181    let search_len = rest.len().min(7);
182    let pct_pos = match bytes[..search_len].iter().position(|&b| b == b'%') {
183        Some(p) => p,
184        None => return (None, rest),
185    };
186    if !bytes[..pct_pos]
187        .iter()
188        .all(|&b| b.is_ascii_digit() || b == b'.')
189    {
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 parse_timestamped_fields(
202    s: &str,
203) -> (
204    Option<&str>,
205    Option<&str>,
206    Option<LogLevel>,
207    Option<&str>,
208    &str,
209) {
210    if s.len() < 27 {
211        return (None, None, None, None, s);
212    }
213    let timestamp = &s[0..26];
214    let rest = &s[27..];
215
216    let (idle_pct, rest) = parse_idle_pct(rest);
217
218    let bracket_end = match rest.find(']') {
219        Some(p) => p,
220        None => return (Some(timestamp), idle_pct, None, None, rest),
221    };
222    let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
223
224    if rest.len() < bracket_end + 3 {
225        return (Some(timestamp), idle_pct, level, None, "");
226    }
227    let rest = &rest[bracket_end + 2..];
228
229    let source_end = rest.find(' ').unwrap_or(rest.len());
230    let source = &rest[0..source_end];
231    let message = if source_end < rest.len() {
232        &rest[source_end + 1..]
233    } else {
234        ""
235    };
236
237    (Some(timestamp), idle_pct, level, Some(source), message)
238}
239
240/// Layer 1 entry point: classify a single line and extract its fields.
241///
242/// Pure function — no state, no allocation. All returned string slices borrow
243/// from the input. Use [`classify_message`](crate::classify_message) on the
244/// `message` field for semantic classification.
245pub fn parse_line(line: &str) -> RawLine<'_> {
246    if line.trim().is_empty() {
247        return RawLine {
248            uuid: None,
249            timestamp: None,
250            idle_pct: None,
251            level: None,
252            source: None,
253            message: line,
254            kind: LineKind::Empty,
255        };
256    }
257
258    let bytes = line.as_bytes();
259
260    if is_uuid_at(bytes, 0) {
261        let uuid = &line[0..UUID_LEN];
262        let after_uuid = &line[UUID_PREFIX_LEN..];
263
264        if is_date_at(bytes, UUID_PREFIX_LEN) {
265            let (timestamp, idle_pct, level, source, message) =
266                parse_timestamped_fields(after_uuid);
267            return RawLine {
268                uuid: Some(uuid),
269                timestamp,
270                idle_pct,
271                level,
272                source,
273                message,
274                kind: LineKind::Full,
275            };
276        }
277
278        return RawLine {
279            uuid: Some(uuid),
280            timestamp: None,
281            idle_pct: None,
282            level: None,
283            source: None,
284            message: after_uuid,
285            kind: LineKind::UuidContinuation,
286        };
287    }
288
289    if is_date_at(bytes, 0) {
290        let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
291        let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
292            (Some(&message[0..UUID_LEN]), &message[UUID_PREFIX_LEN..])
293        } else {
294            (None, message)
295        };
296        return RawLine {
297            uuid,
298            timestamp,
299            idle_pct,
300            level,
301            source,
302            message,
303            kind: LineKind::System,
304        };
305    }
306
307    if let Some(uuid_start) = find_uuid_in(bytes) {
308        let uuid = &line[uuid_start..uuid_start + UUID_LEN];
309        let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
310            &line[uuid_start + UUID_PREFIX_LEN..]
311        } else {
312            ""
313        };
314        return RawLine {
315            uuid: Some(uuid),
316            timestamp: None,
317            idle_pct: None,
318            level: None,
319            source: None,
320            message,
321            kind: LineKind::Truncated,
322        };
323    }
324
325    RawLine {
326        uuid: None,
327        timestamp: None,
328        idle_pct: None,
329        level: None,
330        source: None,
331        message: line,
332        kind: LineKind::BareContinuation,
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
341
342    // --- Format A (Full) ---
343
344    #[test]
345    fn full_line_all_fields() {
346        let line = format!(
347            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
348        );
349        let parsed = parse_line(&line);
350        assert_eq!(parsed.kind, LineKind::Full);
351        assert_eq!(parsed.uuid, Some(UUID1));
352        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
353        assert_eq!(parsed.idle_pct, Some("95.97%"));
354        assert_eq!(parsed.level, Some(LogLevel::Debug));
355        assert_eq!(parsed.source, Some("sofia.c:100"));
356        assert_eq!(parsed.message, "Test message here");
357    }
358
359    #[test]
360    fn full_line_each_level() {
361        for (name, expected) in [
362            ("DEBUG", LogLevel::Debug),
363            ("INFO", LogLevel::Info),
364            ("NOTICE", LogLevel::Notice),
365            ("WARNING", LogLevel::Warning),
366            ("ERR", LogLevel::Err),
367            ("CRIT", LogLevel::Crit),
368            ("ALERT", LogLevel::Alert),
369            ("CONSOLE", LogLevel::Console),
370        ] {
371            let line =
372                format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
373            let parsed = parse_line(&line);
374            assert_eq!(parsed.kind, LineKind::Full);
375            assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
376        }
377    }
378
379    #[test]
380    fn full_line_high_idle() {
381        let line =
382            format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
383        let parsed = parse_line(&line);
384        assert_eq!(parsed.idle_pct, Some("99.99%"));
385    }
386
387    #[test]
388    fn full_line_low_idle() {
389        let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
390        let parsed = parse_line(&line);
391        assert_eq!(parsed.idle_pct, Some("0.00%"));
392    }
393
394    #[test]
395    fn full_line_long_message() {
396        let line = format!(
397            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
398        );
399        let parsed = parse_line(&line);
400        assert_eq!(
401            parsed.message,
402            "Channel [sofia/internal] key=val:123 (test) {braces}"
403        );
404    }
405
406    // --- Format B (System) ---
407
408    #[test]
409    fn system_line_no_uuid() {
410        let line =
411            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
412        let parsed = parse_line(line);
413        assert_eq!(parsed.kind, LineKind::System);
414        assert_eq!(parsed.uuid, None);
415        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
416        assert_eq!(parsed.idle_pct, Some("95.97%"));
417        assert_eq!(parsed.level, Some(LogLevel::Info));
418        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
419        assert_eq!(parsed.message, "Event Socket command");
420    }
421
422    #[test]
423    fn system_line_with_embedded_uuid() {
424        let line = format!(
425            "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
426        );
427        let parsed = parse_line(&line);
428        assert_eq!(parsed.kind, LineKind::System);
429        assert_eq!(parsed.uuid, Some(UUID1));
430        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
431        assert_eq!(parsed.level, Some(LogLevel::Debug));
432        assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
433        assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
434    }
435
436    #[test]
437    fn system_line_with_embedded_uuid_empty_message() {
438        let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
439        let parsed = parse_line(&line);
440        assert_eq!(parsed.kind, LineKind::System);
441        assert_eq!(parsed.uuid, Some(UUID1));
442        assert_eq!(parsed.message, "");
443    }
444
445    #[test]
446    fn system_line_without_embedded_uuid() {
447        let line =
448            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
449        let parsed = parse_line(line);
450        assert_eq!(parsed.kind, LineKind::System);
451        assert_eq!(parsed.uuid, None);
452        assert_eq!(parsed.message, "Event Socket command");
453    }
454
455    #[test]
456    fn system_line_event_socket() {
457        let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
458        let parsed = parse_line(line);
459        assert_eq!(parsed.kind, LineKind::System);
460        assert_eq!(parsed.level, Some(LogLevel::Notice));
461        assert_eq!(parsed.message, "New log started.");
462    }
463
464    // --- Format C (UuidContinuation) ---
465
466    #[test]
467    fn uuid_continuation_dialplan() {
468        let line =
469            format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
470        let parsed = parse_line(&line);
471        assert_eq!(parsed.kind, LineKind::UuidContinuation);
472        assert_eq!(parsed.uuid, Some(UUID1));
473        assert_eq!(parsed.timestamp, None);
474        assert_eq!(parsed.level, None);
475        assert_eq!(
476            parsed.message,
477            "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
478        );
479    }
480
481    #[test]
482    fn uuid_continuation_execute() {
483        let line =
484            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
485        let parsed = parse_line(&line);
486        assert_eq!(parsed.kind, LineKind::UuidContinuation);
487        assert_eq!(parsed.uuid, Some(UUID1));
488        assert_eq!(
489            parsed.message,
490            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
491        );
492    }
493
494    #[test]
495    fn uuid_continuation_channel_var() {
496        let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
497        let parsed = parse_line(&line);
498        assert_eq!(parsed.kind, LineKind::UuidContinuation);
499        assert_eq!(parsed.uuid, Some(UUID1));
500        assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
501    }
502
503    #[test]
504    fn uuid_continuation_variable() {
505        let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
506        let parsed = parse_line(&line);
507        assert_eq!(parsed.kind, LineKind::UuidContinuation);
508        assert_eq!(parsed.uuid, Some(UUID1));
509        assert_eq!(parsed.message, "variable_sip_call_id: [test123@192.0.2.1]");
510    }
511
512    #[test]
513    fn uuid_continuation_blank() {
514        let line = format!("{UUID1} ");
515        let parsed = parse_line(&line);
516        assert_eq!(parsed.kind, LineKind::UuidContinuation);
517        assert_eq!(parsed.uuid, Some(UUID1));
518        assert_eq!(parsed.message, "");
519    }
520
521    // --- Format D (BareContinuation) ---
522
523    #[test]
524    fn bare_variable() {
525        let line = "variable_foo: [bar]";
526        let parsed = parse_line(line);
527        assert_eq!(parsed.kind, LineKind::BareContinuation);
528        assert_eq!(parsed.uuid, None);
529        assert_eq!(parsed.message, "variable_foo: [bar]");
530    }
531
532    #[test]
533    fn bare_sdp_origin() {
534        let line = "o=- 1234 5678 IN IP4 192.0.2.1";
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_sdp_media() {
542        let line = "m=audio 47758 RTP/AVP 0 101";
543        let parsed = parse_line(line);
544        assert_eq!(parsed.kind, LineKind::BareContinuation);
545        assert_eq!(parsed.message, line);
546    }
547
548    #[test]
549    fn bare_sdp_attribute() {
550        let line = "a=rtpmap:0 PCMU/8000";
551        let parsed = parse_line(line);
552        assert_eq!(parsed.kind, LineKind::BareContinuation);
553        assert_eq!(parsed.message, line);
554    }
555
556    #[test]
557    fn bare_closing_bracket() {
558        let line = "]";
559        let parsed = parse_line(line);
560        assert_eq!(parsed.kind, LineKind::BareContinuation);
561        assert_eq!(parsed.message, "]");
562    }
563
564    #[test]
565    fn bare_empty_line() {
566        let parsed = parse_line("");
567        assert_eq!(parsed.kind, LineKind::Empty);
568        assert_eq!(parsed.message, "");
569    }
570
571    // --- Format E (Truncated) ---
572
573    #[test]
574    fn truncated_varia_prefix() {
575        let line = format!(
576            "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
577        );
578        let parsed = parse_line(&line);
579        assert_eq!(parsed.kind, LineKind::Truncated);
580        assert_eq!(parsed.uuid, Some(UUID1));
581        assert_eq!(
582            parsed.message,
583            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
584        );
585    }
586
587    #[test]
588    fn truncated_variab_prefix() {
589        let line = format!(
590            "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
591        );
592        let parsed = parse_line(&line);
593        assert_eq!(parsed.kind, LineKind::Truncated);
594        assert_eq!(parsed.uuid, Some(UUID1));
595    }
596
597    #[test]
598    fn truncated_var_prefix() {
599        let line =
600            format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
601        let parsed = parse_line(&line);
602        assert_eq!(parsed.kind, LineKind::Truncated);
603        assert_eq!(parsed.uuid, Some(UUID1));
604    }
605
606    #[test]
607    fn truncated_variable_prefix() {
608        let line = format!(
609            "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
610        );
611        let parsed = parse_line(&line);
612        assert_eq!(parsed.kind, LineKind::Truncated);
613        assert_eq!(parsed.uuid, Some(UUID1));
614    }
615
616    // --- is_log_header_at (collision split marker) ---
617
618    #[test]
619    fn log_header_with_idle_pct() {
620        let line = "2024-04-02 10:31:28.785679 98.03% [NOTICE] sofia.c:1114 Hangup";
621        assert!(is_log_header_at(line.as_bytes(), 0));
622    }
623
624    #[test]
625    fn log_header_no_idle_pct() {
626        // Older/eSInet FS builds emit "[LEVEL]" directly after the timestamp.
627        let line = "2024-04-02 10:31:28.785679 [NOTICE] sofia.c:1114 Hangup";
628        assert!(is_log_header_at(line.as_bytes(), 0));
629    }
630
631    #[test]
632    fn log_header_no_idle_pct_at_offset() {
633        let line = "Session does not exist, aborting REFER.2024-04-02 10:31:28.785679 [WARNING] sofia_presence.c:4546 x";
634        let offset = line.find("2024").unwrap();
635        assert!(is_log_header_at(line.as_bytes(), offset));
636    }
637
638    #[test]
639    fn log_header_rejects_non_header() {
640        let line = "2024-04-02 not a real timestamp here";
641        assert!(!is_log_header_at(line.as_bytes(), 0));
642    }
643
644    // --- No idle percentage (issue #1) ---
645
646    #[test]
647    fn full_line_no_idle_pct() {
648        let line = format!(
649            "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
650        );
651        let parsed = parse_line(&line);
652        assert_eq!(parsed.kind, LineKind::Full);
653        assert_eq!(parsed.uuid, Some(UUID1));
654        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
655        assert_eq!(parsed.idle_pct, None);
656        assert_eq!(parsed.level, Some(LogLevel::Notice));
657        assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
658        assert_eq!(parsed.message, "Session 3178948 ended");
659    }
660
661    #[test]
662    fn full_line_no_idle_pct_url_encoded_percent() {
663        let line = format!(
664            "{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"
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!(
674            parsed.message,
675            "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
676        );
677    }
678
679    #[test]
680    fn system_line_no_idle_pct() {
681        let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
682        let parsed = parse_line(line);
683        assert_eq!(parsed.kind, LineKind::System);
684        assert_eq!(parsed.uuid, None);
685        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
686        assert_eq!(parsed.idle_pct, None);
687        assert_eq!(parsed.level, Some(LogLevel::Info));
688        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
689        assert_eq!(parsed.message, "Event Socket command");
690    }
691
692    #[test]
693    fn full_line_no_idle_pct_hangup_url_encoded() {
694        let line = format!(
695            "{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]"
696        );
697        let parsed = parse_line(&line);
698        assert_eq!(parsed.kind, LineKind::Full);
699        assert_eq!(parsed.idle_pct, None);
700        assert_eq!(parsed.level, Some(LogLevel::Notice));
701        assert_eq!(parsed.source, Some("sofia.c:1089"));
702        assert_eq!(
703            parsed.message,
704            "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
705        );
706    }
707
708    // --- Edge cases ---
709
710    #[test]
711    fn not_uuid_36_chars() {
712        let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
713        let parsed = parse_line(line);
714        assert_eq!(parsed.kind, LineKind::BareContinuation);
715        assert_eq!(parsed.message, line);
716    }
717
718    #[test]
719    fn uuid_in_message_not_prefix() {
720        let line =
721            format!("This is some log message body with extra context then {UUID1} appears here");
722        let parsed = parse_line(&line);
723        assert_eq!(parsed.kind, LineKind::BareContinuation);
724        assert_eq!(parsed.message, line.as_str());
725    }
726
727    #[test]
728    fn whitespace_only_is_empty() {
729        let parsed = parse_line("   \t  ");
730        assert_eq!(parsed.kind, LineKind::Empty);
731    }
732}