Skip to main content

freeswitch_log_parser/message/
classify.rs

1//! The classification dispatcher — the ordered prefix checks that map a
2//! message to its [`MessageKind`], and the shape-specific constructors.
3
4use crate::codec::CodecMedia;
5
6use super::dtmf::parse_dtmf;
7use super::kind::MessageKind;
8use super::lifecycle::{classify_channel_prefixed, detect_channel_lifecycle};
9use super::media::{detect_media, detect_sdp_direction};
10use super::parts::{
11    dialplan_parts, execute_parts, parse_bracketed_value, set_export_parts, strip_channel_prefix,
12};
13
14fn parse_execute(msg: &str) -> MessageKind {
15    let parts = execute_parts(msg);
16    MessageKind::Execute {
17        depth: parts.depth,
18        channel: parts.channel.to_string(),
19        application: parts.application.to_string(),
20        arguments: parts.arguments.to_string(),
21    }
22}
23fn parse_dialplan(msg: &str) -> MessageKind {
24    let (channel, detail) = dialplan_parts(msg);
25    MessageKind::Dialplan {
26        channel: channel.to_string(),
27        detail: detail.to_string(),
28    }
29}
30/// Classify a log message's text into a [`MessageKind`].
31///
32/// Pure function — no state, no allocation beyond the returned enum. Works on
33/// the `message` field from [`RawLine`](crate::RawLine) or any raw message string.
34pub fn classify_message(msg: &str) -> MessageKind {
35    if msg.starts_with("EXECUTE ") || msg.starts_with("Execute ") {
36        return parse_execute(msg);
37    }
38
39    if msg.starts_with("RECV DTMF ")
40        || msg.starts_with("RTP RECV DTMF ")
41        || msg.starts_with("INFO DTMF(")
42    {
43        if let Some(dtmf) = parse_dtmf(msg) {
44            return dtmf;
45        }
46    }
47
48    if msg.starts_with("Dialplan: ") || msg.starts_with("Chatplan: ") {
49        return parse_dialplan(msg);
50    }
51
52    if msg.starts_with("Processing ")
53        && (msg.contains(" in context ") || msg.contains("recursive conditions"))
54    {
55        return parse_dialplan_processing(msg);
56    }
57
58    if msg.contains("CHANNEL_DATA") {
59        return MessageKind::ChannelData;
60    }
61
62    if msg.starts_with("variable_") {
63        if let Some((name, value)) = parse_bracketed_value(msg, 0) {
64            return MessageKind::Variable {
65                name: name.to_string(),
66                value: value.to_string(),
67            };
68        }
69    }
70
71    if let Some(direction) = detect_sdp_direction(msg) {
72        return MessageKind::SdpMarker { direction };
73    }
74
75    if msg.contains("State Change") || msg.contains("Callstate Change") {
76        return MessageKind::StateChange {
77            detail: msg.to_string(),
78        };
79    }
80
81    // `set()` logs its verb first, and the stack variants share the shape.
82    if msg.starts_with("SET ")
83        || msg.starts_with("EXPORT ")
84        || msg.starts_with("PUSH ")
85        || msg.starts_with("UNSHIFT ")
86    {
87        if let Some(sv) = parse_set_or_export(msg) {
88            return sv;
89        }
90    }
91
92    if msg.starts_with("Audio Codec Compare ") {
93        return MessageKind::CodecNegotiation {
94            media: CodecMedia::Audio,
95        };
96    }
97
98    if msg.starts_with("Video Codec Compare ") {
99        return MessageKind::CodecNegotiation {
100            media: CodecMedia::Video,
101        };
102    }
103
104    if msg.starts_with("CoreSession::setVariable(") {
105        return parse_core_session_set_variable(msg);
106    }
107
108    if msg.starts_with("UNSET ") {
109        return parse_unset(msg);
110    }
111
112    // Pre-dialplan set action: "set variable name=value"
113    if let Some(rest) = msg.strip_prefix("set variable ") {
114        if let Some((name, value)) = rest.split_once('=') {
115            return MessageKind::Variable {
116                name: format!("variable_{name}"),
117                value: value.to_string(),
118            };
119        }
120    }
121
122    if msg.starts_with("Transfer ") {
123        return MessageKind::Dialplan {
124            channel: String::new(),
125            detail: msg.to_string(),
126        };
127    }
128
129    // (channel) State STATE — parenthesized channel state
130    if msg.starts_with('(') {
131        if msg.contains(") State ") {
132            return MessageKind::StateChange {
133                detail: msg.to_string(),
134            };
135        }
136        return MessageKind::ChannelLifecycle {
137            detail: msg.to_string(),
138        };
139    }
140
141    // SOFIA STATE (no channel prefix) — e.g. "SOFIA EXCHANGE_MEDIA"
142    if msg.starts_with("SOFIA ") {
143        return MessageKind::StateChange {
144            detail: msg.to_string(),
145        };
146    }
147
148    // Pre-dialplan: checking condition / action results from sofia_pre_dialplan.c
149    if msg.starts_with("checking condition") || msg.starts_with("action(") {
150        return MessageKind::ChannelLifecycle {
151            detail: msg.to_string(),
152        };
153    }
154
155    if msg.starts_with("Event Socket Command") {
156        return MessageKind::EventSocket {
157            detail: msg.to_string(),
158        };
159    }
160
161    // Media patterns (no channel prefix)
162    if let Some(kind) = detect_media(msg) {
163        return kind;
164    }
165
166    // Channel lifecycle patterns (no channel prefix)
167    if let Some(kind) = detect_channel_lifecycle(msg) {
168        return kind;
169    }
170
171    // Channel-prefixed messages: sofia/..., loopback/... prefix
172    if let Some((channel_part, rest)) = strip_channel_prefix(msg) {
173        return classify_channel_prefixed(channel_part, rest);
174    }
175
176    // Channel-* fields and other Key: [value] patterns from CHANNEL_DATA dumps
177    // Must come after more specific checks to avoid false positives
178    if let Some((name, value)) = parse_bracketed_value(msg, 0) {
179        let name_bytes = name.as_bytes();
180        if !name_bytes.is_empty()
181            && !name.contains(' ')
182            && name_bytes[0].is_ascii_alphabetic()
183            && (name.contains('-') || name.starts_with("Channel-"))
184        {
185            return MessageKind::ChannelField {
186                name: name.to_string(),
187                value: value.to_string(),
188            };
189        }
190    }
191
192    MessageKind::General
193}
194fn parse_core_session_set_variable(msg: &str) -> MessageKind {
195    let rest = &msg["CoreSession::setVariable(".len()..];
196    if let Some(end) = rest.strip_suffix(')') {
197        if let Some(comma) = end.find(", ") {
198            return MessageKind::Variable {
199                name: format!("variable_{}", &end[..comma]),
200                value: end[comma + 2..].to_string(),
201            };
202        }
203    }
204    MessageKind::Variable {
205        name: String::new(),
206        value: msg.to_string(),
207    }
208}
209
210fn parse_unset(msg: &str) -> MessageKind {
211    let rest = &msg["UNSET ".len()..];
212    let name = if let Some(inner) = rest.strip_prefix('[') {
213        inner.strip_suffix(']').unwrap_or(inner)
214    } else {
215        rest
216    };
217    MessageKind::Variable {
218        name: format!("variable_{name}"),
219        value: String::new(),
220    }
221}
222
223fn parse_dialplan_processing(msg: &str) -> MessageKind {
224    let rest = &msg["Processing ".len()..];
225    MessageKind::Dialplan {
226        channel: String::new(),
227        detail: rest.to_string(),
228    }
229}
230fn parse_set_or_export(msg: &str) -> Option<MessageKind> {
231    let parts = set_export_parts(msg)?;
232    Some(MessageKind::Variable {
233        name: format!("variable_{}", parts.name),
234        value: parts.value.to_string(),
235    })
236}