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