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/// Used by Layer 2 to detect same-line collisions where multiple log entries
114/// were concatenated without a newline (thread contention on file write).
115pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
116    // Minimum: 27-byte timestamp + space + "0% [" = 31 bytes
117    if bytes.len() < offset + 31 {
118        return false;
119    }
120    // YYYY-MM-DD HH:MM:SS.UUUUUU (26 bytes + space)
121    if !(bytes[offset..offset + 4].iter().all(u8::is_ascii_digit)
122        && bytes[offset + 4] == b'-'
123        && bytes[offset + 5..offset + 7].iter().all(u8::is_ascii_digit)
124        && bytes[offset + 7] == b'-'
125        && bytes[offset + 8..offset + 10]
126            .iter()
127            .all(u8::is_ascii_digit)
128        && bytes[offset + 10] == b' '
129        && bytes[offset + 11..offset + 13]
130            .iter()
131            .all(u8::is_ascii_digit)
132        && bytes[offset + 13] == b':'
133        && bytes[offset + 14..offset + 16]
134            .iter()
135            .all(u8::is_ascii_digit)
136        && bytes[offset + 16] == b':'
137        && bytes[offset + 17..offset + 19]
138            .iter()
139            .all(u8::is_ascii_digit)
140        && bytes[offset + 19] == b'.'
141        && bytes[offset + 20..offset + 26]
142            .iter()
143            .all(u8::is_ascii_digit)
144        && bytes[offset + 26] == b' ')
145    {
146        return false;
147    }
148    // Idle percentage: starts with digit, has % within 6 bytes, then " ["
149    let rest = &bytes[offset + 27..];
150    if !rest[0].is_ascii_digit() {
151        return false;
152    }
153    let Some(pct_pos) = rest[..rest.len().min(7)].iter().position(|&b| b == b'%') else {
154        return false;
155    };
156    rest.len() > pct_pos + 2 && rest[pct_pos + 1] == b' ' && rest[pct_pos + 2] == b'['
157}
158
159/// Try to parse idle percentage from the start of `rest`.
160///
161/// The idle percentage appears immediately after the timestamp, starts with a
162/// digit, contains only digits and dots, and the `%` falls within the first 7
163/// bytes (max value: `"100.00%"`). When absent (some FS versions/configurations
164/// omit it), `rest` starts with `[LEVEL]` instead.
165///
166/// Returns `(Some(idle_pct), remaining)` on success, or `(None, rest)` unchanged.
167fn parse_idle_pct(rest: &str) -> (Option<&str>, &str) {
168    let bytes = rest.as_bytes();
169    if bytes.is_empty() || !bytes[0].is_ascii_digit() {
170        return (None, rest);
171    }
172    let search_len = rest.len().min(7);
173    let pct_pos = match bytes[..search_len].iter().position(|&b| b == b'%') {
174        Some(p) => p,
175        None => return (None, rest),
176    };
177    if !bytes[..pct_pos]
178        .iter()
179        .all(|&b| b.is_ascii_digit() || b == b'.')
180    {
181        return (None, rest);
182    }
183    let idle_pct = &rest[0..=pct_pos];
184    let after = if rest.len() > pct_pos + 2 {
185        &rest[pct_pos + 2..]
186    } else {
187        ""
188    };
189    (Some(idle_pct), after)
190}
191
192fn parse_timestamped_fields(
193    s: &str,
194) -> (
195    Option<&str>,
196    Option<&str>,
197    Option<LogLevel>,
198    Option<&str>,
199    &str,
200) {
201    if s.len() < 27 {
202        return (None, None, None, None, s);
203    }
204    let timestamp = &s[0..26];
205    let rest = &s[27..];
206
207    let (idle_pct, rest) = parse_idle_pct(rest);
208
209    let bracket_end = match rest.find(']') {
210        Some(p) => p,
211        None => return (Some(timestamp), idle_pct, None, None, rest),
212    };
213    let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
214
215    if rest.len() < bracket_end + 3 {
216        return (Some(timestamp), idle_pct, level, None, "");
217    }
218    let rest = &rest[bracket_end + 2..];
219
220    let source_end = rest.find(' ').unwrap_or(rest.len());
221    let source = &rest[0..source_end];
222    let message = if source_end < rest.len() {
223        &rest[source_end + 1..]
224    } else {
225        ""
226    };
227
228    (Some(timestamp), idle_pct, level, Some(source), message)
229}
230
231/// Layer 1 entry point: classify a single line and extract its fields.
232///
233/// Pure function — no state, no allocation. All returned string slices borrow
234/// from the input. Use [`classify_message`](crate::classify_message) on the
235/// `message` field for semantic classification.
236pub fn parse_line(line: &str) -> RawLine<'_> {
237    if line.trim().is_empty() {
238        return RawLine {
239            uuid: None,
240            timestamp: None,
241            idle_pct: None,
242            level: None,
243            source: None,
244            message: line,
245            kind: LineKind::Empty,
246        };
247    }
248
249    let bytes = line.as_bytes();
250
251    if is_uuid_at(bytes, 0) {
252        let uuid = &line[0..UUID_LEN];
253        let after_uuid = &line[UUID_PREFIX_LEN..];
254
255        if is_date_at(bytes, UUID_PREFIX_LEN) {
256            let (timestamp, idle_pct, level, source, message) =
257                parse_timestamped_fields(after_uuid);
258            return RawLine {
259                uuid: Some(uuid),
260                timestamp,
261                idle_pct,
262                level,
263                source,
264                message,
265                kind: LineKind::Full,
266            };
267        }
268
269        return RawLine {
270            uuid: Some(uuid),
271            timestamp: None,
272            idle_pct: None,
273            level: None,
274            source: None,
275            message: after_uuid,
276            kind: LineKind::UuidContinuation,
277        };
278    }
279
280    if is_date_at(bytes, 0) {
281        let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
282        let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
283            (Some(&message[0..UUID_LEN]), &message[UUID_PREFIX_LEN..])
284        } else {
285            (None, message)
286        };
287        return RawLine {
288            uuid,
289            timestamp,
290            idle_pct,
291            level,
292            source,
293            message,
294            kind: LineKind::System,
295        };
296    }
297
298    if let Some(uuid_start) = find_uuid_in(bytes) {
299        let uuid = &line[uuid_start..uuid_start + UUID_LEN];
300        let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
301            &line[uuid_start + UUID_PREFIX_LEN..]
302        } else {
303            ""
304        };
305        return RawLine {
306            uuid: Some(uuid),
307            timestamp: None,
308            idle_pct: None,
309            level: None,
310            source: None,
311            message,
312            kind: LineKind::Truncated,
313        };
314    }
315
316    RawLine {
317        uuid: None,
318        timestamp: None,
319        idle_pct: None,
320        level: None,
321        source: None,
322        message: line,
323        kind: LineKind::BareContinuation,
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
332
333    // --- Format A (Full) ---
334
335    #[test]
336    fn full_line_all_fields() {
337        let line = format!(
338            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
339        );
340        let parsed = parse_line(&line);
341        assert_eq!(parsed.kind, LineKind::Full);
342        assert_eq!(parsed.uuid, Some(UUID1));
343        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
344        assert_eq!(parsed.idle_pct, Some("95.97%"));
345        assert_eq!(parsed.level, Some(LogLevel::Debug));
346        assert_eq!(parsed.source, Some("sofia.c:100"));
347        assert_eq!(parsed.message, "Test message here");
348    }
349
350    #[test]
351    fn full_line_each_level() {
352        for (name, expected) in [
353            ("DEBUG", LogLevel::Debug),
354            ("INFO", LogLevel::Info),
355            ("NOTICE", LogLevel::Notice),
356            ("WARNING", LogLevel::Warning),
357            ("ERR", LogLevel::Err),
358            ("CRIT", LogLevel::Crit),
359            ("ALERT", LogLevel::Alert),
360            ("CONSOLE", LogLevel::Console),
361        ] {
362            let line =
363                format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
364            let parsed = parse_line(&line);
365            assert_eq!(parsed.kind, LineKind::Full);
366            assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
367        }
368    }
369
370    #[test]
371    fn full_line_high_idle() {
372        let line =
373            format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
374        let parsed = parse_line(&line);
375        assert_eq!(parsed.idle_pct, Some("99.99%"));
376    }
377
378    #[test]
379    fn full_line_low_idle() {
380        let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
381        let parsed = parse_line(&line);
382        assert_eq!(parsed.idle_pct, Some("0.00%"));
383    }
384
385    #[test]
386    fn full_line_long_message() {
387        let line = format!(
388            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
389        );
390        let parsed = parse_line(&line);
391        assert_eq!(
392            parsed.message,
393            "Channel [sofia/internal] key=val:123 (test) {braces}"
394        );
395    }
396
397    // --- Format B (System) ---
398
399    #[test]
400    fn system_line_no_uuid() {
401        let line =
402            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
403        let parsed = parse_line(line);
404        assert_eq!(parsed.kind, LineKind::System);
405        assert_eq!(parsed.uuid, None);
406        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
407        assert_eq!(parsed.idle_pct, Some("95.97%"));
408        assert_eq!(parsed.level, Some(LogLevel::Info));
409        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
410        assert_eq!(parsed.message, "Event Socket command");
411    }
412
413    #[test]
414    fn system_line_with_embedded_uuid() {
415        let line = format!(
416            "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
417        );
418        let parsed = parse_line(&line);
419        assert_eq!(parsed.kind, LineKind::System);
420        assert_eq!(parsed.uuid, Some(UUID1));
421        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
422        assert_eq!(parsed.level, Some(LogLevel::Debug));
423        assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
424        assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
425    }
426
427    #[test]
428    fn system_line_with_embedded_uuid_empty_message() {
429        let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
430        let parsed = parse_line(&line);
431        assert_eq!(parsed.kind, LineKind::System);
432        assert_eq!(parsed.uuid, Some(UUID1));
433        assert_eq!(parsed.message, "");
434    }
435
436    #[test]
437    fn system_line_without_embedded_uuid() {
438        let line =
439            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
440        let parsed = parse_line(line);
441        assert_eq!(parsed.kind, LineKind::System);
442        assert_eq!(parsed.uuid, None);
443        assert_eq!(parsed.message, "Event Socket command");
444    }
445
446    #[test]
447    fn system_line_event_socket() {
448        let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
449        let parsed = parse_line(line);
450        assert_eq!(parsed.kind, LineKind::System);
451        assert_eq!(parsed.level, Some(LogLevel::Notice));
452        assert_eq!(parsed.message, "New log started.");
453    }
454
455    // --- Format C (UuidContinuation) ---
456
457    #[test]
458    fn uuid_continuation_dialplan() {
459        let line =
460            format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
461        let parsed = parse_line(&line);
462        assert_eq!(parsed.kind, LineKind::UuidContinuation);
463        assert_eq!(parsed.uuid, Some(UUID1));
464        assert_eq!(parsed.timestamp, None);
465        assert_eq!(parsed.level, None);
466        assert_eq!(
467            parsed.message,
468            "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
469        );
470    }
471
472    #[test]
473    fn uuid_continuation_execute() {
474        let line =
475            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
476        let parsed = parse_line(&line);
477        assert_eq!(parsed.kind, LineKind::UuidContinuation);
478        assert_eq!(parsed.uuid, Some(UUID1));
479        assert_eq!(
480            parsed.message,
481            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
482        );
483    }
484
485    #[test]
486    fn uuid_continuation_channel_var() {
487        let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
488        let parsed = parse_line(&line);
489        assert_eq!(parsed.kind, LineKind::UuidContinuation);
490        assert_eq!(parsed.uuid, Some(UUID1));
491        assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
492    }
493
494    #[test]
495    fn uuid_continuation_variable() {
496        let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
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, "variable_sip_call_id: [test123@192.0.2.1]");
501    }
502
503    #[test]
504    fn uuid_continuation_blank() {
505        let line = format!("{UUID1} ");
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, "");
510    }
511
512    // --- Format D (BareContinuation) ---
513
514    #[test]
515    fn bare_variable() {
516        let line = "variable_foo: [bar]";
517        let parsed = parse_line(line);
518        assert_eq!(parsed.kind, LineKind::BareContinuation);
519        assert_eq!(parsed.uuid, None);
520        assert_eq!(parsed.message, "variable_foo: [bar]");
521    }
522
523    #[test]
524    fn bare_sdp_origin() {
525        let line = "o=- 1234 5678 IN IP4 192.0.2.1";
526        let parsed = parse_line(line);
527        assert_eq!(parsed.kind, LineKind::BareContinuation);
528        assert_eq!(parsed.message, line);
529    }
530
531    #[test]
532    fn bare_sdp_media() {
533        let line = "m=audio 47758 RTP/AVP 0 101";
534        let parsed = parse_line(line);
535        assert_eq!(parsed.kind, LineKind::BareContinuation);
536        assert_eq!(parsed.message, line);
537    }
538
539    #[test]
540    fn bare_sdp_attribute() {
541        let line = "a=rtpmap:0 PCMU/8000";
542        let parsed = parse_line(line);
543        assert_eq!(parsed.kind, LineKind::BareContinuation);
544        assert_eq!(parsed.message, line);
545    }
546
547    #[test]
548    fn bare_closing_bracket() {
549        let line = "]";
550        let parsed = parse_line(line);
551        assert_eq!(parsed.kind, LineKind::BareContinuation);
552        assert_eq!(parsed.message, "]");
553    }
554
555    #[test]
556    fn bare_empty_line() {
557        let parsed = parse_line("");
558        assert_eq!(parsed.kind, LineKind::Empty);
559        assert_eq!(parsed.message, "");
560    }
561
562    // --- Format E (Truncated) ---
563
564    #[test]
565    fn truncated_varia_prefix() {
566        let line = format!(
567            "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
568        );
569        let parsed = parse_line(&line);
570        assert_eq!(parsed.kind, LineKind::Truncated);
571        assert_eq!(parsed.uuid, Some(UUID1));
572        assert_eq!(
573            parsed.message,
574            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
575        );
576    }
577
578    #[test]
579    fn truncated_variab_prefix() {
580        let line = format!(
581            "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
582        );
583        let parsed = parse_line(&line);
584        assert_eq!(parsed.kind, LineKind::Truncated);
585        assert_eq!(parsed.uuid, Some(UUID1));
586    }
587
588    #[test]
589    fn truncated_var_prefix() {
590        let line =
591            format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
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_variable_prefix() {
599        let line = format!(
600            "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
601        );
602        let parsed = parse_line(&line);
603        assert_eq!(parsed.kind, LineKind::Truncated);
604        assert_eq!(parsed.uuid, Some(UUID1));
605    }
606
607    // --- No idle percentage (issue #1) ---
608
609    #[test]
610    fn full_line_no_idle_pct() {
611        let line = format!(
612            "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
613        );
614        let parsed = parse_line(&line);
615        assert_eq!(parsed.kind, LineKind::Full);
616        assert_eq!(parsed.uuid, Some(UUID1));
617        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
618        assert_eq!(parsed.idle_pct, None);
619        assert_eq!(parsed.level, Some(LogLevel::Notice));
620        assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
621        assert_eq!(parsed.message, "Session 3178948 ended");
622    }
623
624    #[test]
625    fn full_line_no_idle_pct_url_encoded_percent() {
626        let line = format!(
627            "{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"
628        );
629        let parsed = parse_line(&line);
630        assert_eq!(parsed.kind, LineKind::Full);
631        assert_eq!(parsed.uuid, Some(UUID1));
632        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
633        assert_eq!(parsed.idle_pct, None);
634        assert_eq!(parsed.level, Some(LogLevel::Notice));
635        assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
636        assert_eq!(
637            parsed.message,
638            "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
639        );
640    }
641
642    #[test]
643    fn system_line_no_idle_pct() {
644        let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
645        let parsed = parse_line(line);
646        assert_eq!(parsed.kind, LineKind::System);
647        assert_eq!(parsed.uuid, None);
648        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
649        assert_eq!(parsed.idle_pct, None);
650        assert_eq!(parsed.level, Some(LogLevel::Info));
651        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
652        assert_eq!(parsed.message, "Event Socket command");
653    }
654
655    #[test]
656    fn full_line_no_idle_pct_hangup_url_encoded() {
657        let line = format!(
658            "{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]"
659        );
660        let parsed = parse_line(&line);
661        assert_eq!(parsed.kind, LineKind::Full);
662        assert_eq!(parsed.idle_pct, None);
663        assert_eq!(parsed.level, Some(LogLevel::Notice));
664        assert_eq!(parsed.source, Some("sofia.c:1089"));
665        assert_eq!(
666            parsed.message,
667            "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
668        );
669    }
670
671    // --- Edge cases ---
672
673    #[test]
674    fn not_uuid_36_chars() {
675        let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
676        let parsed = parse_line(line);
677        assert_eq!(parsed.kind, LineKind::BareContinuation);
678        assert_eq!(parsed.message, line);
679    }
680
681    #[test]
682    fn uuid_in_message_not_prefix() {
683        let line =
684            format!("This is some log message body with extra context then {UUID1} appears here");
685        let parsed = parse_line(&line);
686        assert_eq!(parsed.kind, LineKind::BareContinuation);
687        assert_eq!(parsed.message, line.as_str());
688    }
689
690    #[test]
691    fn whitespace_only_is_empty() {
692        let parsed = parse_line("   \t  ");
693        assert_eq!(parsed.kind, LineKind::Empty);
694    }
695}