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