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    // The field is written "% ", so anything other than a space after the sign
187    // is not one — `is_log_header_at` requires the same, and two validators
188    // disagreeing on the same format is how a corrupt line gets read two ways.
189    if rest.len() > pct_pos + 1 && bytes[pct_pos + 1] != b' ' {
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
201/// The header slices at bytes 26/27 (timestamp + separating space) are only
202/// valid when both land on char boundaries — a multi-byte char straddling
203/// either offset means the line is not a Format A/B header.
204fn header_boundaries_ok(s: &str) -> bool {
205    s.len() < 27 || (s.is_char_boundary(26) && s.is_char_boundary(27))
206}
207
208fn parse_timestamped_fields(
209    s: &str,
210) -> (
211    Option<&str>,
212    Option<&str>,
213    Option<LogLevel>,
214    Option<&str>,
215    &str,
216) {
217    if s.len() < 27 {
218        return (None, None, None, None, s);
219    }
220    let timestamp = &s[0..26];
221    let rest = &s[27..];
222
223    let (idle_pct, rest) = parse_idle_pct(rest);
224
225    let bracket_end = match rest.find(']') {
226        Some(p) => p,
227        None => return (Some(timestamp), idle_pct, None, None, rest),
228    };
229    let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
230
231    if rest.len() < bracket_end + 3 || !rest.is_char_boundary(bracket_end + 2) {
232        return (Some(timestamp), idle_pct, level, None, "");
233    }
234    let rest = &rest[bracket_end + 2..];
235
236    let source_end = rest.find(' ').unwrap_or(rest.len());
237    let source = &rest[0..source_end];
238    let message = if source_end < rest.len() {
239        &rest[source_end + 1..]
240    } else {
241        ""
242    };
243
244    (Some(timestamp), idle_pct, level, Some(source), message)
245}
246
247/// Layer 1 entry point: classify a single line and extract its fields.
248///
249/// Pure function — no state, no allocation. All returned string slices borrow
250/// from the input. Use [`classify_message`](crate::classify_message) on the
251/// `message` field for semantic classification.
252pub fn parse_line(line: &str) -> RawLine<'_> {
253    if line.trim().is_empty() {
254        return RawLine {
255            uuid: None,
256            timestamp: None,
257            idle_pct: None,
258            level: None,
259            source: None,
260            message: line,
261            kind: LineKind::Empty,
262        };
263    }
264
265    let bytes = line.as_bytes();
266
267    if is_uuid_at(bytes, 0) {
268        let uuid = &line[0..UUID_LEN];
269        let after_uuid = &line[UUID_PREFIX_LEN..];
270
271        if is_date_at(bytes, UUID_PREFIX_LEN) && header_boundaries_ok(after_uuid) {
272            let (timestamp, idle_pct, level, source, message) =
273                parse_timestamped_fields(after_uuid);
274            return RawLine {
275                uuid: Some(uuid),
276                timestamp,
277                idle_pct,
278                level,
279                source,
280                message,
281                kind: LineKind::Full,
282            };
283        }
284
285        return RawLine {
286            uuid: Some(uuid),
287            timestamp: None,
288            idle_pct: None,
289            level: None,
290            source: None,
291            message: after_uuid,
292            kind: LineKind::UuidContinuation,
293        };
294    }
295
296    if is_date_at(bytes, 0) && header_boundaries_ok(line) {
297        let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
298        let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
299            (Some(&message[0..UUID_LEN]), &message[UUID_PREFIX_LEN..])
300        } else {
301            (None, message)
302        };
303        return RawLine {
304            uuid,
305            timestamp,
306            idle_pct,
307            level,
308            source,
309            message,
310            kind: LineKind::System,
311        };
312    }
313
314    if let Some(uuid_start) = find_uuid_in(bytes) {
315        let uuid = &line[uuid_start..uuid_start + UUID_LEN];
316        let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
317            &line[uuid_start + UUID_PREFIX_LEN..]
318        } else {
319            ""
320        };
321        return RawLine {
322            uuid: Some(uuid),
323            timestamp: None,
324            idle_pct: None,
325            level: None,
326            source: None,
327            message,
328            kind: LineKind::Truncated,
329        };
330    }
331
332    RawLine {
333        uuid: None,
334        timestamp: None,
335        idle_pct: None,
336        level: None,
337        source: None,
338        message: line,
339        kind: LineKind::BareContinuation,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
348
349    // --- Format A (Full) ---
350
351    #[test]
352    fn full_line_all_fields() {
353        let line = format!(
354            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
355        );
356        let parsed = parse_line(&line);
357        assert_eq!(parsed.kind, LineKind::Full);
358        assert_eq!(parsed.uuid, Some(UUID1));
359        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
360        assert_eq!(parsed.idle_pct, Some("95.97%"));
361        assert_eq!(parsed.level, Some(LogLevel::Debug));
362        assert_eq!(parsed.source, Some("sofia.c:100"));
363        assert_eq!(parsed.message, "Test message here");
364    }
365
366    #[test]
367    fn full_line_each_level() {
368        for (name, expected) in [
369            ("DEBUG", LogLevel::Debug),
370            ("INFO", LogLevel::Info),
371            ("NOTICE", LogLevel::Notice),
372            ("WARNING", LogLevel::Warning),
373            ("ERR", LogLevel::Err),
374            ("CRIT", LogLevel::Crit),
375            ("ALERT", LogLevel::Alert),
376            ("CONSOLE", LogLevel::Console),
377        ] {
378            let line =
379                format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
380            let parsed = parse_line(&line);
381            assert_eq!(parsed.kind, LineKind::Full);
382            assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
383        }
384    }
385
386    #[test]
387    fn full_line_high_idle() {
388        let line =
389            format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
390        let parsed = parse_line(&line);
391        assert_eq!(parsed.idle_pct, Some("99.99%"));
392    }
393
394    #[test]
395    fn full_line_low_idle() {
396        let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
397        let parsed = parse_line(&line);
398        assert_eq!(parsed.idle_pct, Some("0.00%"));
399    }
400
401    /// The field is written `"% "`. Without the space it is not one, and
402    /// reporting it as one claimed a scheduler reading off a corrupt line.
403    #[test]
404    fn idle_pct_requires_the_space_after_the_sign() {
405        let line = format!("{UUID1} 2025-01-15 10:30:45.123456 9%X[DEBUG] sofia.c:100 Message");
406        assert_eq!(parse_line(&line).idle_pct, None);
407    }
408
409    #[test]
410    fn full_line_long_message() {
411        let line = format!(
412            "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
413        );
414        let parsed = parse_line(&line);
415        assert_eq!(
416            parsed.message,
417            "Channel [sofia/internal] key=val:123 (test) {braces}"
418        );
419    }
420
421    // --- Format B (System) ---
422
423    #[test]
424    fn system_line_no_uuid() {
425        let line =
426            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
427        let parsed = parse_line(line);
428        assert_eq!(parsed.kind, LineKind::System);
429        assert_eq!(parsed.uuid, None);
430        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
431        assert_eq!(parsed.idle_pct, Some("95.97%"));
432        assert_eq!(parsed.level, Some(LogLevel::Info));
433        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
434        assert_eq!(parsed.message, "Event Socket command");
435    }
436
437    #[test]
438    fn system_line_with_embedded_uuid() {
439        let line = format!(
440            "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
441        );
442        let parsed = parse_line(&line);
443        assert_eq!(parsed.kind, LineKind::System);
444        assert_eq!(parsed.uuid, Some(UUID1));
445        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
446        assert_eq!(parsed.level, Some(LogLevel::Debug));
447        assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
448        assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
449    }
450
451    #[test]
452    fn system_line_with_embedded_uuid_empty_message() {
453        let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
454        let parsed = parse_line(&line);
455        assert_eq!(parsed.kind, LineKind::System);
456        assert_eq!(parsed.uuid, Some(UUID1));
457        assert_eq!(parsed.message, "");
458    }
459
460    #[test]
461    fn system_line_without_embedded_uuid() {
462        let line =
463            "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
464        let parsed = parse_line(line);
465        assert_eq!(parsed.kind, LineKind::System);
466        assert_eq!(parsed.uuid, None);
467        assert_eq!(parsed.message, "Event Socket command");
468    }
469
470    #[test]
471    fn system_line_event_socket() {
472        let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
473        let parsed = parse_line(line);
474        assert_eq!(parsed.kind, LineKind::System);
475        assert_eq!(parsed.level, Some(LogLevel::Notice));
476        assert_eq!(parsed.message, "New log started.");
477    }
478
479    // --- Format C (UuidContinuation) ---
480
481    #[test]
482    fn uuid_continuation_dialplan() {
483        let line =
484            format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
485        let parsed = parse_line(&line);
486        assert_eq!(parsed.kind, LineKind::UuidContinuation);
487        assert_eq!(parsed.uuid, Some(UUID1));
488        assert_eq!(parsed.timestamp, None);
489        assert_eq!(parsed.level, None);
490        assert_eq!(
491            parsed.message,
492            "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
493        );
494    }
495
496    #[test]
497    fn uuid_continuation_execute() {
498        let line =
499            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
500        let parsed = parse_line(&line);
501        assert_eq!(parsed.kind, LineKind::UuidContinuation);
502        assert_eq!(parsed.uuid, Some(UUID1));
503        assert_eq!(
504            parsed.message,
505            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
506        );
507    }
508
509    #[test]
510    fn uuid_continuation_channel_var() {
511        let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
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, "Channel-State: [CS_EXECUTE]");
516    }
517
518    #[test]
519    fn uuid_continuation_variable() {
520        let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
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, "variable_sip_call_id: [test123@192.0.2.1]");
525    }
526
527    #[test]
528    fn uuid_continuation_blank() {
529        let line = format!("{UUID1} ");
530        let parsed = parse_line(&line);
531        assert_eq!(parsed.kind, LineKind::UuidContinuation);
532        assert_eq!(parsed.uuid, Some(UUID1));
533        assert_eq!(parsed.message, "");
534    }
535
536    // --- Format D (BareContinuation) ---
537
538    #[test]
539    fn bare_variable() {
540        let line = "variable_foo: [bar]";
541        let parsed = parse_line(line);
542        assert_eq!(parsed.kind, LineKind::BareContinuation);
543        assert_eq!(parsed.uuid, None);
544        assert_eq!(parsed.message, "variable_foo: [bar]");
545    }
546
547    #[test]
548    fn bare_sdp_origin() {
549        let line = "o=- 1234 5678 IN IP4 192.0.2.1";
550        let parsed = parse_line(line);
551        assert_eq!(parsed.kind, LineKind::BareContinuation);
552        assert_eq!(parsed.message, line);
553    }
554
555    #[test]
556    fn bare_sdp_media() {
557        let line = "m=audio 47758 RTP/AVP 0 101";
558        let parsed = parse_line(line);
559        assert_eq!(parsed.kind, LineKind::BareContinuation);
560        assert_eq!(parsed.message, line);
561    }
562
563    #[test]
564    fn bare_sdp_attribute() {
565        let line = "a=rtpmap:0 PCMU/8000";
566        let parsed = parse_line(line);
567        assert_eq!(parsed.kind, LineKind::BareContinuation);
568        assert_eq!(parsed.message, line);
569    }
570
571    #[test]
572    fn bare_closing_bracket() {
573        let line = "]";
574        let parsed = parse_line(line);
575        assert_eq!(parsed.kind, LineKind::BareContinuation);
576        assert_eq!(parsed.message, "]");
577    }
578
579    #[test]
580    fn bare_empty_line() {
581        let parsed = parse_line("");
582        assert_eq!(parsed.kind, LineKind::Empty);
583        assert_eq!(parsed.message, "");
584    }
585
586    // --- Format E (Truncated) ---
587
588    #[test]
589    fn truncated_varia_prefix() {
590        let line = format!(
591            "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
592        );
593        let parsed = parse_line(&line);
594        assert_eq!(parsed.kind, LineKind::Truncated);
595        assert_eq!(parsed.uuid, Some(UUID1));
596        assert_eq!(
597            parsed.message,
598            "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
599        );
600    }
601
602    #[test]
603    fn truncated_variab_prefix() {
604        let line = format!(
605            "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
606        );
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_var_prefix() {
614        let line =
615            format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
616        let parsed = parse_line(&line);
617        assert_eq!(parsed.kind, LineKind::Truncated);
618        assert_eq!(parsed.uuid, Some(UUID1));
619    }
620
621    #[test]
622    fn truncated_variable_prefix() {
623        let line = format!(
624            "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
625        );
626        let parsed = parse_line(&line);
627        assert_eq!(parsed.kind, LineKind::Truncated);
628        assert_eq!(parsed.uuid, Some(UUID1));
629    }
630
631    // --- is_log_header_at (collision split marker) ---
632
633    #[test]
634    fn log_header_with_idle_pct() {
635        let line = "2024-04-02 10:31:28.785679 98.03% [NOTICE] sofia.c:1114 Hangup";
636        assert!(is_log_header_at(line.as_bytes(), 0));
637    }
638
639    #[test]
640    fn log_header_no_idle_pct() {
641        // Older/eSInet FS builds emit "[LEVEL]" directly after the timestamp.
642        let line = "2024-04-02 10:31:28.785679 [NOTICE] sofia.c:1114 Hangup";
643        assert!(is_log_header_at(line.as_bytes(), 0));
644    }
645
646    #[test]
647    fn log_header_no_idle_pct_at_offset() {
648        let line = "Session does not exist, aborting REFER.2024-04-02 10:31:28.785679 [WARNING] sofia_presence.c:4546 x";
649        let offset = line.find("2024").unwrap();
650        assert!(is_log_header_at(line.as_bytes(), offset));
651    }
652
653    #[test]
654    fn log_header_rejects_non_header() {
655        let line = "2024-04-02 not a real timestamp here";
656        assert!(!is_log_header_at(line.as_bytes(), 0));
657    }
658
659    // --- No idle percentage (issue #1) ---
660
661    #[test]
662    fn full_line_no_idle_pct() {
663        let line = format!(
664            "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 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!(parsed.message, "Session 3178948 ended");
674    }
675
676    #[test]
677    fn full_line_no_idle_pct_url_encoded_percent() {
678        let line = format!(
679            "{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"
680        );
681        let parsed = parse_line(&line);
682        assert_eq!(parsed.kind, LineKind::Full);
683        assert_eq!(parsed.uuid, Some(UUID1));
684        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
685        assert_eq!(parsed.idle_pct, None);
686        assert_eq!(parsed.level, Some(LogLevel::Notice));
687        assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
688        assert_eq!(
689            parsed.message,
690            "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
691        );
692    }
693
694    #[test]
695    fn system_line_no_idle_pct() {
696        let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
697        let parsed = parse_line(line);
698        assert_eq!(parsed.kind, LineKind::System);
699        assert_eq!(parsed.uuid, None);
700        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
701        assert_eq!(parsed.idle_pct, None);
702        assert_eq!(parsed.level, Some(LogLevel::Info));
703        assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
704        assert_eq!(parsed.message, "Event Socket command");
705    }
706
707    #[test]
708    fn full_line_no_idle_pct_hangup_url_encoded() {
709        let line = format!(
710            "{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]"
711        );
712        let parsed = parse_line(&line);
713        assert_eq!(parsed.kind, LineKind::Full);
714        assert_eq!(parsed.idle_pct, None);
715        assert_eq!(parsed.level, Some(LogLevel::Notice));
716        assert_eq!(parsed.source, Some("sofia.c:1089"));
717        assert_eq!(
718            parsed.message,
719            "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
720        );
721    }
722
723    // --- Edge cases ---
724
725    #[test]
726    fn not_uuid_36_chars() {
727        let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
728        let parsed = parse_line(line);
729        assert_eq!(parsed.kind, LineKind::BareContinuation);
730        assert_eq!(parsed.message, line);
731    }
732
733    #[test]
734    fn uuid_in_message_not_prefix() {
735        let line =
736            format!("This is some log message body with extra context then {UUID1} appears here");
737        let parsed = parse_line(&line);
738        assert_eq!(parsed.kind, LineKind::BareContinuation);
739        assert_eq!(parsed.message, line.as_str());
740    }
741
742    #[test]
743    fn whitespace_only_is_empty() {
744        let parsed = parse_line("   \t  ");
745        assert_eq!(parsed.kind, LineKind::Empty);
746    }
747
748    // --- Multi-byte content at fixed header offsets (must not panic) ---
749
750    #[test]
751    fn multibyte_straddling_timestamp_end_not_system() {
752        // 'é' occupies bytes 25-26: slicing the timestamp at 26 splits it.
753        let line = "2025-01-15 10:30:45.12345é more content following here";
754        assert!(!line.is_char_boundary(26));
755        let parsed = parse_line(line);
756        assert_eq!(parsed.kind, LineKind::BareContinuation);
757        assert_eq!(parsed.timestamp, None);
758        assert_eq!(parsed.message, line);
759    }
760
761    #[test]
762    fn multibyte_after_timestamp_not_system() {
763        // 'é' occupies bytes 26-27: slicing the message start at 27 splits it.
764        let line = "2025-01-15 10:30:45.123456é more content following here";
765        assert!(!line.is_char_boundary(27));
766        let parsed = parse_line(line);
767        assert_eq!(parsed.kind, LineKind::BareContinuation);
768        assert_eq!(parsed.timestamp, None);
769        assert_eq!(parsed.message, line);
770    }
771
772    #[test]
773    fn multibyte_after_uuid_timestamp_is_continuation() {
774        let line = format!("{UUID1} 2025-01-15 10:30:45.123456é more content");
775        let parsed = parse_line(&line);
776        assert_eq!(parsed.kind, LineKind::UuidContinuation);
777        assert_eq!(parsed.uuid, Some(UUID1));
778        assert_eq!(parsed.timestamp, None);
779        assert_eq!(parsed.message, "2025-01-15 10:30:45.123456é more content");
780    }
781
782    #[test]
783    fn multibyte_after_idle_pct_degrades() {
784        // 'é' where the "% " separator's trailing space should be.
785        let line = "2025-01-15 10:30:45.123456 9%é[DEBUG] x";
786        let parsed = parse_line(line);
787        assert_eq!(parsed.kind, LineKind::System);
788        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
789        assert_eq!(parsed.idle_pct, None);
790        assert_eq!(parsed.level, None);
791    }
792
793    #[test]
794    fn multibyte_after_level_bracket_degrades() {
795        // 'é' where the "] " separator's trailing space should be.
796        let line = "2025-01-15 10:30:45.123456 95.97% [DEBUG]éxx";
797        let parsed = parse_line(line);
798        assert_eq!(parsed.kind, LineKind::System);
799        assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
800        assert_eq!(parsed.idle_pct, Some("95.97%"));
801        assert_eq!(parsed.level, Some(LogLevel::Debug));
802        assert_eq!(parsed.source, None);
803        assert_eq!(parsed.message, "");
804    }
805}