Skip to main content

freeswitch_log_parser/
message.rs

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