1use std::collections::{HashMap, HashSet};
2use std::str::FromStr;
3
4use freeswitch_types::{BridgeDialString, CallDirection, DialString};
5
6use crate::line::parse_line;
7use crate::message::{classify_message, MessageKind};
8use crate::stream::{Block, LogEntry, LogStream, ParseStats, UnclassifiedLine};
9
10type SessionHook = Box<dyn Fn(&LogEntry, &mut SessionState) + Send>;
11
12#[derive(Debug, Clone, Default)]
18pub struct SessionState {
19 pub channel_name: Option<String>,
21 pub channel_state: Option<String>,
23 pub initial_context: Option<String>,
25 pub initial_destination: Option<String>,
28 pub dialplan_context: Option<String>,
30 pub dialplan_from: Option<String>,
32 pub dialplan_to: Option<String>,
34 pub call_direction: Option<CallDirection>,
36 pub caller_id_number: Option<String>,
38 pub caller_id_name: Option<String>,
40 pub destination_number: Option<String>,
42 pub hangup_cause: Option<String>,
44 pub answered_at: Option<String>,
46 pub other_leg_uuid: Option<String>,
49 pub(crate) pending_bridge_target: Option<String>,
51 pub variables: HashMap<String, String>,
53}
54
55#[derive(Default)]
57struct IndexedFieldChanges {
58 channel_name: Option<(Option<String>, Option<String>)>,
59 pending_bridge_target: Option<(Option<String>, Option<String>)>,
60 other_leg_uuid: Option<(Option<String>, Option<String>)>,
61}
62
63#[derive(Debug, Clone)]
68pub struct SessionSnapshot {
69 pub channel_name: Option<String>,
70 pub channel_state: Option<String>,
71 pub initial_context: Option<String>,
72 pub initial_destination: Option<String>,
73 pub dialplan_context: Option<String>,
74 pub dialplan_from: Option<String>,
75 pub dialplan_to: Option<String>,
76 pub call_direction: Option<CallDirection>,
77 pub caller_id_number: Option<String>,
78 pub caller_id_name: Option<String>,
79 pub destination_number: Option<String>,
80 pub hangup_cause: Option<String>,
81 pub answered_at: Option<String>,
82 pub other_leg_uuid: Option<String>,
83}
84
85impl SessionState {
86 fn snapshot(&self) -> SessionSnapshot {
87 SessionSnapshot {
88 channel_name: self.channel_name.clone(),
89 channel_state: self.channel_state.clone(),
90 initial_context: self.initial_context.clone(),
91 initial_destination: self.initial_destination.clone(),
92 dialplan_context: self.dialplan_context.clone(),
93 dialplan_from: self.dialplan_from.clone(),
94 dialplan_to: self.dialplan_to.clone(),
95 call_direction: self.call_direction,
96 caller_id_number: self.caller_id_number.clone(),
97 caller_id_name: self.caller_id_name.clone(),
98 destination_number: self.destination_number.clone(),
99 hangup_cause: self.hangup_cause.clone(),
100 answered_at: self.answered_at.clone(),
101 other_leg_uuid: self.other_leg_uuid.clone(),
102 }
103 }
104
105 fn update_from_entry(&mut self, entry: &LogEntry) -> IndexedFieldChanges {
106 let old_channel_name = self.channel_name.clone();
107 let old_pending_bridge_target = self.pending_bridge_target.clone();
108 let old_other_leg_uuid = self.other_leg_uuid.clone();
109
110 if let Some(Block::ChannelData { fields, variables }) = &entry.block {
111 for (name, value) in fields {
112 match name.as_str() {
113 "Channel-Name" => self.channel_name = Some(value.clone()),
114 "Channel-State" => self.channel_state = Some(value.clone()),
115 "Call-Direction" => {
116 self.call_direction = CallDirection::from_str(value).ok();
117 }
118 "Caller-Caller-ID-Number" => {
119 self.caller_id_number = Some(value.clone());
120 }
121 "Caller-Caller-ID-Name" => {
122 self.caller_id_name = Some(value.clone());
123 }
124 "Caller-Destination-Number" => {
125 self.destination_number = Some(value.clone());
126 }
127 "Other-Leg-Unique-ID" => {
128 self.other_leg_uuid = Some(value.clone());
129 }
130 _ => {}
131 }
132 }
133 for (name, value) in variables {
134 let var_name = name.strip_prefix("variable_").unwrap_or(name);
135 self.variables.insert(var_name.to_string(), value.clone());
136 }
137 }
138
139 match &entry.message_kind {
140 MessageKind::Dialplan { detail, .. } => {
141 if let Some(dp) = parse_dialplan_context(detail) {
142 self.initial_context.get_or_insert(dp.context.clone());
143 self.dialplan_context = Some(dp.context);
144 self.dialplan_from = Some(dp.from);
145 self.dialplan_to = Some(dp.to);
146 }
147 }
148 MessageKind::Execute {
149 application,
150 arguments,
151 ..
152 } => match application.as_str() {
153 "set" | "export" => {
154 if let Some((name, value)) = arguments.split_once('=') {
155 self.variables.insert(name.to_string(), value.to_string());
156 }
157 }
158 "bridge" => {
159 if let Some(info) = parse_bridge_args(arguments) {
160 if let Some(uuid) = &info.origination_uuid {
161 self.other_leg_uuid = Some(uuid.clone());
162 }
163 self.pending_bridge_target = Some(info.target_channel);
164 }
165 }
166 _ => {}
167 },
168 MessageKind::Variable { name, value } => {
169 let var_name = name.strip_prefix("variable_").unwrap_or(name);
170 self.variables.insert(var_name.to_string(), value.clone());
171 }
172 MessageKind::ChannelField { name, value } => match name.as_str() {
173 "Channel-Name" => self.channel_name = Some(value.clone()),
174 "Channel-State" => self.channel_state = Some(value.clone()),
175 _ => {}
176 },
177 MessageKind::StateChange { detail } => {
178 if let Some(new_state) = parse_state_change(detail) {
179 self.channel_state = Some(new_state);
180 }
181 }
182 MessageKind::ChannelLifecycle { detail } => {
183 if let Some(name) = parse_new_channel(detail) {
184 if self.channel_name.is_none() {
185 self.channel_name = Some(name);
186 }
187 }
188 if let Some(cause) = parse_hangup(detail) {
189 self.hangup_cause = Some(cause);
190 }
191 if is_answered(detail) && self.answered_at.is_none() {
192 self.answered_at = Some(entry.timestamp.clone());
193 }
194 }
195 _ => {}
196 }
197
198 if entry.message.contains("Processing ") && entry.message.contains(" in context ") {
199 if let Some(dp) = parse_processing_line(&entry.message) {
200 self.initial_context.get_or_insert(dp.context.clone());
201 self.initial_destination.get_or_insert(dp.to.clone());
202 self.dialplan_context = Some(dp.context);
203 self.dialplan_from = Some(dp.from);
204 self.dialplan_to = Some(dp.to);
205 }
206 }
207
208 for attached in &entry.attached {
209 let parsed = parse_line(attached);
210 self.update_from_message(parsed.message);
211 }
212
213 let mut changes = IndexedFieldChanges::default();
214 if self.channel_name != old_channel_name {
215 changes.channel_name = Some((old_channel_name, self.channel_name.clone()));
216 }
217 if self.pending_bridge_target != old_pending_bridge_target {
218 changes.pending_bridge_target = Some((
219 old_pending_bridge_target,
220 self.pending_bridge_target.clone(),
221 ));
222 }
223 if self.other_leg_uuid != old_other_leg_uuid {
224 changes.other_leg_uuid = Some((old_other_leg_uuid, self.other_leg_uuid.clone()));
225 }
226 changes
227 }
228
229 fn update_from_message(&mut self, msg: &str) {
230 let kind = classify_message(msg);
231 match &kind {
232 MessageKind::Dialplan { detail, .. } => {
233 if let Some(dp) = parse_dialplan_context(detail) {
234 self.initial_context.get_or_insert(dp.context.clone());
235 self.dialplan_context = Some(dp.context);
236 self.dialplan_from = Some(dp.from);
237 self.dialplan_to = Some(dp.to);
238 }
239 }
240 MessageKind::Variable { name, value } => {
241 let var_name = name.strip_prefix("variable_").unwrap_or(name);
242 self.variables.insert(var_name.to_string(), value.clone());
243 }
244 MessageKind::ChannelField { name, value } => match name.as_str() {
245 "Channel-Name" => self.channel_name = Some(value.clone()),
246 "Channel-State" => self.channel_state = Some(value.clone()),
247 _ => {}
248 },
249 MessageKind::StateChange { detail } => {
250 if let Some(new_state) = parse_state_change(detail) {
251 self.channel_state = Some(new_state);
252 }
253 }
254 _ => {}
255 }
256 }
257}
258
259struct DialplanContext {
260 from: String,
261 to: String,
262 context: String,
263}
264
265fn parse_dialplan_context(detail: &str) -> Option<DialplanContext> {
266 if !detail.starts_with("parsing [") {
267 return None;
268 }
269 let rest = &detail["parsing [".len()..];
270 let bracket_end = rest.find(']')?;
271 let inner = &rest[..bracket_end];
272
273 let arrow = inner.find("->")?;
274 let from_part = &inner[..arrow];
275 let to_part = &inner[arrow + 2..];
276
277 let context = if rest.len() > bracket_end + 1 {
278 let after = rest[bracket_end + 1..].trim();
279 if let Some(stripped) = after.strip_prefix("continue=") {
280 let _ = stripped;
281 }
282 from_part.to_string()
283 } else {
284 from_part.to_string()
285 };
286
287 Some(DialplanContext {
288 from: from_part.to_string(),
289 to: to_part.to_string(),
290 context,
291 })
292}
293
294fn parse_processing_line(msg: &str) -> Option<DialplanContext> {
300 let proc_idx = msg.find("Processing ")?;
301 let after_proc = &msg[proc_idx + "Processing ".len()..];
302
303 let ctx_idx = after_proc.rfind(" in context ")?;
304 let head = &after_proc[..ctx_idx];
305 let context = after_proc[ctx_idx + " in context ".len()..]
306 .split_whitespace()
307 .next()?;
308
309 let (from, to) = match head.rfind(">->") {
310 Some(i) => (&head[..i + 1], &head[i + ">->".len()..]),
311 None => {
312 let i = head.rfind("->")?;
313 (&head[..i], &head[i + "->".len()..])
314 }
315 };
316
317 Some(DialplanContext {
318 from: from.to_string(),
319 to: to.to_string(),
320 context: context.to_string(),
321 })
322}
323
324fn parse_new_channel(detail: &str) -> Option<String> {
325 let rest = detail.strip_prefix("New Channel ")?;
326 let bracket = rest.rfind(" [")?;
327 Some(rest[..bracket].to_string())
328}
329
330fn parse_state_change(detail: &str) -> Option<String> {
331 let arrow = detail.find(" -> ")?;
332 Some(detail[arrow + 4..].trim().to_string())
333}
334
335fn parse_hangup(detail: &str) -> Option<String> {
336 if !detail.contains("Hangup ") {
337 return None;
338 }
339 let start = detail.rfind('[')?;
340 let end = detail[start..].find(']')?;
341 Some(detail[start + 1..start + end].to_string())
342}
343
344fn is_answered(detail: &str) -> bool {
345 detail.contains("has been answered")
346}
347
348fn parse_bridge_args(arguments: &str) -> Option<BridgeInfo> {
352 let dial = BridgeDialString::from_str(arguments).ok()?;
353 let first_ep = dial.groups().first()?.first()?;
354 let origination_uuid = first_ep
355 .variables()
356 .and_then(|v| v.get("origination_uuid"))
357 .map(|s| s.to_string());
358 let mut bare = first_ep.clone();
359 bare.set_variables(None);
360 let target_channel = bare.to_string();
361 Some(BridgeInfo {
362 origination_uuid,
363 target_channel,
364 })
365}
366
367struct BridgeInfo {
368 origination_uuid: Option<String>,
369 target_channel: String,
370}
371
372fn parse_originate_success(msg: &str) -> Option<String> {
374 let marker = "Peer UUID: ";
375 let idx = msg.find(marker)?;
376 let uuid = msg[idx + marker.len()..].trim();
377 if uuid.is_empty() {
378 None
379 } else {
380 Some(uuid.to_string())
381 }
382}
383
384fn parse_originate_channel(msg: &str) -> Option<&str> {
388 let start = msg.find(" [")? + 2;
389 let end = msg[start..].find(']')?;
390 let chan = &msg[start..start + end];
391 if chan.is_empty() {
392 None
393 } else {
394 Some(chan)
395 }
396}
397
398fn is_terminal_channel_state(state: Option<&str>) -> bool {
406 matches!(
407 state,
408 Some("CS_HANGUP" | "CS_REPORTING" | "CS_DESTROY" | "CS_NONE" | "HANGUP")
409 )
410}
411
412#[derive(Debug)]
414pub struct EnrichedEntry {
415 pub entry: LogEntry,
416 pub session: Option<SessionSnapshot>,
418}
419
420pub struct SessionTracker<I> {
427 inner: LogStream<I>,
428 sessions: HashMap<String, SessionState>,
429 by_channel_name: HashMap<String, HashSet<String>>,
430 by_pending_target: HashMap<String, String>,
431 by_other_leg: HashMap<String, String>,
432 pre_hook: Option<SessionHook>,
433 post_hook: Option<SessionHook>,
434}
435
436impl<I: Iterator<Item = String>> SessionTracker<I> {
437 pub fn new(inner: LogStream<I>) -> Self {
439 SessionTracker {
440 inner,
441 sessions: HashMap::new(),
442 by_channel_name: HashMap::new(),
443 by_pending_target: HashMap::new(),
444 by_other_leg: HashMap::new(),
445 pre_hook: None,
446 post_hook: None,
447 }
448 }
449
450 pub fn with_pre_hook<F>(mut self, hook: F) -> Self
456 where
457 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
458 {
459 self.pre_hook = Some(Box::new(hook));
460 self
461 }
462
463 pub fn with_post_hook<F>(mut self, hook: F) -> Self
486 where
487 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
488 {
489 self.post_hook = Some(Box::new(hook));
490 self
491 }
492
493 pub fn sessions(&self) -> &HashMap<String, SessionState> {
495 &self.sessions
496 }
497
498 pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
501 let state = self.sessions.remove(uuid)?;
502 if let Some(chan) = &state.channel_name {
503 if let Some(set) = self.by_channel_name.get_mut(chan) {
504 set.remove(uuid);
505 if set.is_empty() {
506 self.by_channel_name.remove(chan);
507 }
508 }
509 }
510 if let Some(target) = &state.pending_bridge_target {
511 self.by_pending_target.remove(target);
512 }
513 if let Some(other) = &state.other_leg_uuid {
514 self.by_other_leg.remove(other);
515 }
516 Some(state)
517 }
518
519 pub fn stats(&self) -> &ParseStats {
521 self.inner.stats()
522 }
523
524 pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
526 self.inner.drain_unclassified()
527 }
528
529 fn apply_index_changes(&mut self, uuid: &str, changes: &IndexedFieldChanges) {
530 if let Some((old, new)) = &changes.channel_name {
531 if let Some(old_name) = old {
532 if let Some(set) = self.by_channel_name.get_mut(old_name) {
533 set.remove(uuid);
534 if set.is_empty() {
535 self.by_channel_name.remove(old_name);
536 }
537 }
538 }
539 if let Some(new_name) = new {
540 self.by_channel_name
541 .entry(new_name.clone())
542 .or_default()
543 .insert(uuid.to_string());
544 }
545 }
546 if let Some((old, new)) = &changes.pending_bridge_target {
547 if let Some(old_target) = old {
548 self.by_pending_target.remove(old_target);
549 }
550 if let Some(new_target) = new {
551 self.by_pending_target
552 .insert(new_target.clone(), uuid.to_string());
553 }
554 }
555 if let Some((old, new)) = &changes.other_leg_uuid {
556 if let Some(old_leg) = old {
557 self.by_other_leg.remove(old_leg);
558 }
559 if let Some(new_leg) = new {
560 self.by_other_leg.insert(new_leg.clone(), uuid.to_string());
561 }
562 }
563 }
564
565 fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
568 if entry.message.contains("Originate Resulted in Success") {
570 let a_uuid = uuid.to_string();
571 if let Some(peer_uuid) = parse_originate_success(&entry.message) {
572 let a_old_pending = self
573 .sessions
574 .get(&a_uuid)
575 .and_then(|s| s.pending_bridge_target.clone());
576
577 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
578 a_state.other_leg_uuid = Some(peer_uuid.clone());
579 a_state.pending_bridge_target = None;
580 }
581 self.by_other_leg.insert(peer_uuid.clone(), a_uuid.clone());
582 if let Some(old_target) = a_old_pending {
583 self.by_pending_target.remove(&old_target);
584 }
585
586 let b_state = self.sessions.entry(peer_uuid.clone()).or_default();
587 b_state.other_leg_uuid = Some(a_uuid.clone());
588 self.by_other_leg.insert(a_uuid, peer_uuid);
589 } else if let Some(chan) = parse_originate_channel(&entry.message) {
590 let candidates: Vec<String> = self
595 .by_channel_name
596 .get(chan)
597 .map(|set| {
598 set.iter()
599 .filter(|u| *u != &a_uuid)
600 .filter(|u| {
601 self.sessions
602 .get(*u)
603 .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
604 .unwrap_or(false)
605 })
606 .cloned()
607 .collect()
608 })
609 .unwrap_or_default();
610
611 if candidates.len() == 1 {
612 let b_uuid = candidates.into_iter().next().unwrap();
613 let a_old_pending = self
614 .sessions
615 .get(&a_uuid)
616 .and_then(|s| s.pending_bridge_target.clone());
617
618 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
619 a_state.other_leg_uuid = Some(b_uuid.clone());
620 a_state.pending_bridge_target = None;
621 }
622 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
623 b_state.other_leg_uuid = Some(a_uuid.clone());
624 }
625
626 self.by_other_leg.insert(b_uuid.clone(), a_uuid.clone());
627 self.by_other_leg.insert(a_uuid, b_uuid);
628 if let Some(old_target) = a_old_pending {
629 self.by_pending_target.remove(&old_target);
630 }
631 }
632 }
633 return;
634 }
635
636 if let MessageKind::ChannelLifecycle { detail } = &entry.message_kind {
639 if let Some(channel_name) = parse_new_channel(detail) {
640 let b_uuid = uuid.to_string();
641
642 let a_uuid_found = self
644 .by_other_leg
645 .get(&b_uuid)
646 .cloned()
647 .or_else(|| self.by_pending_target.get(&channel_name).cloned())
648 .filter(|a| a != &b_uuid);
649
650 if let Some(a_uuid) = a_uuid_found {
651 let a_old_pending = self
652 .sessions
653 .get(&a_uuid)
654 .and_then(|s| s.pending_bridge_target.clone());
655
656 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
657 a_state.other_leg_uuid = Some(b_uuid.clone());
658 a_state.pending_bridge_target = None;
659 }
660 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
661 b_state.other_leg_uuid = Some(a_uuid.clone());
662 }
663
664 self.by_other_leg.insert(b_uuid.clone(), a_uuid.clone());
665 self.by_other_leg.insert(a_uuid, b_uuid);
666 if let Some(old_target) = a_old_pending {
667 self.by_pending_target.remove(&old_target);
668 }
669 }
670 }
671 }
672 }
673}
674
675impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
676 type Item = EnrichedEntry;
677
678 fn next(&mut self) -> Option<EnrichedEntry> {
679 let entry = self.inner.next()?;
680
681 if entry.uuid.is_empty() {
682 return Some(EnrichedEntry {
683 entry,
684 session: None,
685 });
686 }
687
688 let uuid = entry.uuid.clone();
689 self.sessions.entry(uuid.clone()).or_default();
690
691 if let Some(hook) = &self.pre_hook {
692 let state = self.sessions.get_mut(&uuid).unwrap();
693 hook(&entry, state);
694 }
695
696 let changes = self
697 .sessions
698 .get_mut(&uuid)
699 .unwrap()
700 .update_from_entry(&entry);
701 self.apply_index_changes(&uuid, &changes);
702
703 self.link_legs(&uuid, &entry);
704
705 if let Some(hook) = &self.post_hook {
706 let state = self.sessions.get_mut(&uuid).unwrap();
707 hook(&entry, state);
708 }
709
710 let snapshot = self.sessions.get(&uuid).unwrap().snapshot();
711
712 Some(EnrichedEntry {
713 entry,
714 session: Some(snapshot),
715 })
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
724 const UUID2: &str = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
725 const UUID3: &str = "c3d4e5f6-a7b8-9012-cdef-234567890123";
726 const TS1: &str = "2025-01-15 10:30:45.123456";
727 const TS2: &str = "2025-01-15 10:30:46.234567";
728
729 fn full_line(uuid: &str, ts: &str, msg: &str) -> String {
730 format!("{uuid} {ts} 95.97% [DEBUG] sofia.c:100 {msg}")
731 }
732
733 fn collect_enriched(lines: Vec<String>) -> Vec<EnrichedEntry> {
734 let stream = LogStream::new(lines.into_iter());
735 SessionTracker::new(stream).collect()
736 }
737
738 #[test]
739 fn system_line_no_session() {
740 let lines = vec![format!(
741 "{TS1} 95.97% [INFO] mod_event_socket.c:1772 Event Socket command"
742 )];
743 let entries = collect_enriched(lines);
744 assert_eq!(entries.len(), 1);
745 assert!(entries[0].session.is_none());
746 }
747
748 #[test]
749 fn dialplan_context_propagation() {
750 let lines = vec![
751 full_line(UUID1, TS1, "CHANNEL_DATA:"),
752 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
753 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer"),
754 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true"),
755 full_line(UUID1, TS2, "Some later event"),
756 ];
757 let entries = collect_enriched(lines);
758 let last = entries.last().unwrap();
759 let session = last.session.as_ref().unwrap();
760 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
761 assert_eq!(session.dialplan_from.as_deref(), Some("public"));
762 assert_eq!(session.dialplan_to.as_deref(), Some("global"));
763 }
764
765 #[test]
766 fn processing_line_extracts_context() {
767 let lines = vec![full_line(
768 UUID1,
769 TS1,
770 "Processing 5551234567->5559876543 in context public",
771 )];
772 let entries = collect_enriched(lines);
773 let session = entries[0].session.as_ref().unwrap();
774 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
775 assert_eq!(session.dialplan_from.as_deref(), Some("5551234567"));
776 assert_eq!(session.dialplan_to.as_deref(), Some("5559876543"));
777 }
778
779 #[test]
780 fn initial_context_preserved_across_transfers() {
781 let lines = vec![
782 full_line(
783 UUID1,
784 TS1,
785 "Processing 5551234567->5559876543 in context public",
786 ),
787 full_line(
788 UUID1,
789 TS2,
790 "Processing 5551234567->start_recording in context recordings",
791 ),
792 ];
793 let stream = LogStream::new(lines.into_iter());
794 let mut tracker = SessionTracker::new(stream);
795 let entries: Vec<_> = tracker.by_ref().collect();
796
797 let first = entries[0].session.as_ref().unwrap();
798 assert_eq!(
799 first.initial_context.as_deref(),
800 Some("public"),
801 "initial_context set on first Processing line"
802 );
803 assert_eq!(first.dialplan_context.as_deref(), Some("public"));
804
805 let state = tracker.sessions().get(UUID1).unwrap();
806 assert_eq!(
807 state.initial_context.as_deref(),
808 Some("public"),
809 "initial_context keeps the first context seen"
810 );
811 assert_eq!(
812 state.dialplan_context.as_deref(),
813 Some("recordings"),
814 "dialplan_context tracks the current context"
815 );
816 assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
817 }
818
819 #[test]
820 fn new_channel_sets_channel_name() {
821 let lines = vec![full_line(
822 UUID1,
823 TS1,
824 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
825 )];
826 let entries = collect_enriched(lines);
827 let session = entries[0].session.as_ref().unwrap();
828 assert_eq!(
829 session.channel_name.as_deref(),
830 Some("sofia/internal-v4/sos")
831 );
832 }
833
834 #[test]
835 fn originate_success_links_both_legs() {
836 let lines = vec![
839 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
840 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/esinet1-v6-tcp/sip:target.example.com] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901"),
841 ];
842 let stream = LogStream::new(lines.into_iter());
843 let mut tracker = SessionTracker::new(stream);
844 let _: Vec<_> = tracker.by_ref().collect();
845
846 let a_leg = tracker.sessions().get(UUID1).unwrap();
847 assert_eq!(
848 a_leg.other_leg_uuid.as_deref(),
849 Some(UUID2),
850 "A-leg other_leg_uuid set from Originate Resulted in Success"
851 );
852
853 let b_leg = tracker.sessions().get(UUID2).unwrap();
854 assert_eq!(
855 b_leg.other_leg_uuid.as_deref(),
856 Some(UUID1),
857 "B-leg other_leg_uuid points back to A-leg"
858 );
859 }
860
861 #[test]
862 fn originate_success_channel_fallback_links_legs() {
863 let lines = vec![
867 full_line(
868 UUID2,
869 TS1,
870 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
871 ),
872 full_line(
873 UUID1,
874 TS2,
875 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
876 ),
877 ];
878 let stream = LogStream::new(lines.into_iter());
879 let mut tracker = SessionTracker::new(stream);
880 let _: Vec<_> = tracker.by_ref().collect();
881
882 let a_leg = tracker.sessions().get(UUID1).unwrap();
883 assert_eq!(
884 a_leg.other_leg_uuid.as_deref(),
885 Some(UUID2),
886 "A-leg linked to B-leg via channel-name fallback when Peer UUID absent"
887 );
888
889 let b_leg = tracker.sessions().get(UUID2).unwrap();
890 assert_eq!(
891 b_leg.other_leg_uuid.as_deref(),
892 Some(UUID1),
893 "B-leg linked back to A-leg"
894 );
895 }
896
897 #[test]
898 fn originate_success_peer_uuid_wins_over_channel_fallback() {
899 let lines = vec![
902 full_line(
903 UUID2,
904 TS1,
905 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
906 ),
907 full_line(
908 UUID3,
909 TS1,
910 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
911 ),
912 full_line(
913 UUID1,
914 TS2,
915 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901",
916 ),
917 ];
918 let stream = LogStream::new(lines.into_iter());
919 let mut tracker = SessionTracker::new(stream);
920 let _: Vec<_> = tracker.by_ref().collect();
921
922 let a_leg = tracker.sessions().get(UUID1).unwrap();
923 assert_eq!(
924 a_leg.other_leg_uuid.as_deref(),
925 Some(UUID2),
926 "Peer UUID wins over channel-name match"
927 );
928
929 let decoy = tracker.sessions().get(UUID3).unwrap();
930 assert_eq!(
931 decoy.other_leg_uuid, None,
932 "Decoy session sharing channel name is not touched"
933 );
934 }
935
936 #[test]
937 fn originate_success_channel_fallback_skips_when_ambiguous() {
938 let lines = vec![
941 full_line(
942 UUID2,
943 TS1,
944 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
945 ),
946 full_line(
947 UUID3,
948 TS1,
949 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
950 ),
951 full_line(
952 UUID1,
953 TS2,
954 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
955 ),
956 ];
957 let stream = LogStream::new(lines.into_iter());
958 let mut tracker = SessionTracker::new(stream);
959 let _: Vec<_> = tracker.by_ref().collect();
960
961 let a_leg = tracker.sessions().get(UUID1).unwrap();
962 assert_eq!(
963 a_leg.other_leg_uuid, None,
964 "Ambiguous channel name yields no link"
965 );
966 assert_eq!(tracker.sessions().get(UUID2).unwrap().other_leg_uuid, None);
967 assert_eq!(tracker.sessions().get(UUID3).unwrap().other_leg_uuid, None);
968 }
969
970 #[test]
971 fn originate_success_channel_fallback_skips_terminated_candidates() {
972 let lines = vec![
977 full_line(
978 UUID2,
979 TS1,
980 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
981 ),
982 full_line(
983 UUID2,
984 TS1,
985 "(sofia/internal/6244@192.0.2.72:50744) State Change CS_EXECUTE -> CS_DESTROY",
986 ),
987 full_line(
988 UUID3,
989 TS1,
990 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
991 ),
992 full_line(
993 UUID1,
994 TS2,
995 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
996 ),
997 ];
998 let stream = LogStream::new(lines.into_iter());
999 let mut tracker = SessionTracker::new(stream);
1000 let _: Vec<_> = tracker.by_ref().collect();
1001
1002 let a_leg = tracker.sessions().get(UUID1).unwrap();
1003 assert_eq!(
1004 a_leg.other_leg_uuid.as_deref(),
1005 Some(UUID3),
1006 "Live b-leg wins over CS_DESTROY straggler"
1007 );
1008
1009 let live_b = tracker.sessions().get(UUID3).unwrap();
1010 assert_eq!(
1011 live_b.other_leg_uuid.as_deref(),
1012 Some(UUID1),
1013 "Live b-leg points back to a-leg"
1014 );
1015
1016 let stale_b = tracker.sessions().get(UUID2).unwrap();
1017 assert_eq!(
1018 stale_b.other_leg_uuid, None,
1019 "Terminated b-leg is not touched"
1020 );
1021 }
1022
1023 #[test]
1024 fn originate_success_channel_fallback_skips_when_no_match() {
1025 let lines = vec![full_line(
1028 UUID1,
1029 TS2,
1030 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1031 )];
1032 let stream = LogStream::new(lines.into_iter());
1033 let mut tracker = SessionTracker::new(stream);
1034 let _: Vec<_> = tracker.by_ref().collect();
1035
1036 let a_leg = tracker.sessions().get(UUID1).unwrap();
1037 assert_eq!(a_leg.other_leg_uuid, None);
1038 assert_eq!(a_leg.pending_bridge_target, None);
1039 }
1040
1041 #[test]
1042 fn bridge_origination_uuid_links_a_leg_immediately() {
1043 let lines = vec![
1046 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal-v6/1232@[2001:db8::10] bridge([origination_uuid=b2c3d4e5-f6a7-8901-bcde-f12345678901,leg_timeout=2]sofia/esinet1-v6-tcp/sip:target.example.com)"),
1047 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1048 ];
1049 let stream = LogStream::new(lines.into_iter());
1050 let mut tracker = SessionTracker::new(stream);
1051 let _: Vec<_> = tracker.by_ref().collect();
1052
1053 let a_leg = tracker.sessions().get(UUID1).unwrap();
1054 assert_eq!(
1055 a_leg.other_leg_uuid.as_deref(),
1056 Some(UUID2),
1057 "A-leg knows B-leg UUID from origination_uuid in bridge args"
1058 );
1059
1060 let b_leg = tracker.sessions().get(UUID2).unwrap();
1061 assert_eq!(
1062 b_leg.other_leg_uuid.as_deref(),
1063 Some(UUID1),
1064 "B-leg knows A-leg once New Channel correlates"
1065 );
1066 }
1067
1068 #[test]
1069 fn bridge_target_matches_new_channel() {
1070 let lines = vec![
1073 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1074 full_line(UUID1, TS1, "Parsing session specific variables"),
1075 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1076 ];
1077 let stream = LogStream::new(lines.into_iter());
1078 let mut tracker = SessionTracker::new(stream);
1079 let _: Vec<_> = tracker.by_ref().collect();
1080
1081 let a_leg = tracker.sessions().get(UUID1).unwrap();
1082 assert_eq!(
1083 a_leg.other_leg_uuid.as_deref(),
1084 Some(UUID2),
1085 "A-leg linked to B-leg via bridge target matching New Channel"
1086 );
1087
1088 let b_leg = tracker.sessions().get(UUID2).unwrap();
1089 assert_eq!(
1090 b_leg.other_leg_uuid.as_deref(),
1091 Some(UUID1),
1092 "B-leg linked back to A-leg"
1093 );
1094 }
1095
1096 #[test]
1097 fn originate_success_corrects_wrong_target_match() {
1098 let lines = vec![
1101 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1102 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1103 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/gateway/carrier/+15559876543] Peer UUID: c3d4e5f6-a7b8-9012-cdef-234567890123"),
1104 ];
1105 let stream = LogStream::new(lines.into_iter());
1106 let mut tracker = SessionTracker::new(stream);
1107 let _: Vec<_> = tracker.by_ref().collect();
1108
1109 let a_leg = tracker.sessions().get(UUID1).unwrap();
1110 assert_eq!(
1111 a_leg.other_leg_uuid.as_deref(),
1112 Some(UUID3),
1113 "Originate success overrides earlier target-match guess"
1114 );
1115
1116 let real_b_leg = tracker.sessions().get(UUID3).unwrap();
1117 assert_eq!(
1118 real_b_leg.other_leg_uuid.as_deref(),
1119 Some(UUID1),
1120 "Real B-leg points back to A-leg"
1121 );
1122 }
1123
1124 #[test]
1125 fn channel_data_other_leg_uuid() {
1126 let lines = vec![
1128 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1129 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1130 ];
1131 let stream = LogStream::new(lines.into_iter());
1132 let mut tracker = SessionTracker::new(stream);
1133 let _: Vec<_> = tracker.by_ref().collect();
1134
1135 let state = tracker.sessions().get(UUID1).unwrap();
1136 assert_eq!(
1137 state.other_leg_uuid.as_deref(),
1138 Some(UUID2),
1139 "other_leg_uuid set from Other-Leg-Unique-ID CHANNEL_DATA field"
1140 );
1141 }
1142
1143 #[test]
1144 fn channel_data_populates_session() {
1145 let lines = vec![
1146 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1147 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1148 format!("{UUID1} Channel-State: [CS_EXECUTE]"),
1149 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1150 "variable_direction: [inbound]".to_string(),
1151 ];
1152 let entries = collect_enriched(lines);
1153 assert_eq!(entries.len(), 1);
1154 let session = entries[0].session.as_ref().unwrap();
1155 assert_eq!(
1156 session.channel_name.as_deref(),
1157 Some("sofia/internal/+15550001234@192.0.2.1")
1158 );
1159 assert_eq!(session.channel_state.as_deref(), Some("CS_EXECUTE"));
1160 }
1161
1162 #[test]
1163 fn variables_learned_from_channel_data() {
1164 let lines = vec![
1165 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1166 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1167 "variable_direction: [inbound]".to_string(),
1168 ];
1169 let stream = LogStream::new(lines.into_iter());
1170 let mut tracker = SessionTracker::new(stream);
1171 let _: Vec<_> = tracker.by_ref().collect();
1172 let state = tracker.sessions().get(UUID1).unwrap();
1173 assert_eq!(
1174 state.variables.get("sip_call_id").map(|s| s.as_str()),
1175 Some("test123@192.0.2.1")
1176 );
1177 assert_eq!(
1178 state.variables.get("direction").map(|s| s.as_str()),
1179 Some("inbound")
1180 );
1181 }
1182
1183 #[test]
1184 fn variables_learned_from_set_execute() {
1185 let lines = vec![
1186 full_line(UUID1, TS1, "First"),
1187 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(call_direction=inbound)"),
1188 full_line(UUID1, TS2, "After set"),
1189 ];
1190 let stream = LogStream::new(lines.into_iter());
1191 let mut tracker = SessionTracker::new(stream);
1192 let entries: Vec<_> = tracker.by_ref().collect();
1193 assert_eq!(entries.len(), 3);
1194 let state = tracker.sessions().get(UUID1).unwrap();
1195 assert_eq!(
1196 state.variables.get("call_direction").map(|s| s.as_str()),
1197 Some("inbound")
1198 );
1199 }
1200
1201 #[test]
1202 fn variables_learned_from_export_execute() {
1203 let lines = vec![
1204 full_line(UUID1, TS1, "First"),
1205 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)"),
1206 ];
1207 let stream = LogStream::new(lines.into_iter());
1208 let mut tracker = SessionTracker::new(stream);
1209 let _: Vec<_> = tracker.by_ref().collect();
1210 let state = tracker.sessions().get(UUID1).unwrap();
1211 assert_eq!(
1212 state.variables.get("originate_timeout").map(|s| s.as_str()),
1213 Some("3600")
1214 );
1215 }
1216
1217 #[test]
1218 fn session_isolation_between_uuids() {
1219 let lines = vec![
1220 full_line(
1221 UUID1,
1222 TS1,
1223 "Processing 5551111111->5552222222 in context public",
1224 ),
1225 full_line(
1226 UUID2,
1227 TS2,
1228 "Processing 5553333333->5554444444 in context private",
1229 ),
1230 ];
1231 let stream = LogStream::new(lines.into_iter());
1232 let mut tracker = SessionTracker::new(stream);
1233 let _: Vec<_> = tracker.by_ref().collect();
1234 let s1 = tracker.sessions().get(UUID1).unwrap();
1235 let s2 = tracker.sessions().get(UUID2).unwrap();
1236 assert_eq!(s1.dialplan_context.as_deref(), Some("public"));
1237 assert_eq!(s2.dialplan_context.as_deref(), Some("private"));
1238 assert_eq!(s1.dialplan_from.as_deref(), Some("5551111111"));
1239 assert_eq!(s2.dialplan_from.as_deref(), Some("5553333333"));
1240 }
1241
1242 #[test]
1243 fn processing_line_with_regex_type_and_angle_bracket_caller() {
1244 let lines = vec![full_line(
1245 UUID1,
1246 TS1,
1247 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1248 )];
1249 let entries = collect_enriched(lines);
1250 let session = entries[0].session.as_ref().unwrap();
1251 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1252 assert_eq!(session.dialplan_context.as_deref(), Some("recordings"));
1253 assert_eq!(
1254 session.dialplan_from.as_deref(),
1255 Some("Emergency S R <5550001234>")
1256 );
1257 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1258 }
1259
1260 #[test]
1261 fn processing_line_extension_format() {
1262 let lines = vec![full_line(
1263 UUID1,
1264 TS1,
1265 "Processing Extension 1263 <1263>->start_recording in context recordings",
1266 )];
1267 let entries = collect_enriched(lines);
1268 let session = entries[0].session.as_ref().unwrap();
1269 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1270 assert_eq!(
1271 session.dialplan_from.as_deref(),
1272 Some("Extension 1263 <1263>")
1273 );
1274 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1275 }
1276
1277 #[test]
1278 fn parse_processing_line_anchors_on_last_arrow() {
1279 let dest = |msg: &str| parse_processing_line(msg).map(|dp| dp.to);
1280 assert_eq!(
1281 dest("Processing Anonymous <anonymous>->5550001234 in context public").as_deref(),
1282 Some("5550001234"),
1283 );
1284 assert_eq!(
1285 dest("Processing 5550009999 <5550009999>->5550001234 in context public").as_deref(),
1286 Some("5550001234"),
1287 );
1288 assert_eq!(
1289 dest("Processing Jane Doe <5550009999>->5550001234 in context internal").as_deref(),
1290 Some("5550001234"),
1291 );
1292 assert_eq!(
1294 dest("Processing Weird -> Name <5550009999>->5550001234 in context internal")
1295 .as_deref(),
1296 Some("5550001234"),
1297 );
1298 assert_eq!(
1300 dest("Processing Jane Doe <5550009999>->start_recording in context features")
1301 .as_deref(),
1302 Some("start_recording"),
1303 );
1304 }
1305
1306 #[test]
1307 fn initial_destination_first_wins() {
1308 let lines = vec![
1309 full_line(
1310 UUID1,
1311 TS1,
1312 "Processing Jane Doe <5550009999>->5550001234 in context public",
1313 ),
1314 full_line(
1315 UUID1,
1316 TS2,
1317 "Processing Jane Doe <5550009999>->5550001234 in context transit",
1318 ),
1319 full_line(
1320 UUID1,
1321 TS2,
1322 "Processing Jane Doe <5550009999>->start_recording in context features",
1323 ),
1324 full_line(
1325 UUID1,
1326 TS2,
1327 "Processing Jane Doe <5550009999>->check_end_call in context features",
1328 ),
1329 ];
1330 let stream = LogStream::new(lines.into_iter());
1331 let mut tracker = SessionTracker::new(stream);
1332 let _: Vec<_> = tracker.by_ref().collect();
1333
1334 let state = tracker.sessions().get(UUID1).unwrap();
1335 assert_eq!(
1336 state.initial_destination.as_deref(),
1337 Some("5550001234"),
1338 "initial_destination keeps the dialed number from the first Processing line"
1339 );
1340 assert_eq!(
1341 state.dialplan_to.as_deref(),
1342 Some("check_end_call"),
1343 "dialplan_to is last-wins and gets clobbered by feature-context routing"
1344 );
1345 }
1346
1347 #[test]
1348 fn state_change_updates_channel_state() {
1349 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1350 let entries = collect_enriched(lines);
1351 let session = entries[0].session.as_ref().unwrap();
1352 assert_eq!(session.channel_state.as_deref(), Some("CS_ROUTING"));
1353 }
1354
1355 #[test]
1356 fn callstate_change_updates_channel_state() {
1357 let lines = vec![full_line(
1358 UUID1,
1359 TS1,
1360 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1361 )];
1362 let entries = collect_enriched(lines);
1363 let session = entries[0].session.as_ref().unwrap();
1364 assert_eq!(session.channel_state.as_deref(), Some("RINGING"));
1365 }
1366
1367 #[test]
1368 fn state_change_overrides_callstate() {
1369 let lines = vec![
1370 full_line(
1371 UUID1,
1372 TS1,
1373 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1374 ),
1375 full_line(
1376 UUID1,
1377 TS2,
1378 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1379 ),
1380 ];
1381 let entries = collect_enriched(lines);
1382 assert_eq!(
1383 entries[0]
1384 .session
1385 .as_ref()
1386 .unwrap()
1387 .channel_state
1388 .as_deref(),
1389 Some("RINGING")
1390 );
1391 assert_eq!(
1392 entries[1]
1393 .session
1394 .as_ref()
1395 .unwrap()
1396 .channel_state
1397 .as_deref(),
1398 Some("CS_EXCHANGE_MEDIA")
1399 );
1400 }
1401
1402 #[test]
1403 fn bleg_lifecycle_extracts_data_from_processing() {
1404 let lines = vec![
1405 full_line(
1406 UUID1,
1407 TS1,
1408 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1409 ),
1410 full_line(
1411 UUID1,
1412 TS1,
1413 "(sofia/internal-v4/sos) State Change CS_NEW -> CS_INIT",
1414 ),
1415 full_line(
1416 UUID1,
1417 TS1,
1418 "(sofia/internal-v4/sos) State Change CS_INIT -> CS_ROUTING",
1419 ),
1420 full_line(
1421 UUID1,
1422 TS1,
1423 "(sofia/internal-v4/sos) State Change CS_ROUTING -> CS_CONSUME_MEDIA",
1424 ),
1425 full_line(
1426 UUID1,
1427 TS1,
1428 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1429 ),
1430 full_line(
1431 UUID1,
1432 TS2,
1433 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1434 ),
1435 full_line(
1436 UUID1,
1437 TS2,
1438 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1439 ),
1440 full_line(
1441 UUID1,
1442 TS2,
1443 "(sofia/internal-v4/sos) State Change CS_EXCHANGE_MEDIA -> CS_HANGUP",
1444 ),
1445 ];
1446 let entries = collect_enriched(lines);
1447
1448 let after_ringing = entries[4].session.as_ref().unwrap();
1449 assert_eq!(after_ringing.channel_state.as_deref(), Some("RINGING"));
1450 assert!(after_ringing.initial_context.is_none());
1451
1452 let after_processing = entries[6].session.as_ref().unwrap();
1453 assert_eq!(
1454 after_processing.channel_state.as_deref(),
1455 Some("CS_EXCHANGE_MEDIA")
1456 );
1457 assert_eq!(
1458 after_processing.initial_context.as_deref(),
1459 Some("recordings")
1460 );
1461 assert_eq!(
1462 after_processing.dialplan_from.as_deref(),
1463 Some("Emergency S R <5550001234>")
1464 );
1465 assert_eq!(
1466 after_processing.dialplan_to.as_deref(),
1467 Some("start_recording")
1468 );
1469
1470 let after_hangup = entries[7].session.as_ref().unwrap();
1471 assert_eq!(after_hangup.channel_state.as_deref(), Some("CS_HANGUP"));
1472 assert_eq!(after_hangup.initial_context.as_deref(), Some("recordings"));
1473 }
1474
1475 #[test]
1476 fn channel_name_from_new_channel() {
1477 let lines = vec![full_line(
1478 UUID1,
1479 TS1,
1480 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1481 )];
1482 let entries = collect_enriched(lines);
1483 let session = entries[0].session.as_ref().unwrap();
1484 assert_eq!(
1485 session.channel_name.as_deref(),
1486 Some("sofia/internal-v4/sos")
1487 );
1488 }
1489
1490 #[test]
1491 fn remove_session() {
1492 let lines = vec![full_line(
1493 UUID1,
1494 TS1,
1495 "Processing 5551111111->5552222222 in context public",
1496 )];
1497 let stream = LogStream::new(lines.into_iter());
1498 let mut tracker = SessionTracker::new(stream);
1499 let _: Vec<_> = tracker.by_ref().collect();
1500 assert!(tracker.sessions().contains_key(UUID1));
1501 let removed = tracker.remove_session(UUID1).unwrap();
1502 assert_eq!(removed.dialplan_context.as_deref(), Some("public"));
1503 assert!(!tracker.sessions().contains_key(UUID1));
1504 }
1505
1506 #[test]
1507 fn stats_delegation() {
1508 let lines = vec![
1509 full_line(UUID1, TS1, "First"),
1510 full_line(UUID1, TS2, "Second"),
1511 ];
1512 let stream = LogStream::new(lines.into_iter());
1513 let mut tracker = SessionTracker::new(stream);
1514 let _: Vec<_> = tracker.by_ref().collect();
1515 assert_eq!(tracker.stats().lines_processed, 2);
1516 }
1517
1518 #[test]
1519 fn snapshot_reflects_cumulative_state() {
1520 let lines = vec![
1521 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1522 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1523 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"),
1524 full_line(
1525 UUID1,
1526 TS2,
1527 "Processing 5551111111->5552222222 in context public",
1528 ),
1529 ];
1530 let entries = collect_enriched(lines);
1531 assert_eq!(entries.len(), 3);
1532 let first = entries[0].session.as_ref().unwrap();
1533 assert_eq!(
1534 first.channel_name.as_deref(),
1535 Some("sofia/internal/+15550001234@192.0.2.1"),
1536 );
1537 assert!(first.dialplan_context.is_none());
1538
1539 let last = entries[2].session.as_ref().unwrap();
1540 assert_eq!(
1541 last.channel_name.as_deref(),
1542 Some("sofia/internal/+15550001234@192.0.2.1"),
1543 );
1544 assert_eq!(last.dialplan_context.as_deref(), Some("public"));
1545 }
1546
1547 #[test]
1548 fn post_hook_sets_other_leg_uuid() {
1549 let lines = vec![
1550 full_line(UUID1, TS1, "First entry"),
1551 format!(
1552 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2} Job-UUID: ...)"
1553 ),
1554 ];
1555 let stream = LogStream::new(lines.into_iter());
1556 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1557 if let MessageKind::Execute {
1558 application,
1559 arguments,
1560 ..
1561 } = &entry.message_kind
1562 {
1563 if application == "set" {
1564 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1565 let uuid = value.split_whitespace().next().unwrap_or("");
1566 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1567 state.other_leg_uuid = Some(uuid.to_string());
1568 }
1569 }
1570 }
1571 }
1572 });
1573
1574 let entries: Vec<_> = tracker.by_ref().collect();
1575 assert_eq!(entries.len(), 2);
1576
1577 let session = entries[1].session.as_ref().unwrap();
1578 assert_eq!(
1579 session.other_leg_uuid.as_deref(),
1580 Some(UUID2),
1581 "post_hook should detect uuid_bridge API result"
1582 );
1583 }
1584
1585 #[test]
1586 fn post_hook_does_not_override_builtin() {
1587 let lines = vec![
1588 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1589 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1590 format!(
1591 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID3} Job-UUID: ...)"
1592 ),
1593 ];
1594 let stream = LogStream::new(lines.into_iter());
1595 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1596 if let MessageKind::Execute {
1597 application,
1598 arguments,
1599 ..
1600 } = &entry.message_kind
1601 {
1602 if application == "set" {
1603 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1604 let uuid = value.split_whitespace().next().unwrap_or("");
1605 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1606 state.other_leg_uuid = Some(uuid.to_string());
1607 }
1608 }
1609 }
1610 }
1611 });
1612
1613 let entries: Vec<_> = tracker.by_ref().collect();
1614 assert_eq!(entries.len(), 2);
1615
1616 let session = entries[1].session.as_ref().unwrap();
1617 assert_eq!(
1618 session.other_leg_uuid.as_deref(),
1619 Some(UUID2),
1620 "built-in Other-Leg-Unique-ID takes precedence over hook"
1621 );
1622 }
1623
1624 #[test]
1625 fn pre_hook_runs_before_builtin() {
1626 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1627 let stream = LogStream::new(lines.into_iter());
1628 let mut tracker = SessionTracker::new(stream).with_pre_hook(|_entry, state| {
1629 state.channel_state = Some("PRE_SET".to_string());
1630 });
1631 let entries: Vec<_> = tracker.by_ref().collect();
1632 assert_eq!(
1633 entries[0]
1634 .session
1635 .as_ref()
1636 .unwrap()
1637 .channel_state
1638 .as_deref(),
1639 Some("CS_ROUTING"),
1640 "built-in overwrites pre_hook value when no guard"
1641 );
1642 }
1643
1644 #[test]
1645 fn post_hook_runs_after_builtin() {
1646 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1647 let stream = LogStream::new(lines.into_iter());
1648 let mut tracker = SessionTracker::new(stream).with_post_hook(|_entry, state| {
1649 if state.channel_state.as_deref() == Some("CS_ROUTING") {
1650 state
1651 .variables
1652 .insert("routing_seen".to_string(), "true".to_string());
1653 }
1654 });
1655 let _: Vec<_> = tracker.by_ref().collect();
1656 let state = tracker.sessions().get(UUID1).unwrap();
1657 assert_eq!(
1658 state.variables.get("routing_seen").map(|s| s.as_str()),
1659 Some("true"),
1660 "post_hook can read fields set by built-in"
1661 );
1662 }
1663
1664 #[test]
1665 fn parse_hangup_extracts_cause() {
1666 assert_eq!(
1667 parse_hangup("Hangup sofia/internal/1234 [NORMAL_CLEARING]"),
1668 Some("NORMAL_CLEARING".to_string())
1669 );
1670 assert_eq!(
1671 parse_hangup("Hangup sofia/internal/1234 [USER_BUSY]"),
1672 Some("USER_BUSY".to_string())
1673 );
1674 assert_eq!(parse_hangup("Some other message"), None);
1675 assert_eq!(parse_hangup("New Channel sofia/internal/1234 [uuid]"), None);
1676 }
1677
1678 #[test]
1679 fn is_answered_detects_answer_event() {
1680 assert!(is_answered("sofia/internal/1234 has been answered"));
1681 assert!(!is_answered("sofia/internal/1234 is ringing"));
1682 assert!(!is_answered("New Channel sofia/internal/1234"));
1683 }
1684
1685 #[test]
1686 fn hangup_cause_from_lifecycle() {
1687 let lines = vec![full_line(
1688 UUID1,
1689 TS1,
1690 "Hangup sofia/internal/+15550001234@192.0.2.1 [NORMAL_CLEARING]",
1691 )];
1692 let entries = collect_enriched(lines);
1693 let session = entries[0].session.as_ref().unwrap();
1694 assert_eq!(
1695 session.hangup_cause.as_deref(),
1696 Some("NORMAL_CLEARING"),
1697 "hangup_cause extracted from ChannelLifecycle Hangup"
1698 );
1699 }
1700
1701 #[test]
1702 fn answered_at_from_lifecycle() {
1703 let lines = vec![full_line(
1704 UUID1,
1705 TS1,
1706 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1707 )];
1708 let entries = collect_enriched(lines);
1709 let session = entries[0].session.as_ref().unwrap();
1710 assert_eq!(
1711 session.answered_at.as_deref(),
1712 Some(TS1),
1713 "answered_at captures timestamp when 'has been answered' seen"
1714 );
1715 }
1716
1717 #[test]
1718 fn answered_at_not_overwritten() {
1719 let lines = vec![
1720 full_line(
1721 UUID1,
1722 TS1,
1723 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1724 ),
1725 full_line(
1726 UUID1,
1727 TS2,
1728 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1729 ),
1730 ];
1731 let entries = collect_enriched(lines);
1732 let session = entries[1].session.as_ref().unwrap();
1733 assert_eq!(
1734 session.answered_at.as_deref(),
1735 Some(TS1),
1736 "answered_at preserves first answer timestamp"
1737 );
1738 }
1739
1740 #[test]
1741 fn caller_id_name_from_channel_data() {
1742 let lines = vec![
1743 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1744 format!("{UUID1} Caller-Caller-ID-Name: [Test Caller Name]"),
1745 ];
1746 let entries = collect_enriched(lines);
1747 let session = entries[0].session.as_ref().unwrap();
1748 assert_eq!(
1749 session.caller_id_name.as_deref(),
1750 Some("Test Caller Name"),
1751 "caller_id_name extracted from CHANNEL_DATA"
1752 );
1753 }
1754}