Skip to main content

freeswitch_log_parser/
line.rs

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