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