Skip to main content

freeswitch_log_parser/
message.rs

1use std::fmt;
2
3/// Which end of a call an SDP body belongs to.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum SdpDirection {
6    Local,
7    /// Local SDP sent in a 180/183 early media response.
8    LocalRing,
9    Remote,
10    /// SDP reference that doesn't specify local or remote.
11    Unknown,
12}
13
14/// Direction of a sofia SIP INVITE log line.
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SipInviteDirection {
18    /// `sofia/X/Y receiving invite ...` — inbound INVITE on a sofia profile.
19    Receiving,
20    /// `sofia/X/Y sending invite ...` — outbound INVITE on a sofia profile.
21    Sending,
22}
23
24impl fmt::Display for SdpDirection {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            SdpDirection::Local => f.pad("local"),
28            SdpDirection::LocalRing => f.pad("local-ring"),
29            SdpDirection::Remote => f.pad("remote"),
30            SdpDirection::Unknown => f.pad("unknown"),
31        }
32    }
33}
34
35/// Semantic classification of a log message's content.
36///
37/// `Display` includes variant-specific detail (e.g. `execute(set)`, `var(sip_call_id)`)
38/// while [`label()`](MessageKind::label) returns just the category string.
39#[non_exhaustive]
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum MessageKind {
42    /// Dialplan application execution trace (`EXECUTE [depth=N] channel app(args)`).
43    Execute {
44        depth: u32,
45        channel: String,
46        application: String,
47        arguments: String,
48    },
49    /// Dialplan processing output — regex matching, actions, context routing.
50    Dialplan { channel: String, detail: String },
51    /// Start of a CHANNEL_DATA variable dump block.
52    ChannelData,
53    /// A `Channel-*` or similar hyphenated field from a CHANNEL_DATA dump.
54    ChannelField { name: String, value: String },
55    /// A `variable_*` field — from dumps, `SET`, `EXPORT`, `set()`, or `CoreSession::setVariable`.
56    Variable { name: String, value: String },
57    /// Start of an SDP body block (`Local SDP:`, `Remote SDP:`).
58    SdpMarker { direction: SdpDirection },
59    /// Channel state transition (`State Change`, `Callstate Change`, `SOFIA` state).
60    StateChange { detail: String },
61    /// `Audio Codec Compare` lines during codec negotiation.
62    CodecNegotiation,
63    /// RTP, RTCP, recording, and other media-related messages.
64    Media { detail: String },
65    /// Channel lifecycle events — new/close/hangup, bridge, ring, REFER, CANCEL, BYE.
66    ChannelLifecycle { detail: String },
67    /// Sofia logged a SIP INVITE on this channel — the line is one of:
68    /// - `sofia/<profile>/<endpoint> receiving invite from <ip>:<port> ... call-id: <id>`
69    /// - `sofia/<profile>/<endpoint> sending invite [version: ...] [call-id: <id>]`
70    ///
71    /// Always emitted by sofia for every inbound and outbound call regardless
72    /// of dialplan — the canonical primitive for `sip_call_id ↔ channel_uuid`
73    /// correlation. The line's leading UUID is on [`crate::LogEntry::uuid`].
74    SipInvite {
75        direction: SipInviteDirection,
76        /// The sofia profile name (segment between `sofia/` and the next `/`).
77        profile: String,
78        /// SIP `Call-ID` from the log line. `None` when sofia logs `(null)`
79        /// (typical for outbound at pre-routing time — a later log entry on
80        /// the same UUID will carry the actual id) or when the line carries
81        /// no `call-id:` field (the version-only DEBUG follow-up for sending).
82        call_id: Option<String>,
83    },
84    /// Event socket commands from `mod_event_socket`.
85    EventSocket { detail: String },
86    /// Anything not matching a more specific pattern.
87    General,
88    /// Synthetic marker emitted at log file boundaries (never from `classify_message`).
89    FileChange,
90    /// Synthetic marker emitted at date boundaries (never from `classify_message`).
91    DateChange,
92}
93
94impl MessageKind {
95    /// Exhaustive list of all category label strings, in declaration order.
96    pub const ALL_LABELS: &[&str] = &[
97        "execute",
98        "dialplan",
99        "channel-data",
100        "channel-field",
101        "variable",
102        "sdp-marker",
103        "state-change",
104        "codec-negotiation",
105        "media",
106        "channel-lifecycle",
107        "sip-invite",
108        "event-socket",
109        "general",
110        "file-change",
111        "date-change",
112    ];
113
114    /// Returns the bare category string without variant-specific data.
115    pub fn label(&self) -> &'static str {
116        match self {
117            MessageKind::Execute { .. } => "execute",
118            MessageKind::Dialplan { .. } => "dialplan",
119            MessageKind::ChannelData => "channel-data",
120            MessageKind::ChannelField { .. } => "channel-field",
121            MessageKind::Variable { .. } => "variable",
122            MessageKind::SdpMarker { .. } => "sdp-marker",
123            MessageKind::StateChange { .. } => "state-change",
124            MessageKind::CodecNegotiation => "codec-negotiation",
125            MessageKind::Media { .. } => "media",
126            MessageKind::ChannelLifecycle { .. } => "channel-lifecycle",
127            MessageKind::SipInvite { .. } => "sip-invite",
128            MessageKind::EventSocket { .. } => "event-socket",
129            MessageKind::General => "general",
130            MessageKind::FileChange => "file-change",
131            MessageKind::DateChange => "date-change",
132        }
133    }
134}
135
136impl fmt::Display for MessageKind {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            MessageKind::Execute { application, .. } => write!(f, "execute({})", application),
140            MessageKind::Dialplan { .. } => f.pad("dialplan"),
141            MessageKind::ChannelData => f.pad("channel-data"),
142            MessageKind::ChannelField { name, .. } => write!(f, "field({})", name),
143            MessageKind::Variable { name, .. } => write!(f, "var({})", name),
144            MessageKind::SdpMarker { direction } => write!(f, "sdp({})", direction),
145            MessageKind::StateChange { .. } => f.pad("state-change"),
146            MessageKind::CodecNegotiation => f.pad("codec-negotiation"),
147            MessageKind::Media { .. } => f.pad("media"),
148            MessageKind::ChannelLifecycle { .. } => f.pad("channel-lifecycle"),
149            MessageKind::SipInvite { .. } => f.pad("sip-invite"),
150            MessageKind::EventSocket { .. } => f.pad("event-socket"),
151            MessageKind::General => f.pad("general"),
152            MessageKind::FileChange => f.pad("file-change"),
153            MessageKind::DateChange => f.pad("date-change"),
154        }
155    }
156}
157
158fn parse_execute(msg: &str) -> MessageKind {
159    let rest = &msg["EXECUTE ".len()..];
160
161    let depth = if rest.starts_with("[depth=") {
162        let end = rest.find(']').unwrap_or(0);
163        if end > 7 {
164            rest[7..end].parse::<u32>().unwrap_or(0)
165        } else {
166            0
167        }
168    } else {
169        return MessageKind::Execute {
170            depth: 0,
171            channel: String::new(),
172            application: String::new(),
173            arguments: rest.to_string(),
174        };
175    };
176
177    let after_bracket = rest.find("] ").map(|p| &rest[p + 2..]).unwrap_or("");
178
179    // Lowercase "Execute [depth=N] app(args)" has no channel.
180    // Uppercase "EXECUTE [depth=N] channel app(args)" has channel before app.
181    // Detect by checking if first token contains '(' (app) or '/' (channel path).
182    let (channel, app_part) = match after_bracket.find(' ') {
183        Some(p) => {
184            let first_token = &after_bracket[..p];
185            if first_token.contains('/') {
186                (first_token, &after_bracket[p + 1..])
187            } else {
188                ("", after_bracket)
189            }
190        }
191        None => ("", after_bracket),
192    };
193
194    let (application, arguments) = match app_part.find('(') {
195        Some(p) => {
196            let app = &app_part[..p];
197            let args = if app_part.ends_with(')') {
198                &app_part[p + 1..app_part.len() - 1]
199            } else {
200                &app_part[p + 1..]
201            };
202            (app, args)
203        }
204        None => (app_part, ""),
205    };
206
207    MessageKind::Execute {
208        depth,
209        channel: channel.to_string(),
210        application: application.to_string(),
211        arguments: arguments.to_string(),
212    }
213}
214
215fn parse_dialplan(msg: &str) -> MessageKind {
216    let prefix_len = if msg.starts_with("Chatplan: ") {
217        "Chatplan: ".len()
218    } else {
219        "Dialplan: ".len()
220    };
221    let rest = &msg[prefix_len..];
222    let (channel, detail) = match rest.find(' ') {
223        Some(p) => (&rest[..p], &rest[p + 1..]),
224        None => (rest, ""),
225    };
226    MessageKind::Dialplan {
227        channel: channel.to_string(),
228        detail: detail.to_string(),
229    }
230}
231
232fn parse_bracketed_value(s: &str, prefix_len: usize) -> Option<(&str, &str)> {
233    let after_prefix = &s[prefix_len..];
234    let colon = after_prefix.find(": ")?;
235    let name = &after_prefix[..colon];
236    let value_part = &after_prefix[colon + 2..];
237    if let Some(inner) = value_part.strip_prefix('[') {
238        if let Some(stripped) = inner.strip_suffix(']') {
239            Some((name, stripped))
240        } else {
241            Some((name, inner))
242        }
243    } else {
244        Some((name, value_part))
245    }
246}
247
248fn detect_sdp_direction(msg: &str) -> Option<SdpDirection> {
249    if msg.contains("Ring SDP") {
250        Some(SdpDirection::LocalRing)
251    } else if msg.contains("Local SDP") || msg.contains("local-sdp") {
252        Some(SdpDirection::Local)
253    } else if msg.contains("Remote SDP") || msg.contains("remote-sdp") {
254        Some(SdpDirection::Remote)
255    } else if msg.ends_with(" SDP:") || msg.ends_with(" SDP") {
256        Some(SdpDirection::Unknown)
257    } else {
258        None
259    }
260}
261
262/// Classify a log message's text into a [`MessageKind`].
263///
264/// Pure function — no state, no allocation beyond the returned enum. Works on
265/// the `message` field from [`RawLine`](crate::RawLine) or any raw message string.
266pub fn classify_message(msg: &str) -> MessageKind {
267    if msg.starts_with("EXECUTE ") || msg.starts_with("Execute ") {
268        return parse_execute(msg);
269    }
270
271    if msg.starts_with("Dialplan: ") || msg.starts_with("Chatplan: ") {
272        return parse_dialplan(msg);
273    }
274
275    if msg.starts_with("Processing ")
276        && (msg.contains(" in context ") || msg.contains("recursive conditions"))
277    {
278        return parse_dialplan_processing(msg);
279    }
280
281    if msg.contains("CHANNEL_DATA") {
282        return MessageKind::ChannelData;
283    }
284
285    if msg.starts_with("variable_") {
286        if let Some((name, value)) = parse_bracketed_value(msg, 0) {
287            return MessageKind::Variable {
288                name: name.to_string(),
289                value: value.to_string(),
290            };
291        }
292    }
293
294    if let Some(direction) = detect_sdp_direction(msg) {
295        return MessageKind::SdpMarker { direction };
296    }
297
298    if msg.contains("State Change") || msg.contains("Callstate Change") {
299        return MessageKind::StateChange {
300            detail: msg.to_string(),
301        };
302    }
303
304    if msg.starts_with("SET ") || msg.starts_with("EXPORT ") {
305        if let Some(sv) = parse_set_or_export(msg) {
306            return sv;
307        }
308    }
309
310    if msg.starts_with("Audio Codec Compare ") {
311        return MessageKind::CodecNegotiation;
312    }
313
314    if msg.starts_with("CoreSession::setVariable(") {
315        return parse_core_session_set_variable(msg);
316    }
317
318    if msg.starts_with("UNSET ") {
319        return parse_unset(msg);
320    }
321
322    // Pre-dialplan set action: "set variable name=value"
323    if let Some(rest) = msg.strip_prefix("set variable ") {
324        if let Some((name, value)) = rest.split_once('=') {
325            return MessageKind::Variable {
326                name: format!("variable_{name}"),
327                value: value.to_string(),
328            };
329        }
330    }
331
332    if msg.starts_with("Transfer ") {
333        return MessageKind::Dialplan {
334            channel: String::new(),
335            detail: msg.to_string(),
336        };
337    }
338
339    // (channel) State STATE — parenthesized channel state
340    if msg.starts_with('(') {
341        if msg.contains(") State ") {
342            return MessageKind::StateChange {
343                detail: msg.to_string(),
344            };
345        }
346        return MessageKind::ChannelLifecycle {
347            detail: msg.to_string(),
348        };
349    }
350
351    // SOFIA STATE (no channel prefix) — e.g. "SOFIA EXCHANGE_MEDIA"
352    if msg.starts_with("SOFIA ") {
353        return MessageKind::StateChange {
354            detail: msg.to_string(),
355        };
356    }
357
358    // Pre-dialplan: checking condition / action results from sofia_pre_dialplan.c
359    if msg.starts_with("checking condition") || msg.starts_with("action(") {
360        return MessageKind::ChannelLifecycle {
361            detail: msg.to_string(),
362        };
363    }
364
365    if msg.starts_with("Event Socket Command") {
366        return MessageKind::EventSocket {
367            detail: msg.to_string(),
368        };
369    }
370
371    // Media patterns (no channel prefix)
372    if let Some(kind) = detect_media(msg) {
373        return kind;
374    }
375
376    // Channel lifecycle patterns (no channel prefix)
377    if let Some(kind) = detect_channel_lifecycle(msg) {
378        return kind;
379    }
380
381    // Channel-prefixed messages: sofia/..., loopback/... prefix
382    if let Some((channel_part, rest)) = strip_channel_prefix(msg) {
383        return classify_channel_prefixed(channel_part, rest);
384    }
385
386    // Channel-* fields and other Key: [value] patterns from CHANNEL_DATA dumps
387    // Must come after more specific checks to avoid false positives
388    if let Some((name, value)) = parse_bracketed_value(msg, 0) {
389        let name_bytes = name.as_bytes();
390        if !name_bytes.is_empty()
391            && !name.contains(' ')
392            && name_bytes[0].is_ascii_alphabetic()
393            && (name.contains('-') || name.starts_with("Channel-"))
394        {
395            return MessageKind::ChannelField {
396                name: name.to_string(),
397                value: value.to_string(),
398            };
399        }
400    }
401
402    MessageKind::General
403}
404
405fn strip_channel_prefix(msg: &str) -> Option<(&str, &str)> {
406    if !msg.starts_with("sofia/") && !msg.starts_with("loopback/") {
407        return None;
408    }
409    let bytes = msg.as_bytes();
410    let mut i = 0;
411    let mut bracket_depth: u32 = 0;
412    while i < bytes.len() {
413        match bytes[i] {
414            b'[' => bracket_depth += 1,
415            b']' => {
416                bracket_depth = bracket_depth.saturating_sub(1);
417            }
418            b' ' if bracket_depth == 0 => {
419                return Some((&msg[..i], &msg[i + 1..]));
420            }
421            _ => {}
422        }
423        i += 1;
424    }
425    None
426}
427
428fn classify_channel_prefixed(channel_part: &str, rest: &str) -> MessageKind {
429    // Sofia INVITE lines — typed extraction of (direction, profile, call-id).
430    // Must run before the ChannelLifecycle fallback; sofia always logs these
431    // for every inbound and outbound call regardless of dialplan, making them
432    // the canonical primitive for sip_call_id ↔ channel_uuid correlation.
433    if let Some(direction) = sip_invite_direction(rest) {
434        let profile = extract_sofia_profile(channel_part).unwrap_or_default();
435        let call_id = extract_call_id(rest);
436        return MessageKind::SipInvite {
437            direction,
438            profile,
439            call_id,
440        };
441    }
442
443    // SOFIA STATE / Standard STATE / RTC STATE
444    if rest.starts_with("SOFIA ") || rest.starts_with("Standard ") || rest.starts_with("RTC ") {
445        return MessageKind::StateChange {
446            detail: rest.to_string(),
447        };
448    }
449
450    if let Some(kind) = detect_media(rest) {
451        return kind;
452    }
453
454    // Channel-prefixed lifecycle: destroy/unlink, REFER, CANCEL, BYE, etc.
455    MessageKind::ChannelLifecycle {
456        detail: rest.to_string(),
457    }
458}
459
460fn sip_invite_direction(rest: &str) -> Option<SipInviteDirection> {
461    if rest.starts_with("receiving invite") {
462        Some(SipInviteDirection::Receiving)
463    } else if rest.starts_with("sending invite") {
464        Some(SipInviteDirection::Sending)
465    } else {
466        None
467    }
468}
469
470fn extract_sofia_profile(channel_part: &str) -> Option<String> {
471    let after = channel_part.strip_prefix("sofia/")?;
472    let end = after.find('/').unwrap_or(after.len());
473    if end == 0 {
474        None
475    } else {
476        Some(after[..end].to_string())
477    }
478}
479
480fn extract_call_id(rest: &str) -> Option<String> {
481    let after = rest.split_once("call-id: ")?.1;
482    let token = after.split_whitespace().next()?;
483    if token == "(null)" {
484        None
485    } else {
486        Some(token.to_string())
487    }
488}
489
490fn detect_media(msg: &str) -> Option<MessageKind> {
491    let media_prefixes = [
492        "AUDIO RTP ",
493        "VIDEO RTP ",
494        "Activating ",
495        "RTCP ",
496        "Starting timer",
497        "Record session",
498        "Correct audio",
499        "No silence detection",
500        "Audio params",
501        "Codec ",
502        "Attaching BUG",
503        "Removing BUG",
504        "rtcp_stats_init",
505        "Send middle packet",
506        "Send end packet",
507        "Send first packet",
508        "START_RECORDING",
509        "Stop recording",
510        "Engaging Write Buffer",
511        "rtcp_stats:",
512    ];
513    for prefix in &media_prefixes {
514        if msg.starts_with(prefix) {
515            return Some(MessageKind::Media {
516                detail: msg.to_string(),
517            });
518        }
519    }
520
521    if msg.starts_with("Setting RTCP") || msg.starts_with("Setting BUG Codec") {
522        return Some(MessageKind::Media {
523            detail: msg.to_string(),
524        });
525    }
526
527    if msg.starts_with("Set ") {
528        return Some(MessageKind::Media {
529            detail: msg.to_string(),
530        });
531    }
532
533    if msg.starts_with("Original read codec set to")
534        || msg.starts_with("Forcing crypto_mode")
535        || msg.starts_with("Parsing global variables")
536        || msg.starts_with("Parsing session specific variables")
537    {
538        return Some(MessageKind::Media {
539            detail: msg.to_string(),
540        });
541    }
542
543    None
544}
545
546fn detect_channel_lifecycle(msg: &str) -> Option<MessageKind> {
547    let lifecycle_prefixes = [
548        "New Channel ",
549        "Close Channel ",
550        "Hangup ",
551        "Ring-Ready ",
552        "Ring Ready ",
553        "Pre-Answer ",
554        "Sending early media",
555        "Sending BYE",
556        "Sending CANCEL",
557        "Channel is hung up",
558        "Call appears",
559        "Found channel",
560        "3PCC ",
561        "Subscribed to 3PCC",
562        "New log started",
563        "Received a ",
564        "Session ",
565        "BRIDGE ",
566        "Originate ",
567        "USAGE:",
568        "Split into",
569        "Part ",
570        "Responding to INVITE",
571        "Redirecting to",
572        "subscribing to",
573        "Queue digit delay",
574    ];
575    for prefix in &lifecycle_prefixes {
576        if msg.starts_with(prefix) {
577            return Some(MessageKind::ChannelLifecycle {
578                detail: msg.to_string(),
579            });
580        }
581    }
582
583    if msg.starts_with("Channel ") {
584        return Some(MessageKind::ChannelLifecycle {
585            detail: msg.to_string(),
586        });
587    }
588
589    if msg.starts_with("Application ") && msg.contains("Requires media") {
590        return Some(MessageKind::ChannelLifecycle {
591            detail: msg.to_string(),
592        });
593    }
594
595    None
596}
597
598fn parse_core_session_set_variable(msg: &str) -> MessageKind {
599    let rest = &msg["CoreSession::setVariable(".len()..];
600    if let Some(end) = rest.strip_suffix(')') {
601        if let Some(comma) = end.find(", ") {
602            return MessageKind::Variable {
603                name: format!("variable_{}", &end[..comma]),
604                value: end[comma + 2..].to_string(),
605            };
606        }
607    }
608    MessageKind::Variable {
609        name: String::new(),
610        value: msg.to_string(),
611    }
612}
613
614fn parse_unset(msg: &str) -> MessageKind {
615    let rest = &msg["UNSET ".len()..];
616    let name = if let Some(inner) = rest.strip_prefix('[') {
617        inner.strip_suffix(']').unwrap_or(inner)
618    } else {
619        rest
620    };
621    MessageKind::Variable {
622        name: format!("variable_{name}"),
623        value: String::new(),
624    }
625}
626
627fn parse_dialplan_processing(msg: &str) -> MessageKind {
628    let rest = &msg["Processing ".len()..];
629    MessageKind::Dialplan {
630        channel: String::new(),
631        detail: rest.to_string(),
632    }
633}
634
635fn parse_set_or_export(msg: &str) -> Option<MessageKind> {
636    // SET channel [name]=[value]
637    // EXPORT (export_vars) [name]=[value]
638    // EXPORT (export_vars) (REMOTE ONLY) [name]=[value]
639    // Find "]=[" which uniquely identifies the [name]=[value] boundary
640    let sep = msg.find("]=[");
641    if let Some(sep_pos) = sep {
642        let name_start = msg[..sep_pos].rfind('[')?;
643        let name = &msg[name_start + 1..sep_pos];
644        let val_start = sep_pos + 3; // skip "]=["
645        let val_end = msg[val_start..]
646            .find(']')
647            .map(|p| val_start + p)
648            .unwrap_or(msg.len());
649        let value = &msg[val_start..val_end];
650        return Some(MessageKind::Variable {
651            name: format!("variable_{name}"),
652            value: value.to_string(),
653        });
654    }
655
656    // EXPORT with simple [name=value] (no ]=[ separator)
657    // e.g. "EXPORT (export_vars) [originate_timeout=3600]"
658    // This doesn't exist in the samples but handle it for robustness
659    None
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn execute_full() {
668        let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 db(insert/ng_a1b2c3d4/city/ST GEORGES)";
669        let kind = classify_message(msg);
670        assert_eq!(
671            kind,
672            MessageKind::Execute {
673                depth: 0,
674                channel: "sofia/internal/+15550001234@192.0.2.1".to_string(),
675                application: "db".to_string(),
676                arguments: "insert/ng_a1b2c3d4/city/ST GEORGES".to_string(),
677            }
678        );
679    }
680
681    #[test]
682    fn execute_nested_depth() {
683        let msg = "EXECUTE [depth=2] sofia/internal/+15550001234@192.0.2.1 set(x=y)";
684        match classify_message(msg) {
685            MessageKind::Execute {
686                depth,
687                application,
688                arguments,
689                ..
690            } => {
691                assert_eq!(depth, 2);
692                assert_eq!(application, "set");
693                assert_eq!(arguments, "x=y");
694            }
695            other => panic!("expected Execute, got {other:?}"),
696        }
697    }
698
699    #[test]
700    fn execute_no_arguments() {
701        let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer";
702        match classify_message(msg) {
703            MessageKind::Execute {
704                application,
705                arguments,
706                ..
707            } => {
708                assert_eq!(application, "answer");
709                assert_eq!(arguments, "");
710            }
711            other => panic!("expected Execute, got {other:?}"),
712        }
713    }
714
715    #[test]
716    fn execute_export_with_vars() {
717        let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)";
718        match classify_message(msg) {
719            MessageKind::Execute {
720                application,
721                arguments,
722                ..
723            } => {
724                assert_eq!(application, "export");
725                assert_eq!(arguments, "originate_timeout=3600");
726            }
727            other => panic!("expected Execute, got {other:?}"),
728        }
729    }
730
731    #[test]
732    fn dialplan_parsing() {
733        let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true";
734        match classify_message(msg) {
735            MessageKind::Dialplan { channel, detail } => {
736                assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
737                assert_eq!(detail, "parsing [public->global] continue=true");
738            }
739            other => panic!("expected Dialplan, got {other:?}"),
740        }
741    }
742
743    #[test]
744    fn dialplan_regex() {
745        let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
746        match classify_message(msg) {
747            MessageKind::Dialplan { channel, detail } => {
748                assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
749                assert!(detail.starts_with("Regex (PASS)"));
750            }
751            other => panic!("expected Dialplan, got {other:?}"),
752        }
753    }
754
755    #[test]
756    fn dialplan_action() {
757        let msg =
758            "Dialplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
759        match classify_message(msg) {
760            MessageKind::Dialplan { detail, .. } => {
761                assert!(detail.starts_with("Action "));
762            }
763            other => panic!("expected Dialplan, got {other:?}"),
764        }
765    }
766
767    #[test]
768    fn channel_data_marker() {
769        assert_eq!(classify_message("CHANNEL_DATA:"), MessageKind::ChannelData);
770    }
771
772    #[test]
773    fn channel_data_in_message() {
774        assert_eq!(
775            classify_message("New CHANNEL_DATA arrived"),
776            MessageKind::ChannelData,
777        );
778    }
779
780    #[test]
781    fn channel_field_with_brackets() {
782        let msg = "Channel-State: [CS_EXECUTE]";
783        match classify_message(msg) {
784            MessageKind::ChannelField { name, value } => {
785                assert_eq!(name, "Channel-State");
786                assert_eq!(value, "CS_EXECUTE");
787            }
788            other => panic!("expected ChannelField, got {other:?}"),
789        }
790    }
791
792    #[test]
793    fn channel_field_name() {
794        let msg = "Channel-Name: [sofia/internal/+15550001234@192.0.2.1]";
795        match classify_message(msg) {
796            MessageKind::ChannelField { name, value } => {
797                assert_eq!(name, "Channel-Name");
798                assert_eq!(value, "sofia/internal/+15550001234@192.0.2.1");
799            }
800            other => panic!("expected ChannelField, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn variable_single_line() {
806        let msg = "variable_sip_call_id: [test123@192.0.2.1]";
807        match classify_message(msg) {
808            MessageKind::Variable { name, value } => {
809                assert_eq!(name, "variable_sip_call_id");
810                assert_eq!(value, "test123@192.0.2.1");
811            }
812            other => panic!("expected Variable, got {other:?}"),
813        }
814    }
815
816    #[test]
817    fn variable_multi_line_start() {
818        let msg = "variable_switch_r_sdp: [v=0";
819        match classify_message(msg) {
820            MessageKind::Variable { name, value } => {
821                assert_eq!(name, "variable_switch_r_sdp");
822                assert_eq!(value, "v=0");
823            }
824            other => panic!("expected Variable, got {other:?}"),
825        }
826    }
827
828    #[test]
829    fn sdp_local() {
830        assert_eq!(
831            classify_message("Local SDP:"),
832            MessageKind::SdpMarker {
833                direction: SdpDirection::Local
834            },
835        );
836    }
837
838    #[test]
839    fn sdp_remote() {
840        assert_eq!(
841            classify_message("Remote SDP:"),
842            MessageKind::SdpMarker {
843                direction: SdpDirection::Remote
844            },
845        );
846    }
847
848    #[test]
849    fn sdp_in_longer_message() {
850        match classify_message("Setting Local SDP for call") {
851            MessageKind::SdpMarker { direction } => {
852                assert_eq!(direction, SdpDirection::Local);
853            }
854            other => panic!("expected SdpMarker, got {other:?}"),
855        }
856    }
857
858    #[test]
859    fn sdp_unknown_direction() {
860        assert_eq!(
861            classify_message("Patched SDP:"),
862            MessageKind::SdpMarker {
863                direction: SdpDirection::Unknown
864            },
865        );
866    }
867
868    #[test]
869    fn ring_sdp_is_local_ring() {
870        assert_eq!(
871            classify_message("Ring SDP:"),
872            MessageKind::SdpMarker {
873                direction: SdpDirection::LocalRing
874            },
875        );
876    }
877
878    #[test]
879    fn state_change() {
880        let msg = "State Change CS_INIT -> CS_ROUTING";
881        match classify_message(msg) {
882            MessageKind::StateChange { detail } => {
883                assert_eq!(detail, msg);
884            }
885            other => panic!("expected StateChange, got {other:?}"),
886        }
887    }
888
889    #[test]
890    fn core_session_set_variable() {
891        match classify_message("CoreSession::setVariable(X-City, ST GEORGES)") {
892            MessageKind::Variable { name, value } => {
893                assert_eq!(name, "variable_X-City");
894                assert_eq!(value, "ST GEORGES");
895            }
896            other => panic!("expected Variable, got {other:?}"),
897        }
898    }
899
900    #[test]
901    fn general_empty() {
902        assert_eq!(classify_message(""), MessageKind::General);
903    }
904
905    #[test]
906    fn hangup_is_channel_lifecycle() {
907        match classify_message(
908            "Hangup sofia/internal/+15550001234@192.0.2.1 [CS_CONSUME_MEDIA] [NORMAL_CLEARING]",
909        ) {
910            MessageKind::ChannelLifecycle { .. } => {}
911            other => panic!("expected ChannelLifecycle, got {other:?}"),
912        }
913    }
914
915    #[test]
916    fn channel_field_no_brackets() {
917        let msg = "Channel-Presence-ID: 1234@192.0.2.1";
918        match classify_message(msg) {
919            MessageKind::ChannelField { name, value } => {
920                assert_eq!(name, "Channel-Presence-ID");
921                assert_eq!(value, "1234@192.0.2.1");
922            }
923            other => panic!("expected ChannelField, got {other:?}"),
924        }
925    }
926
927    #[test]
928    fn variable_no_brackets() {
929        let msg = "variable_direction: inbound";
930        match classify_message(msg) {
931            MessageKind::Variable { name, value } => {
932                assert_eq!(name, "variable_direction");
933                assert_eq!(value, "inbound");
934            }
935            other => panic!("expected Variable, got {other:?}"),
936        }
937    }
938
939    // --- New: Extended patterns found in production ---
940
941    #[test]
942    fn execute_lowercase() {
943        let msg = "Execute [depth=2] set(RECORD_STEREO=true)";
944        match classify_message(msg) {
945            MessageKind::Execute {
946                depth,
947                application,
948                arguments,
949                ..
950            } => {
951                assert_eq!(depth, 2);
952                assert_eq!(application, "set");
953                assert_eq!(arguments, "RECORD_STEREO=true");
954            }
955            other => panic!("expected Execute, got {other:?}"),
956        }
957    }
958
959    #[test]
960    fn execute_lowercase_db() {
961        let msg = "Execute [depth=1] db(insert/ng_${originating_leg_uuid}/record_leg/${uuid})";
962        match classify_message(msg) {
963            MessageKind::Execute { application, .. } => {
964                assert_eq!(application, "db");
965            }
966            other => panic!("expected Execute, got {other:?}"),
967        }
968    }
969
970    #[test]
971    fn set_variable_message() {
972        let msg = "SET sofia/internal-v6/1263@[fd51:2050:2220:198::10] [ngcs_bridge_sip_req_uri]=[conf-factory-app.qc.core.ng.911bell.ca]";
973        match classify_message(msg) {
974            MessageKind::Variable { name, value } => {
975                assert_eq!(name, "variable_ngcs_bridge_sip_req_uri");
976                assert_eq!(value, "conf-factory-app.qc.core.ng.911bell.ca");
977            }
978            other => panic!("expected Variable, got {other:?}"),
979        }
980    }
981
982    #[test]
983    fn export_variable_message() {
984        let msg =
985            "EXPORT (export_vars) (REMOTE ONLY) [sip_from_uri]=[sip:cauca1.qc.psap.ng.911bell.ca]";
986        match classify_message(msg) {
987            MessageKind::Variable { name, value } => {
988                assert_eq!(name, "variable_sip_from_uri");
989                assert_eq!(value, "sip:cauca1.qc.psap.ng.911bell.ca");
990            }
991            other => panic!("expected Variable, got {other:?}"),
992        }
993    }
994
995    #[test]
996    fn export_simple_variable() {
997        let msg = "EXPORT (export_vars) [originate_timeout]=[3600]";
998        match classify_message(msg) {
999            MessageKind::Variable { name, value } => {
1000                assert_eq!(name, "variable_originate_timeout");
1001                assert_eq!(value, "3600");
1002            }
1003            other => panic!("expected Variable, got {other:?}"),
1004        }
1005    }
1006
1007    #[test]
1008    fn processing_in_context() {
1009        let msg = "Processing Extension 1263 <1263>->start_recording in context recordings";
1010        match classify_message(msg) {
1011            MessageKind::Dialplan { detail, .. } => {
1012                assert!(detail.contains("start_recording"));
1013                assert!(detail.contains("recordings"));
1014            }
1015            other => panic!("expected Dialplan, got {other:?}"),
1016        }
1017    }
1018
1019    #[test]
1020    fn caller_field_as_channel_field() {
1021        let msg = "Caller-Username: [+15550001234]";
1022        match classify_message(msg) {
1023            MessageKind::ChannelField { name, value } => {
1024                assert_eq!(name, "Caller-Username");
1025                assert_eq!(value, "+15550001234");
1026            }
1027            other => panic!("expected ChannelField, got {other:?}"),
1028        }
1029    }
1030
1031    #[test]
1032    fn answer_state_as_channel_field() {
1033        let msg = "Answer-State: [ringing]";
1034        match classify_message(msg) {
1035            MessageKind::ChannelField { name, value } => {
1036                assert_eq!(name, "Answer-State");
1037                assert_eq!(value, "ringing");
1038            }
1039            other => panic!("expected ChannelField, got {other:?}"),
1040        }
1041    }
1042
1043    #[test]
1044    fn unique_id_as_channel_field() {
1045        let msg = "Unique-ID: [a1b2c3d4-e5f6-7890-abcd-ef1234567890]";
1046        match classify_message(msg) {
1047            MessageKind::ChannelField { name, value } => {
1048                assert_eq!(name, "Unique-ID");
1049                assert_eq!(value, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
1050            }
1051            other => panic!("expected ChannelField, got {other:?}"),
1052        }
1053    }
1054
1055    #[test]
1056    fn call_direction_as_channel_field() {
1057        let msg = "Call-Direction: [inbound]";
1058        match classify_message(msg) {
1059            MessageKind::ChannelField { name, value } => {
1060                assert_eq!(name, "Call-Direction");
1061                assert_eq!(value, "inbound");
1062            }
1063            other => panic!("expected ChannelField, got {other:?}"),
1064        }
1065    }
1066
1067    #[test]
1068    fn callstate_change() {
1069        let msg = "(sofia/internal-v4/sos) Callstate Change RINGING -> ACTIVE";
1070        match classify_message(msg) {
1071            MessageKind::StateChange { detail } => {
1072                assert!(detail.contains("RINGING -> ACTIVE"));
1073            }
1074            other => panic!("expected StateChange, got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn action_is_pre_dialplan_lifecycle() {
1080        match classify_message("action(1:3pcc_force_dialplan:1:set_tflag) success") {
1081            MessageKind::ChannelLifecycle { .. } => {}
1082            other => panic!("expected ChannelLifecycle, got {other:?}"),
1083        }
1084    }
1085
1086    #[test]
1087    fn channel_answered_is_lifecycle() {
1088        match classify_message("Channel [sofia/internal] has been answered") {
1089            MessageKind::ChannelLifecycle { .. } => {}
1090            other => panic!("expected ChannelLifecycle, got {other:?}"),
1091        }
1092    }
1093
1094    #[test]
1095    fn chatplan_regex() {
1096        let msg = "Chatplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
1097        match classify_message(msg) {
1098            MessageKind::Dialplan { channel, detail } => {
1099                assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
1100                assert!(detail.starts_with("Regex (PASS)"));
1101            }
1102            other => panic!("expected Dialplan, got {other:?}"),
1103        }
1104    }
1105
1106    #[test]
1107    fn chatplan_action() {
1108        let msg =
1109            "Chatplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
1110        match classify_message(msg) {
1111            MessageKind::Dialplan { detail, .. } => {
1112                assert!(detail.starts_with("Action "));
1113            }
1114            other => panic!("expected Dialplan, got {other:?}"),
1115        }
1116    }
1117
1118    #[test]
1119    fn chatplan_anti_action() {
1120        let msg =
1121            "Chatplan: sofia/internal/+15550001234@192.0.2.1 ANTI-Action log(WARNING no match)";
1122        match classify_message(msg) {
1123            MessageKind::Dialplan { detail, .. } => {
1124                assert!(detail.starts_with("ANTI-Action "));
1125            }
1126            other => panic!("expected Dialplan, got {other:?}"),
1127        }
1128    }
1129
1130    #[test]
1131    fn standard_execute_is_state_change() {
1132        let msg = "sofia/internal/+15550001234@192.0.2.1 Standard EXECUTE";
1133        match classify_message(msg) {
1134            MessageKind::StateChange { detail } => {
1135                assert_eq!(detail, "Standard EXECUTE");
1136            }
1137            other => panic!("expected StateChange, got {other:?}"),
1138        }
1139    }
1140
1141    #[test]
1142    fn sofia_execute_is_state_change() {
1143        let msg = "sofia/internal/+15550001234@192.0.2.1 SOFIA EXECUTE";
1144        match classify_message(msg) {
1145            MessageKind::StateChange { detail } => {
1146                assert_eq!(detail, "SOFIA EXECUTE");
1147            }
1148            other => panic!("expected StateChange, got {other:?}"),
1149        }
1150    }
1151
1152    #[test]
1153    fn rtc_execute_is_state_change() {
1154        let msg = "sofia/internal/+15550001234@192.0.2.1 RTC EXECUTE";
1155        match classify_message(msg) {
1156            MessageKind::StateChange { detail } => {
1157                assert_eq!(detail, "RTC EXECUTE");
1158            }
1159            other => panic!("expected StateChange, got {other:?}"),
1160        }
1161    }
1162
1163    #[test]
1164    fn standard_soft_execute_is_state_change() {
1165        let msg = "sofia/internal/+15550001234@192.0.2.1 Standard SOFT_EXECUTE";
1166        match classify_message(msg) {
1167            MessageKind::StateChange { detail } => {
1168                assert_eq!(detail, "Standard SOFT_EXECUTE");
1169            }
1170            other => panic!("expected StateChange, got {other:?}"),
1171        }
1172    }
1173
1174    #[test]
1175    fn dialplan_recursive_conditions() {
1176        let msg = "Processing recursive conditions level:1 [default] require-nested=true";
1177        match classify_message(msg) {
1178            MessageKind::Dialplan { detail, .. } => {
1179                assert!(detail.contains("recursive conditions"));
1180            }
1181            other => panic!("expected Dialplan, got {other:?}"),
1182        }
1183    }
1184
1185    #[test]
1186    fn sdp_duplicate_marker() {
1187        let msg = "Duplicate SDP";
1188        match classify_message(msg) {
1189            MessageKind::SdpMarker { direction } => {
1190                assert_eq!(direction, SdpDirection::Unknown);
1191            }
1192            other => panic!("expected SdpMarker, got {other:?}"),
1193        }
1194    }
1195
1196    #[test]
1197    fn sdp_verto_update_media() {
1198        match classify_message("updateMedia: Local SDP") {
1199            MessageKind::SdpMarker { direction } => {
1200                assert_eq!(direction, SdpDirection::Local);
1201            }
1202            other => panic!("expected SdpMarker, got {other:?}"),
1203        }
1204    }
1205
1206    #[test]
1207    fn receiving_invite_routes_to_sip_invite_with_call_id() {
1208        let msg = "sofia/internal/1212@host.example:5062 receiving invite from 192.0.2.10:47215 version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit call-id: 00112233-4455-6677-8899-aabbccddeeff";
1209        match classify_message(msg) {
1210            MessageKind::SipInvite {
1211                direction,
1212                profile,
1213                call_id,
1214            } => {
1215                assert_eq!(direction, SipInviteDirection::Receiving);
1216                assert_eq!(profile, "internal");
1217                assert_eq!(
1218                    call_id.as_deref(),
1219                    Some("00112233-4455-6677-8899-aabbccddeeff")
1220                );
1221            }
1222            other => panic!("expected SipInvite, got {other:?}"),
1223        }
1224    }
1225
1226    #[test]
1227    fn sending_invite_routes_to_sip_invite() {
1228        let msg = "sofia/internalv6/ngcs_create_conference sending invite call-id: ffeeddcc-bbaa-9988-7766-554433221100";
1229        match classify_message(msg) {
1230            MessageKind::SipInvite {
1231                direction,
1232                profile,
1233                call_id,
1234            } => {
1235                assert_eq!(direction, SipInviteDirection::Sending);
1236                assert_eq!(profile, "internalv6");
1237                assert_eq!(
1238                    call_id.as_deref(),
1239                    Some("ffeeddcc-bbaa-9988-7766-554433221100")
1240                );
1241            }
1242            other => panic!("expected SipInvite, got {other:?}"),
1243        }
1244    }
1245
1246    #[test]
1247    fn sending_invite_null_call_id_yields_none() {
1248        let msg = "sofia/telus/15555550100 sending invite call-id: (null)";
1249        match classify_message(msg) {
1250            MessageKind::SipInvite {
1251                direction,
1252                profile,
1253                call_id,
1254            } => {
1255                assert_eq!(direction, SipInviteDirection::Sending);
1256                assert_eq!(profile, "telus");
1257                assert_eq!(call_id, None);
1258            }
1259            other => panic!("expected SipInvite, got {other:?}"),
1260        }
1261    }
1262
1263    #[test]
1264    fn sending_invite_version_only_yields_none() {
1265        // The DEBUG follow-up from sofia_glue.c:1676 — no call-id field.
1266        let msg = "sofia/telus/15555550100 sending invite version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit";
1267        match classify_message(msg) {
1268            MessageKind::SipInvite {
1269                direction, call_id, ..
1270            } => {
1271                assert_eq!(direction, SipInviteDirection::Sending);
1272                assert_eq!(call_id, None);
1273            }
1274            other => panic!("expected SipInvite, got {other:?}"),
1275        }
1276    }
1277
1278    #[test]
1279    fn call_id_with_at_host_port_preserved() {
1280        let msg = "sofia/voipms/15555550101@198.51.100.52 receiving invite from 198.51.100.52:5060 version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit call-id: 00deadbeef00abc123def4567890abcd@198.51.100.52:5060";
1281        match classify_message(msg) {
1282            MessageKind::SipInvite { call_id, .. } => {
1283                assert_eq!(
1284                    call_id.as_deref(),
1285                    Some("00deadbeef00abc123def4567890abcd@198.51.100.52:5060")
1286                );
1287            }
1288            other => panic!("expected SipInvite, got {other:?}"),
1289        }
1290    }
1291
1292    #[test]
1293    fn non_invite_sofia_lifecycle_still_channel_lifecycle() {
1294        let msg = "sofia/internal/1212@host.example:5062 receiving refer";
1295        match classify_message(msg) {
1296            MessageKind::ChannelLifecycle { .. } => {}
1297            other => panic!("expected ChannelLifecycle, got {other:?}"),
1298        }
1299    }
1300}