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