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