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)]
58struct IndexedFieldChanges {
59 channel_name: Option<(Option<String>, Option<String>)>,
60 pending_bridge_target: Option<(Option<String>, Option<String>)>,
61 other_leg_uuid: Option<(Option<String>, Option<String>)>,
62}
63
64impl IndexedFieldChanges {
65 fn diff(
66 old_channel_name: Option<String>,
67 old_pending_bridge_target: Option<String>,
68 old_other_leg_uuid: Option<String>,
69 state: &SessionState,
70 ) -> Self {
71 let mut changes = IndexedFieldChanges::default();
72 if state.channel_name != old_channel_name {
73 changes.channel_name = Some((old_channel_name, state.channel_name.clone()));
74 }
75 if state.pending_bridge_target != old_pending_bridge_target {
76 changes.pending_bridge_target = Some((
77 old_pending_bridge_target,
78 state.pending_bridge_target.clone(),
79 ));
80 }
81 if state.other_leg_uuid != old_other_leg_uuid {
82 changes.other_leg_uuid = Some((old_other_leg_uuid, state.other_leg_uuid.clone()));
83 }
84 changes
85 }
86}
87
88#[derive(Debug, Clone)]
93pub struct SessionSnapshot {
94 pub channel_name: Option<String>,
95 pub channel_state: Option<String>,
96 pub initial_context: Option<String>,
97 pub initial_destination: Option<String>,
98 pub dialplan_context: Option<String>,
99 pub dialplan_from: Option<String>,
100 pub dialplan_to: Option<String>,
101 pub call_direction: Option<CallDirection>,
102 pub caller_id_number: Option<String>,
103 pub caller_id_name: Option<String>,
104 pub destination_number: Option<String>,
105 pub hangup_cause: Option<String>,
106 pub answered_at: Option<String>,
107 pub other_leg_uuid: Option<String>,
108}
109
110impl SessionState {
111 fn snapshot(&self) -> SessionSnapshot {
112 SessionSnapshot {
113 channel_name: self.channel_name.clone(),
114 channel_state: self.channel_state.clone(),
115 initial_context: self.initial_context.clone(),
116 initial_destination: self.initial_destination.clone(),
117 dialplan_context: self.dialplan_context.clone(),
118 dialplan_from: self.dialplan_from.clone(),
119 dialplan_to: self.dialplan_to.clone(),
120 call_direction: self.call_direction,
121 caller_id_number: self.caller_id_number.clone(),
122 caller_id_name: self.caller_id_name.clone(),
123 destination_number: self.destination_number.clone(),
124 hangup_cause: self.hangup_cause.clone(),
125 answered_at: self.answered_at.clone(),
126 other_leg_uuid: self.other_leg_uuid.clone(),
127 }
128 }
129
130 fn update_from_entry(&mut self, entry: &LogEntry) {
131 let block_has_channel_data = matches!(entry.block, Some(Block::ChannelData { .. }));
132 if let Some(Block::ChannelData { fields, variables }) = &entry.block {
133 for (name, value) in fields {
134 match name.as_str() {
135 "Channel-Name" => self.channel_name = Some(value.clone()),
136 "Channel-State" => self.channel_state = Some(value.clone()),
137 "Call-Direction" => {
138 self.call_direction = CallDirection::from_str(value).ok();
139 }
140 "Caller-Caller-ID-Number" => {
141 self.caller_id_number = Some(value.clone());
142 }
143 "Caller-Caller-ID-Name" => {
144 self.caller_id_name = Some(value.clone());
145 }
146 "Caller-Destination-Number" => {
147 self.destination_number = Some(value.clone());
148 }
149 "Other-Leg-Unique-ID" => {
150 self.other_leg_uuid = Some(value.clone());
151 }
152 _ => {}
153 }
154 }
155 for (name, value) in variables {
156 let var_name = name.strip_prefix("variable_").unwrap_or(name);
157 self.variables.insert(var_name.to_string(), value.clone());
158 }
159 }
160
161 match &entry.message_kind {
162 MessageKind::Execute {
163 application,
164 arguments,
165 ..
166 } => match application.as_str() {
167 "set" | "export" => {
168 if let Some((name, value)) = arguments.split_once('=') {
169 self.variables.insert(name.to_string(), value.to_string());
170 }
171 }
172 "bridge" => {
173 if let Some(info) = parse_bridge_args(arguments) {
174 if let Some(uuid) = &info.origination_uuid {
175 self.other_leg_uuid = Some(uuid.clone());
176 }
177 self.pending_bridge_target = Some(info.target_channel);
178 }
179 }
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 kind => self.apply_kind(kind),
196 }
197
198 self.apply_processing(&entry.message);
199
200 for attached in &entry.attached {
201 let parsed = parse_line(attached);
202 self.update_from_message(parsed.message, block_has_channel_data);
203 }
204 }
205
206 fn apply_kind(&mut self, kind: &MessageKind) {
210 match kind {
211 MessageKind::Dialplan { detail, .. } => {
212 if let Some(dp) = parse_dialplan_context(detail) {
213 self.initial_context.get_or_insert(dp.context.clone());
214 self.dialplan_context = Some(dp.context);
215 self.dialplan_from = Some(dp.from);
216 self.dialplan_to = Some(dp.to);
217 }
218 }
219 MessageKind::Variable { name, value } => {
220 let var_name = name.strip_prefix("variable_").unwrap_or(name);
221 self.variables.insert(var_name.to_string(), value.clone());
222 }
223 MessageKind::ChannelField { name, value } => match name.as_str() {
224 "Channel-Name" => self.channel_name = Some(value.clone()),
225 "Channel-State" => self.channel_state = Some(value.clone()),
226 _ => {}
227 },
228 MessageKind::StateChange { detail } => {
229 if let Some(new_state) = parse_state_change(detail) {
230 self.channel_state = Some(new_state);
231 }
232 }
233 _ => {}
234 }
235 }
236
237 fn apply_processing(&mut self, msg: &str) {
241 if msg.contains("Processing ") && msg.contains(" in context ") {
242 if let Some(dp) = parse_processing_line(msg) {
243 self.initial_context.get_or_insert(dp.context.clone());
244 self.initial_destination.get_or_insert(dp.to.clone());
245 self.dialplan_context = Some(dp.context);
246 self.dialplan_from = Some(dp.from);
247 self.dialplan_to = Some(dp.to);
248 }
249 }
250 }
251
252 fn update_from_message(&mut self, msg: &str, block_provides_channel_data: bool) {
253 let kind = classify_message(msg);
254 match &kind {
255 MessageKind::Variable { .. } | MessageKind::ChannelField { .. }
259 if block_provides_channel_data => {}
260 kind => self.apply_kind(kind),
261 }
262 self.apply_processing(msg);
263 }
264}
265
266struct DialplanContext {
267 from: String,
268 to: String,
269 context: String,
270}
271
272fn parse_dialplan_context(detail: &str) -> Option<DialplanContext> {
273 if !detail.starts_with("parsing [") {
274 return None;
275 }
276 let rest = &detail["parsing [".len()..];
277 let bracket_end = rest.find(']')?;
278 let inner = &rest[..bracket_end];
279
280 let arrow = inner.find("->")?;
281 let from_part = &inner[..arrow];
282 let to_part = &inner[arrow + 2..];
283
284 let context = if rest.len() > bracket_end + 1 {
285 let after = rest[bracket_end + 1..].trim();
286 if let Some(stripped) = after.strip_prefix("continue=") {
287 let _ = stripped;
288 }
289 from_part.to_string()
290 } else {
291 from_part.to_string()
292 };
293
294 Some(DialplanContext {
295 from: from_part.to_string(),
296 to: to_part.to_string(),
297 context,
298 })
299}
300
301fn parse_processing_line(msg: &str) -> Option<DialplanContext> {
307 let proc_idx = msg.find("Processing ")?;
308 let after_proc = &msg[proc_idx + "Processing ".len()..];
309
310 let ctx_idx = after_proc.rfind(" in context ")?;
311 let head = &after_proc[..ctx_idx];
312 let context = after_proc[ctx_idx + " in context ".len()..]
313 .split_whitespace()
314 .next()?;
315
316 let (from, to) = match head.rfind(">->") {
317 Some(i) => (&head[..i + 1], &head[i + ">->".len()..]),
318 None => {
319 let i = head.rfind("->")?;
320 (&head[..i], &head[i + "->".len()..])
321 }
322 };
323
324 Some(DialplanContext {
325 from: from.to_string(),
326 to: to.to_string(),
327 context: context.to_string(),
328 })
329}
330
331fn parse_new_channel(detail: &str) -> Option<String> {
332 let rest = detail.strip_prefix("New Channel ")?;
333 let bracket = rest.rfind(" [")?;
334 Some(rest[..bracket].to_string())
335}
336
337fn parse_state_change(detail: &str) -> Option<String> {
338 let arrow = detail.find(" -> ")?;
339 Some(detail[arrow + 4..].trim().to_string())
340}
341
342fn parse_hangup(detail: &str) -> Option<String> {
343 if !detail.contains("Hangup ") {
344 return None;
345 }
346 let start = detail.rfind('[')?;
347 let end = detail[start..].find(']')?;
348 Some(detail[start + 1..start + end].to_string())
349}
350
351fn is_answered(detail: &str) -> bool {
352 detail.contains("has been answered")
353}
354
355fn parse_bridge_args(arguments: &str) -> Option<BridgeInfo> {
359 let dial = BridgeDialString::from_str(arguments).ok()?;
360 let first_ep = dial.groups().first()?.first()?;
361 let origination_uuid = first_ep
362 .variables()
363 .and_then(|v| v.get("origination_uuid"))
364 .map(|s| s.to_string());
365 let mut bare = first_ep.clone();
366 bare.set_variables(None);
367 let target_channel = bare.to_string();
368 Some(BridgeInfo {
369 origination_uuid,
370 target_channel,
371 })
372}
373
374struct BridgeInfo {
375 origination_uuid: Option<String>,
376 target_channel: String,
377}
378
379fn parse_originate_success(msg: &str) -> Option<String> {
381 let marker = "Peer UUID: ";
382 let idx = msg.find(marker)?;
383 let uuid = msg[idx + marker.len()..].trim();
384 if uuid.is_empty() {
385 None
386 } else {
387 Some(uuid.to_string())
388 }
389}
390
391fn parse_originate_channel(msg: &str) -> Option<&str> {
395 let start = msg.find(" [")? + 2;
396 let end = msg[start..].find(']')?;
397 let chan = &msg[start..start + end];
398 if chan.is_empty() {
399 None
400 } else {
401 Some(chan)
402 }
403}
404
405fn is_terminal_channel_state(state: Option<&str>) -> bool {
413 matches!(
414 state,
415 Some("CS_HANGUP" | "CS_REPORTING" | "CS_DESTROY" | "CS_NONE" | "HANGUP")
416 )
417}
418
419#[derive(Debug)]
421pub struct EnrichedEntry {
422 pub entry: LogEntry,
423 pub session: Option<SessionSnapshot>,
425}
426
427pub struct SessionTracker<I> {
434 inner: LogStream<I>,
435 sessions: HashMap<String, SessionState>,
436 by_channel_name: HashMap<String, HashSet<String>>,
437 by_pending_target: HashMap<String, String>,
438 by_other_leg: HashMap<String, String>,
439 pre_hook: Option<SessionHook>,
440 post_hook: Option<SessionHook>,
441}
442
443impl<I: Iterator<Item = String>> SessionTracker<I> {
444 pub fn new(inner: LogStream<I>) -> Self {
446 SessionTracker {
447 inner,
448 sessions: HashMap::new(),
449 by_channel_name: HashMap::new(),
450 by_pending_target: HashMap::new(),
451 by_other_leg: HashMap::new(),
452 pre_hook: None,
453 post_hook: None,
454 }
455 }
456
457 pub fn with_pre_hook<F>(mut self, hook: F) -> Self
465 where
466 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
467 {
468 self.pre_hook = Some(Box::new(hook));
469 self
470 }
471
472 pub fn with_post_hook<F>(mut self, hook: F) -> Self
497 where
498 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
499 {
500 self.post_hook = Some(Box::new(hook));
501 self
502 }
503
504 pub fn sessions(&self) -> &HashMap<String, SessionState> {
506 &self.sessions
507 }
508
509 pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
512 let state = self.sessions.remove(uuid)?;
513 if let Some(chan) = &state.channel_name {
514 if let Some(set) = self.by_channel_name.get_mut(chan) {
515 set.remove(uuid);
516 if set.is_empty() {
517 self.by_channel_name.remove(chan);
518 }
519 }
520 }
521 if let Some(target) = &state.pending_bridge_target {
522 self.by_pending_target.remove(target);
523 }
524 if let Some(other) = &state.other_leg_uuid {
525 self.by_other_leg.remove(other);
526 }
527 Some(state)
528 }
529
530 pub fn stats(&self) -> &ParseStats {
532 self.inner.stats()
533 }
534
535 pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
537 self.inner.drain_unclassified()
538 }
539
540 fn apply_index_changes(&mut self, uuid: &str, changes: &IndexedFieldChanges) {
541 if let Some((old, new)) = &changes.channel_name {
542 if let Some(old_name) = old {
543 if let Some(set) = self.by_channel_name.get_mut(old_name) {
544 set.remove(uuid);
545 if set.is_empty() {
546 self.by_channel_name.remove(old_name);
547 }
548 }
549 }
550 if let Some(new_name) = new {
551 self.by_channel_name
552 .entry(new_name.clone())
553 .or_default()
554 .insert(uuid.to_string());
555 }
556 }
557 if let Some((old, new)) = &changes.pending_bridge_target {
558 if let Some(old_target) = old {
559 self.by_pending_target.remove(old_target);
560 }
561 if let Some(new_target) = new {
562 self.by_pending_target
563 .insert(new_target.clone(), uuid.to_string());
564 }
565 }
566 if let Some((old, new)) = &changes.other_leg_uuid {
567 match new {
568 Some(new_leg) => self.index_other_leg(uuid, old.clone(), new_leg),
569 None => {
570 if let Some(old_leg) = old {
571 self.by_other_leg.remove(old_leg);
572 }
573 }
574 }
575 }
576 }
577
578 fn index_other_leg(&mut self, uuid: &str, old_leg: Option<String>, new_leg: &str) {
582 if let Some(old) = old_leg {
583 if old != new_leg {
584 self.by_other_leg.remove(&old);
585 }
586 }
587 self.by_other_leg
588 .insert(new_leg.to_string(), uuid.to_string());
589 }
590
591 fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
594 if entry.message.contains("Originate Resulted in Success") {
596 let a_uuid = uuid.to_string();
597 if let Some(peer_uuid) = parse_originate_success(&entry.message) {
598 let a_old_pending = self
599 .sessions
600 .get(&a_uuid)
601 .and_then(|s| s.pending_bridge_target.clone());
602
603 let mut a_old_leg = None;
604 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
605 a_old_leg = a_state.other_leg_uuid.replace(peer_uuid.clone());
606 a_state.pending_bridge_target = None;
607 }
608 self.index_other_leg(&a_uuid, a_old_leg, &peer_uuid);
609 if let Some(old_target) = a_old_pending {
610 self.by_pending_target.remove(&old_target);
611 }
612
613 let b_state = self.sessions.entry(peer_uuid.clone()).or_default();
614 let b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
615 self.index_other_leg(&peer_uuid, b_old_leg, &a_uuid);
616 } else if let Some(chan) = parse_originate_channel(&entry.message) {
617 let candidates: Vec<String> = self
622 .by_channel_name
623 .get(chan)
624 .map(|set| {
625 set.iter()
626 .filter(|u| *u != &a_uuid)
627 .filter(|u| {
628 self.sessions
629 .get(*u)
630 .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
631 .unwrap_or(false)
632 })
633 .cloned()
634 .collect()
635 })
636 .unwrap_or_default();
637
638 if candidates.len() == 1 {
639 let b_uuid = candidates.into_iter().next().unwrap();
640 let a_old_pending = self
641 .sessions
642 .get(&a_uuid)
643 .and_then(|s| s.pending_bridge_target.clone());
644
645 let mut a_old_leg = None;
646 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
647 a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
648 a_state.pending_bridge_target = None;
649 }
650 let mut b_old_leg = None;
651 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
652 b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
653 }
654
655 self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
656 self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
657 if let Some(old_target) = a_old_pending {
658 self.by_pending_target.remove(&old_target);
659 }
660 }
661 }
662 return;
663 }
664
665 if let MessageKind::ChannelLifecycle { detail } = &entry.message_kind {
668 if let Some(channel_name) = parse_new_channel(detail) {
669 let b_uuid = uuid.to_string();
670
671 let a_uuid_found = self
673 .by_other_leg
674 .get(&b_uuid)
675 .cloned()
676 .or_else(|| self.by_pending_target.get(&channel_name).cloned())
677 .filter(|a| a != &b_uuid);
678
679 if let Some(a_uuid) = a_uuid_found {
680 let a_old_pending = self
681 .sessions
682 .get(&a_uuid)
683 .and_then(|s| s.pending_bridge_target.clone());
684
685 let mut a_old_leg = None;
686 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
687 a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
688 a_state.pending_bridge_target = None;
689 }
690 let mut b_old_leg = None;
691 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
692 b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
693 }
694
695 self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
696 self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
697 if let Some(old_target) = a_old_pending {
698 self.by_pending_target.remove(&old_target);
699 }
700 }
701 }
702 }
703 }
704}
705
706impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
707 type Item = EnrichedEntry;
708
709 fn next(&mut self) -> Option<EnrichedEntry> {
710 let entry = self.inner.next()?;
711
712 if entry.uuid.is_empty() {
713 return Some(EnrichedEntry {
714 entry,
715 session: None,
716 });
717 }
718
719 let uuid = entry.uuid.clone();
720 let state = self.sessions.entry(uuid.clone()).or_default();
721
722 let old_channel_name = state.channel_name.clone();
726 let old_pending_bridge_target = state.pending_bridge_target.clone();
727 let old_other_leg_uuid = state.other_leg_uuid.clone();
728
729 if let Some(hook) = &self.pre_hook {
730 hook(&entry, state);
731 }
732
733 state.update_from_entry(&entry);
734
735 self.link_legs(&uuid, &entry);
736
737 if let Some(hook) = &self.post_hook {
738 let state = self.sessions.get_mut(&uuid).unwrap();
739 hook(&entry, state);
740 }
741
742 let changes = IndexedFieldChanges::diff(
743 old_channel_name,
744 old_pending_bridge_target,
745 old_other_leg_uuid,
746 self.sessions.get(&uuid).unwrap(),
747 );
748 self.apply_index_changes(&uuid, &changes);
749
750 let snapshot = self.sessions.get(&uuid).unwrap().snapshot();
751
752 Some(EnrichedEntry {
753 entry,
754 session: Some(snapshot),
755 })
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
764 const UUID2: &str = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
765 const UUID3: &str = "c3d4e5f6-a7b8-9012-cdef-234567890123";
766 const TS1: &str = "2025-01-15 10:30:45.123456";
767 const TS2: &str = "2025-01-15 10:30:46.234567";
768
769 fn full_line(uuid: &str, ts: &str, msg: &str) -> String {
770 format!("{uuid} {ts} 95.97% [DEBUG] sofia.c:100 {msg}")
771 }
772
773 fn collect_enriched(lines: Vec<String>) -> Vec<EnrichedEntry> {
774 let stream = LogStream::new(lines.into_iter());
775 SessionTracker::new(stream).collect()
776 }
777
778 #[test]
779 fn system_line_no_session() {
780 let lines = vec![format!(
781 "{TS1} 95.97% [INFO] mod_event_socket.c:1772 Event Socket command"
782 )];
783 let entries = collect_enriched(lines);
784 assert_eq!(entries.len(), 1);
785 assert!(entries[0].session.is_none());
786 }
787
788 #[test]
789 fn dialplan_context_propagation() {
790 let lines = vec![
791 full_line(UUID1, TS1, "CHANNEL_DATA:"),
792 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
793 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer"),
794 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true"),
795 full_line(UUID1, TS2, "Some later event"),
796 ];
797 let entries = collect_enriched(lines);
798 let last = entries.last().unwrap();
799 let session = last.session.as_ref().unwrap();
800 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
801 assert_eq!(session.dialplan_from.as_deref(), Some("public"));
802 assert_eq!(session.dialplan_to.as_deref(), Some("global"));
803 }
804
805 #[test]
806 fn processing_line_extracts_context() {
807 let lines = vec![full_line(
808 UUID1,
809 TS1,
810 "Processing 5551234567->5559876543 in context public",
811 )];
812 let entries = collect_enriched(lines);
813 let session = entries[0].session.as_ref().unwrap();
814 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
815 assert_eq!(session.dialplan_from.as_deref(), Some("5551234567"));
816 assert_eq!(session.dialplan_to.as_deref(), Some("5559876543"));
817 }
818
819 #[test]
820 fn initial_context_preserved_across_transfers() {
821 let lines = vec![
822 full_line(
823 UUID1,
824 TS1,
825 "Processing 5551234567->5559876543 in context public",
826 ),
827 full_line(
828 UUID1,
829 TS2,
830 "Processing 5551234567->start_recording in context recordings",
831 ),
832 ];
833 let stream = LogStream::new(lines.into_iter());
834 let mut tracker = SessionTracker::new(stream);
835 let entries: Vec<_> = tracker.by_ref().collect();
836
837 let first = entries[0].session.as_ref().unwrap();
838 assert_eq!(
839 first.initial_context.as_deref(),
840 Some("public"),
841 "initial_context set on first Processing line"
842 );
843 assert_eq!(first.dialplan_context.as_deref(), Some("public"));
844
845 let state = tracker.sessions().get(UUID1).unwrap();
846 assert_eq!(
847 state.initial_context.as_deref(),
848 Some("public"),
849 "initial_context keeps the first context seen"
850 );
851 assert_eq!(
852 state.dialplan_context.as_deref(),
853 Some("recordings"),
854 "dialplan_context tracks the current context"
855 );
856 assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
857 }
858
859 #[test]
860 fn new_channel_sets_channel_name() {
861 let lines = vec![full_line(
862 UUID1,
863 TS1,
864 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
865 )];
866 let entries = collect_enriched(lines);
867 let session = entries[0].session.as_ref().unwrap();
868 assert_eq!(
869 session.channel_name.as_deref(),
870 Some("sofia/internal-v4/sos")
871 );
872 }
873
874 #[test]
875 fn originate_success_links_both_legs() {
876 let lines = vec![
879 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
880 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/esinet1-v6-tcp/sip:target.example.com] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901"),
881 ];
882 let stream = LogStream::new(lines.into_iter());
883 let mut tracker = SessionTracker::new(stream);
884 let _: Vec<_> = tracker.by_ref().collect();
885
886 let a_leg = tracker.sessions().get(UUID1).unwrap();
887 assert_eq!(
888 a_leg.other_leg_uuid.as_deref(),
889 Some(UUID2),
890 "A-leg other_leg_uuid set from Originate Resulted in Success"
891 );
892
893 let b_leg = tracker.sessions().get(UUID2).unwrap();
894 assert_eq!(
895 b_leg.other_leg_uuid.as_deref(),
896 Some(UUID1),
897 "B-leg other_leg_uuid points back to A-leg"
898 );
899 }
900
901 #[test]
902 fn originate_success_channel_fallback_links_legs() {
903 let lines = vec![
907 full_line(
908 UUID2,
909 TS1,
910 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
911 ),
912 full_line(
913 UUID1,
914 TS2,
915 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
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 "A-leg linked to B-leg via channel-name fallback when Peer UUID absent"
927 );
928
929 let b_leg = tracker.sessions().get(UUID2).unwrap();
930 assert_eq!(
931 b_leg.other_leg_uuid.as_deref(),
932 Some(UUID1),
933 "B-leg linked back to A-leg"
934 );
935 }
936
937 #[test]
938 fn originate_success_peer_uuid_wins_over_channel_fallback() {
939 let lines = vec![
942 full_line(
943 UUID2,
944 TS1,
945 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
946 ),
947 full_line(
948 UUID3,
949 TS1,
950 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
951 ),
952 full_line(
953 UUID1,
954 TS2,
955 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901",
956 ),
957 ];
958 let stream = LogStream::new(lines.into_iter());
959 let mut tracker = SessionTracker::new(stream);
960 let _: Vec<_> = tracker.by_ref().collect();
961
962 let a_leg = tracker.sessions().get(UUID1).unwrap();
963 assert_eq!(
964 a_leg.other_leg_uuid.as_deref(),
965 Some(UUID2),
966 "Peer UUID wins over channel-name match"
967 );
968
969 let decoy = tracker.sessions().get(UUID3).unwrap();
970 assert_eq!(
971 decoy.other_leg_uuid, None,
972 "Decoy session sharing channel name is not touched"
973 );
974 }
975
976 #[test]
977 fn originate_success_channel_fallback_skips_when_ambiguous() {
978 let lines = vec![
981 full_line(
982 UUID2,
983 TS1,
984 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
985 ),
986 full_line(
987 UUID3,
988 TS1,
989 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
990 ),
991 full_line(
992 UUID1,
993 TS2,
994 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
995 ),
996 ];
997 let stream = LogStream::new(lines.into_iter());
998 let mut tracker = SessionTracker::new(stream);
999 let _: Vec<_> = tracker.by_ref().collect();
1000
1001 let a_leg = tracker.sessions().get(UUID1).unwrap();
1002 assert_eq!(
1003 a_leg.other_leg_uuid, None,
1004 "Ambiguous channel name yields no link"
1005 );
1006 assert_eq!(tracker.sessions().get(UUID2).unwrap().other_leg_uuid, None);
1007 assert_eq!(tracker.sessions().get(UUID3).unwrap().other_leg_uuid, None);
1008 }
1009
1010 #[test]
1011 fn originate_success_channel_fallback_skips_terminated_candidates() {
1012 let lines = vec![
1017 full_line(
1018 UUID2,
1019 TS1,
1020 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1021 ),
1022 full_line(
1023 UUID2,
1024 TS1,
1025 "(sofia/internal/6244@192.0.2.72:50744) State Change CS_EXECUTE -> CS_DESTROY",
1026 ),
1027 full_line(
1028 UUID3,
1029 TS1,
1030 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1031 ),
1032 full_line(
1033 UUID1,
1034 TS2,
1035 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1036 ),
1037 ];
1038 let stream = LogStream::new(lines.into_iter());
1039 let mut tracker = SessionTracker::new(stream);
1040 let _: Vec<_> = tracker.by_ref().collect();
1041
1042 let a_leg = tracker.sessions().get(UUID1).unwrap();
1043 assert_eq!(
1044 a_leg.other_leg_uuid.as_deref(),
1045 Some(UUID3),
1046 "Live b-leg wins over CS_DESTROY straggler"
1047 );
1048
1049 let live_b = tracker.sessions().get(UUID3).unwrap();
1050 assert_eq!(
1051 live_b.other_leg_uuid.as_deref(),
1052 Some(UUID1),
1053 "Live b-leg points back to a-leg"
1054 );
1055
1056 let stale_b = tracker.sessions().get(UUID2).unwrap();
1057 assert_eq!(
1058 stale_b.other_leg_uuid, None,
1059 "Terminated b-leg is not touched"
1060 );
1061 }
1062
1063 #[test]
1064 fn originate_success_channel_fallback_skips_when_no_match() {
1065 let lines = vec![full_line(
1068 UUID1,
1069 TS2,
1070 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1071 )];
1072 let stream = LogStream::new(lines.into_iter());
1073 let mut tracker = SessionTracker::new(stream);
1074 let _: Vec<_> = tracker.by_ref().collect();
1075
1076 let a_leg = tracker.sessions().get(UUID1).unwrap();
1077 assert_eq!(a_leg.other_leg_uuid, None);
1078 assert_eq!(a_leg.pending_bridge_target, None);
1079 }
1080
1081 #[test]
1082 fn bridge_origination_uuid_links_a_leg_immediately() {
1083 let lines = vec![
1086 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)"),
1087 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1088 ];
1089 let stream = LogStream::new(lines.into_iter());
1090 let mut tracker = SessionTracker::new(stream);
1091 let _: Vec<_> = tracker.by_ref().collect();
1092
1093 let a_leg = tracker.sessions().get(UUID1).unwrap();
1094 assert_eq!(
1095 a_leg.other_leg_uuid.as_deref(),
1096 Some(UUID2),
1097 "A-leg knows B-leg UUID from origination_uuid in bridge args"
1098 );
1099
1100 let b_leg = tracker.sessions().get(UUID2).unwrap();
1101 assert_eq!(
1102 b_leg.other_leg_uuid.as_deref(),
1103 Some(UUID1),
1104 "B-leg knows A-leg once New Channel correlates"
1105 );
1106 }
1107
1108 #[test]
1109 fn bridge_target_matches_new_channel() {
1110 let lines = vec![
1113 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1114 full_line(UUID1, TS1, "Parsing session specific variables"),
1115 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1116 ];
1117 let stream = LogStream::new(lines.into_iter());
1118 let mut tracker = SessionTracker::new(stream);
1119 let _: Vec<_> = tracker.by_ref().collect();
1120
1121 let a_leg = tracker.sessions().get(UUID1).unwrap();
1122 assert_eq!(
1123 a_leg.other_leg_uuid.as_deref(),
1124 Some(UUID2),
1125 "A-leg linked to B-leg via bridge target matching New Channel"
1126 );
1127
1128 let b_leg = tracker.sessions().get(UUID2).unwrap();
1129 assert_eq!(
1130 b_leg.other_leg_uuid.as_deref(),
1131 Some(UUID1),
1132 "B-leg linked back to A-leg"
1133 );
1134 }
1135
1136 #[test]
1137 fn originate_success_corrects_wrong_target_match() {
1138 let lines = vec![
1141 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1142 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1143 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/gateway/carrier/+15559876543] Peer UUID: c3d4e5f6-a7b8-9012-cdef-234567890123"),
1144 ];
1145 let stream = LogStream::new(lines.into_iter());
1146 let mut tracker = SessionTracker::new(stream);
1147 let _: Vec<_> = tracker.by_ref().collect();
1148
1149 let a_leg = tracker.sessions().get(UUID1).unwrap();
1150 assert_eq!(
1151 a_leg.other_leg_uuid.as_deref(),
1152 Some(UUID3),
1153 "Originate success overrides earlier target-match guess"
1154 );
1155
1156 let real_b_leg = tracker.sessions().get(UUID3).unwrap();
1157 assert_eq!(
1158 real_b_leg.other_leg_uuid.as_deref(),
1159 Some(UUID1),
1160 "Real B-leg points back to A-leg"
1161 );
1162 }
1163
1164 #[test]
1165 fn channel_data_other_leg_uuid() {
1166 let lines = vec![
1168 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1169 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1170 ];
1171 let stream = LogStream::new(lines.into_iter());
1172 let mut tracker = SessionTracker::new(stream);
1173 let _: Vec<_> = tracker.by_ref().collect();
1174
1175 let state = tracker.sessions().get(UUID1).unwrap();
1176 assert_eq!(
1177 state.other_leg_uuid.as_deref(),
1178 Some(UUID2),
1179 "other_leg_uuid set from Other-Leg-Unique-ID CHANNEL_DATA field"
1180 );
1181 }
1182
1183 #[test]
1184 fn relink_removes_stale_by_other_leg_entry() {
1185 let lines = vec![
1190 full_line(UUID2, TS1, "CHANNEL_DATA:"),
1191 format!("{UUID2} Other-Leg-Unique-ID: [{UUID3}]"),
1192 full_line(
1193 UUID1,
1194 TS2,
1195 &format!(
1196 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: {UUID2}"
1197 ),
1198 ),
1199 full_line(
1200 UUID3,
1201 TS2,
1202 &format!("New Channel sofia/external/dest@192.0.2.9 [{UUID3}]"),
1203 ),
1204 ];
1205 let stream = LogStream::new(lines.into_iter());
1206 let mut tracker = SessionTracker::new(stream);
1207 let _: Vec<_> = tracker.by_ref().collect();
1208
1209 let a_leg = tracker.sessions().get(UUID1).unwrap();
1210 assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1211
1212 let b_leg = tracker.sessions().get(UUID2).unwrap();
1213 assert_eq!(
1214 b_leg.other_leg_uuid.as_deref(),
1215 Some(UUID1),
1216 "authoritative Peer UUID link must survive the unrelated New Channel"
1217 );
1218
1219 let c_leg = tracker.sessions().get(UUID3).unwrap();
1220 assert_eq!(
1221 c_leg.other_leg_uuid, None,
1222 "New Channel on C must not back-link via the superseded index entry"
1223 );
1224 }
1225
1226 #[test]
1227 fn channel_data_populates_session() {
1228 let lines = vec![
1229 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1230 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1231 format!("{UUID1} Channel-State: [CS_EXECUTE]"),
1232 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1233 "variable_direction: [inbound]".to_string(),
1234 ];
1235 let entries = collect_enriched(lines);
1236 assert_eq!(entries.len(), 1);
1237 let session = entries[0].session.as_ref().unwrap();
1238 assert_eq!(
1239 session.channel_name.as_deref(),
1240 Some("sofia/internal/+15550001234@192.0.2.1")
1241 );
1242 assert_eq!(session.channel_state.as_deref(), Some("CS_EXECUTE"));
1243 }
1244
1245 #[test]
1246 fn variables_learned_from_channel_data() {
1247 let lines = vec![
1248 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1249 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1250 "variable_direction: [inbound]".to_string(),
1251 ];
1252 let stream = LogStream::new(lines.into_iter());
1253 let mut tracker = SessionTracker::new(stream);
1254 let _: Vec<_> = tracker.by_ref().collect();
1255 let state = tracker.sessions().get(UUID1).unwrap();
1256 assert_eq!(
1257 state.variables.get("sip_call_id").map(|s| s.as_str()),
1258 Some("test123@192.0.2.1")
1259 );
1260 assert_eq!(
1261 state.variables.get("direction").map(|s| s.as_str()),
1262 Some("inbound")
1263 );
1264 }
1265
1266 #[test]
1267 fn multi_line_variable_survives_attached_rescan() {
1268 let lines = vec![
1271 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1272 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1273 format!("{UUID1} variable_switch_r_sdp: [v=0"),
1274 "o=FreeSWITCH 1737000000 1737000001 IN IP4 192.0.2.10".to_string(),
1275 "s=FreeSWITCH".to_string(),
1276 "c=IN IP4 192.0.2.10".to_string(),
1277 "m=audio 30000 RTP/AVP 0 101".to_string(),
1278 "]".to_string(),
1279 format!("{UUID1} variable_direction: [inbound]"),
1280 ];
1281 let stream = LogStream::new(lines.into_iter());
1282 let mut tracker = SessionTracker::new(stream);
1283 let _: Vec<_> = tracker.by_ref().collect();
1284
1285 let state = tracker.sessions().get(UUID1).unwrap();
1286 let sdp = state
1287 .variables
1288 .get("switch_r_sdp")
1289 .expect("switch_r_sdp variable present");
1290 assert!(
1291 sdp.contains('\n'),
1292 "expected full reassembled value, got fragment: {sdp:?}"
1293 );
1294 assert!(sdp.starts_with("v=0\n"));
1295 assert!(sdp.contains("m=audio 30000 RTP/AVP 0 101"));
1296 assert_eq!(
1297 state.variables.get("direction").map(|s| s.as_str()),
1298 Some("inbound")
1299 );
1300 assert_eq!(
1301 state.channel_name.as_deref(),
1302 Some("sofia/internal/+15550001234@192.0.2.1")
1303 );
1304 }
1305
1306 #[test]
1307 fn attached_processing_line_updates_context() {
1308 let lines = vec![
1311 full_line(UUID1, TS1, "Ring-Ready sofia/internal-v4/sos!"),
1312 format!(
1313 "{UUID1} Processing Extension 1263 <1263>->start_recording in context recordings"
1314 ),
1315 ];
1316 let stream = LogStream::new(lines.into_iter());
1317 let mut tracker = SessionTracker::new(stream);
1318 let _: Vec<_> = tracker.by_ref().collect();
1319
1320 let state = tracker.sessions().get(UUID1).unwrap();
1321 assert_eq!(state.dialplan_context.as_deref(), Some("recordings"));
1322 assert_eq!(
1323 state.dialplan_from.as_deref(),
1324 Some("Extension 1263 <1263>")
1325 );
1326 assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1327 assert_eq!(
1328 state.initial_destination.as_deref(),
1329 Some("start_recording")
1330 );
1331 }
1332
1333 #[test]
1334 fn variables_learned_from_set_execute() {
1335 let lines = vec![
1336 full_line(UUID1, TS1, "First"),
1337 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(call_direction=inbound)"),
1338 full_line(UUID1, TS2, "After set"),
1339 ];
1340 let stream = LogStream::new(lines.into_iter());
1341 let mut tracker = SessionTracker::new(stream);
1342 let entries: Vec<_> = tracker.by_ref().collect();
1343 assert_eq!(entries.len(), 3);
1344 let state = tracker.sessions().get(UUID1).unwrap();
1345 assert_eq!(
1346 state.variables.get("call_direction").map(|s| s.as_str()),
1347 Some("inbound")
1348 );
1349 }
1350
1351 #[test]
1352 fn variables_learned_from_export_execute() {
1353 let lines = vec![
1354 full_line(UUID1, TS1, "First"),
1355 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)"),
1356 ];
1357 let stream = LogStream::new(lines.into_iter());
1358 let mut tracker = SessionTracker::new(stream);
1359 let _: Vec<_> = tracker.by_ref().collect();
1360 let state = tracker.sessions().get(UUID1).unwrap();
1361 assert_eq!(
1362 state.variables.get("originate_timeout").map(|s| s.as_str()),
1363 Some("3600")
1364 );
1365 }
1366
1367 #[test]
1368 fn session_isolation_between_uuids() {
1369 let lines = vec![
1370 full_line(
1371 UUID1,
1372 TS1,
1373 "Processing 5551111111->5552222222 in context public",
1374 ),
1375 full_line(
1376 UUID2,
1377 TS2,
1378 "Processing 5553333333->5554444444 in context private",
1379 ),
1380 ];
1381 let stream = LogStream::new(lines.into_iter());
1382 let mut tracker = SessionTracker::new(stream);
1383 let _: Vec<_> = tracker.by_ref().collect();
1384 let s1 = tracker.sessions().get(UUID1).unwrap();
1385 let s2 = tracker.sessions().get(UUID2).unwrap();
1386 assert_eq!(s1.dialplan_context.as_deref(), Some("public"));
1387 assert_eq!(s2.dialplan_context.as_deref(), Some("private"));
1388 assert_eq!(s1.dialplan_from.as_deref(), Some("5551111111"));
1389 assert_eq!(s2.dialplan_from.as_deref(), Some("5553333333"));
1390 }
1391
1392 #[test]
1393 fn processing_line_with_regex_type_and_angle_bracket_caller() {
1394 let lines = vec![full_line(
1395 UUID1,
1396 TS1,
1397 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1398 )];
1399 let entries = collect_enriched(lines);
1400 let session = entries[0].session.as_ref().unwrap();
1401 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1402 assert_eq!(session.dialplan_context.as_deref(), Some("recordings"));
1403 assert_eq!(
1404 session.dialplan_from.as_deref(),
1405 Some("Emergency S R <5550001234>")
1406 );
1407 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1408 }
1409
1410 #[test]
1411 fn processing_line_extension_format() {
1412 let lines = vec![full_line(
1413 UUID1,
1414 TS1,
1415 "Processing Extension 1263 <1263>->start_recording in context recordings",
1416 )];
1417 let entries = collect_enriched(lines);
1418 let session = entries[0].session.as_ref().unwrap();
1419 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1420 assert_eq!(
1421 session.dialplan_from.as_deref(),
1422 Some("Extension 1263 <1263>")
1423 );
1424 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1425 }
1426
1427 #[test]
1428 fn parse_processing_line_anchors_on_last_arrow() {
1429 let dest = |msg: &str| parse_processing_line(msg).map(|dp| dp.to);
1430 assert_eq!(
1431 dest("Processing Anonymous <anonymous>->5550001234 in context public").as_deref(),
1432 Some("5550001234"),
1433 );
1434 assert_eq!(
1435 dest("Processing 5550009999 <5550009999>->5550001234 in context public").as_deref(),
1436 Some("5550001234"),
1437 );
1438 assert_eq!(
1439 dest("Processing Jane Doe <5550009999>->5550001234 in context internal").as_deref(),
1440 Some("5550001234"),
1441 );
1442 assert_eq!(
1444 dest("Processing Weird -> Name <5550009999>->5550001234 in context internal")
1445 .as_deref(),
1446 Some("5550001234"),
1447 );
1448 assert_eq!(
1450 dest("Processing Jane Doe <5550009999>->start_recording in context features")
1451 .as_deref(),
1452 Some("start_recording"),
1453 );
1454 }
1455
1456 #[test]
1457 fn initial_destination_first_wins() {
1458 let lines = vec![
1459 full_line(
1460 UUID1,
1461 TS1,
1462 "Processing Jane Doe <5550009999>->5550001234 in context public",
1463 ),
1464 full_line(
1465 UUID1,
1466 TS2,
1467 "Processing Jane Doe <5550009999>->5550001234 in context transit",
1468 ),
1469 full_line(
1470 UUID1,
1471 TS2,
1472 "Processing Jane Doe <5550009999>->start_recording in context features",
1473 ),
1474 full_line(
1475 UUID1,
1476 TS2,
1477 "Processing Jane Doe <5550009999>->check_end_call in context features",
1478 ),
1479 ];
1480 let stream = LogStream::new(lines.into_iter());
1481 let mut tracker = SessionTracker::new(stream);
1482 let _: Vec<_> = tracker.by_ref().collect();
1483
1484 let state = tracker.sessions().get(UUID1).unwrap();
1485 assert_eq!(
1486 state.initial_destination.as_deref(),
1487 Some("5550001234"),
1488 "initial_destination keeps the dialed number from the first Processing line"
1489 );
1490 assert_eq!(
1491 state.dialplan_to.as_deref(),
1492 Some("check_end_call"),
1493 "dialplan_to is last-wins and gets clobbered by feature-context routing"
1494 );
1495 }
1496
1497 #[test]
1498 fn state_change_updates_channel_state() {
1499 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1500 let entries = collect_enriched(lines);
1501 let session = entries[0].session.as_ref().unwrap();
1502 assert_eq!(session.channel_state.as_deref(), Some("CS_ROUTING"));
1503 }
1504
1505 #[test]
1506 fn callstate_change_updates_channel_state() {
1507 let lines = vec![full_line(
1508 UUID1,
1509 TS1,
1510 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1511 )];
1512 let entries = collect_enriched(lines);
1513 let session = entries[0].session.as_ref().unwrap();
1514 assert_eq!(session.channel_state.as_deref(), Some("RINGING"));
1515 }
1516
1517 #[test]
1518 fn state_change_overrides_callstate() {
1519 let lines = vec![
1520 full_line(
1521 UUID1,
1522 TS1,
1523 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1524 ),
1525 full_line(
1526 UUID1,
1527 TS2,
1528 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1529 ),
1530 ];
1531 let entries = collect_enriched(lines);
1532 assert_eq!(
1533 entries[0]
1534 .session
1535 .as_ref()
1536 .unwrap()
1537 .channel_state
1538 .as_deref(),
1539 Some("RINGING")
1540 );
1541 assert_eq!(
1542 entries[1]
1543 .session
1544 .as_ref()
1545 .unwrap()
1546 .channel_state
1547 .as_deref(),
1548 Some("CS_EXCHANGE_MEDIA")
1549 );
1550 }
1551
1552 #[test]
1553 fn bleg_lifecycle_extracts_data_from_processing() {
1554 let lines = vec![
1555 full_line(
1556 UUID1,
1557 TS1,
1558 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1559 ),
1560 full_line(
1561 UUID1,
1562 TS1,
1563 "(sofia/internal-v4/sos) State Change CS_NEW -> CS_INIT",
1564 ),
1565 full_line(
1566 UUID1,
1567 TS1,
1568 "(sofia/internal-v4/sos) State Change CS_INIT -> CS_ROUTING",
1569 ),
1570 full_line(
1571 UUID1,
1572 TS1,
1573 "(sofia/internal-v4/sos) State Change CS_ROUTING -> CS_CONSUME_MEDIA",
1574 ),
1575 full_line(
1576 UUID1,
1577 TS1,
1578 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1579 ),
1580 full_line(
1581 UUID1,
1582 TS2,
1583 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1584 ),
1585 full_line(
1586 UUID1,
1587 TS2,
1588 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1589 ),
1590 full_line(
1591 UUID1,
1592 TS2,
1593 "(sofia/internal-v4/sos) State Change CS_EXCHANGE_MEDIA -> CS_HANGUP",
1594 ),
1595 ];
1596 let entries = collect_enriched(lines);
1597
1598 let after_ringing = entries[4].session.as_ref().unwrap();
1599 assert_eq!(after_ringing.channel_state.as_deref(), Some("RINGING"));
1600 assert!(after_ringing.initial_context.is_none());
1601
1602 let after_processing = entries[6].session.as_ref().unwrap();
1603 assert_eq!(
1604 after_processing.channel_state.as_deref(),
1605 Some("CS_EXCHANGE_MEDIA")
1606 );
1607 assert_eq!(
1608 after_processing.initial_context.as_deref(),
1609 Some("recordings")
1610 );
1611 assert_eq!(
1612 after_processing.dialplan_from.as_deref(),
1613 Some("Emergency S R <5550001234>")
1614 );
1615 assert_eq!(
1616 after_processing.dialplan_to.as_deref(),
1617 Some("start_recording")
1618 );
1619
1620 let after_hangup = entries[7].session.as_ref().unwrap();
1621 assert_eq!(after_hangup.channel_state.as_deref(), Some("CS_HANGUP"));
1622 assert_eq!(after_hangup.initial_context.as_deref(), Some("recordings"));
1623 }
1624
1625 #[test]
1626 fn channel_name_from_new_channel() {
1627 let lines = vec![full_line(
1628 UUID1,
1629 TS1,
1630 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1631 )];
1632 let entries = collect_enriched(lines);
1633 let session = entries[0].session.as_ref().unwrap();
1634 assert_eq!(
1635 session.channel_name.as_deref(),
1636 Some("sofia/internal-v4/sos")
1637 );
1638 }
1639
1640 #[test]
1641 fn remove_session() {
1642 let lines = vec![full_line(
1643 UUID1,
1644 TS1,
1645 "Processing 5551111111->5552222222 in context public",
1646 )];
1647 let stream = LogStream::new(lines.into_iter());
1648 let mut tracker = SessionTracker::new(stream);
1649 let _: Vec<_> = tracker.by_ref().collect();
1650 assert!(tracker.sessions().contains_key(UUID1));
1651 let removed = tracker.remove_session(UUID1).unwrap();
1652 assert_eq!(removed.dialplan_context.as_deref(), Some("public"));
1653 assert!(!tracker.sessions().contains_key(UUID1));
1654 }
1655
1656 #[test]
1657 fn stats_delegation() {
1658 let lines = vec![
1659 full_line(UUID1, TS1, "First"),
1660 full_line(UUID1, TS2, "Second"),
1661 ];
1662 let stream = LogStream::new(lines.into_iter());
1663 let mut tracker = SessionTracker::new(stream);
1664 let _: Vec<_> = tracker.by_ref().collect();
1665 assert_eq!(tracker.stats().lines_processed, 2);
1666 }
1667
1668 #[test]
1669 fn snapshot_reflects_cumulative_state() {
1670 let lines = vec![
1671 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1672 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1673 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"),
1674 full_line(
1675 UUID1,
1676 TS2,
1677 "Processing 5551111111->5552222222 in context public",
1678 ),
1679 ];
1680 let entries = collect_enriched(lines);
1681 assert_eq!(entries.len(), 3);
1682 let first = entries[0].session.as_ref().unwrap();
1683 assert_eq!(
1684 first.channel_name.as_deref(),
1685 Some("sofia/internal/+15550001234@192.0.2.1"),
1686 );
1687 assert!(first.dialplan_context.is_none());
1688
1689 let last = entries[2].session.as_ref().unwrap();
1690 assert_eq!(
1691 last.channel_name.as_deref(),
1692 Some("sofia/internal/+15550001234@192.0.2.1"),
1693 );
1694 assert_eq!(last.dialplan_context.as_deref(), Some("public"));
1695 }
1696
1697 #[test]
1698 fn post_hook_sets_other_leg_uuid() {
1699 let lines = vec![
1700 full_line(UUID1, TS1, "First entry"),
1701 format!(
1702 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2} Job-UUID: ...)"
1703 ),
1704 ];
1705 let stream = LogStream::new(lines.into_iter());
1706 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1707 if let MessageKind::Execute {
1708 application,
1709 arguments,
1710 ..
1711 } = &entry.message_kind
1712 {
1713 if application == "set" {
1714 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1715 let uuid = value.split_whitespace().next().unwrap_or("");
1716 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1717 state.other_leg_uuid = Some(uuid.to_string());
1718 }
1719 }
1720 }
1721 }
1722 });
1723
1724 let entries: Vec<_> = tracker.by_ref().collect();
1725 assert_eq!(entries.len(), 2);
1726
1727 let session = entries[1].session.as_ref().unwrap();
1728 assert_eq!(
1729 session.other_leg_uuid.as_deref(),
1730 Some(UUID2),
1731 "post_hook should detect uuid_bridge API result"
1732 );
1733 }
1734
1735 #[test]
1736 fn post_hook_does_not_override_builtin() {
1737 let lines = vec![
1738 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1739 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1740 format!(
1741 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID3} Job-UUID: ...)"
1742 ),
1743 ];
1744 let stream = LogStream::new(lines.into_iter());
1745 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1746 if let MessageKind::Execute {
1747 application,
1748 arguments,
1749 ..
1750 } = &entry.message_kind
1751 {
1752 if application == "set" {
1753 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1754 let uuid = value.split_whitespace().next().unwrap_or("");
1755 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1756 state.other_leg_uuid = Some(uuid.to_string());
1757 }
1758 }
1759 }
1760 }
1761 });
1762
1763 let entries: Vec<_> = tracker.by_ref().collect();
1764 assert_eq!(entries.len(), 2);
1765
1766 let session = entries[1].session.as_ref().unwrap();
1767 assert_eq!(
1768 session.other_leg_uuid.as_deref(),
1769 Some(UUID2),
1770 "built-in Other-Leg-Unique-ID takes precedence over hook"
1771 );
1772 }
1773
1774 #[test]
1775 fn pre_hook_runs_before_builtin() {
1776 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1777 let stream = LogStream::new(lines.into_iter());
1778 let mut tracker = SessionTracker::new(stream).with_pre_hook(|_entry, state| {
1779 state.channel_state = Some("PRE_SET".to_string());
1780 });
1781 let entries: Vec<_> = tracker.by_ref().collect();
1782 assert_eq!(
1783 entries[0]
1784 .session
1785 .as_ref()
1786 .unwrap()
1787 .channel_state
1788 .as_deref(),
1789 Some("CS_ROUTING"),
1790 "built-in overwrites pre_hook value when no guard"
1791 );
1792 }
1793
1794 #[test]
1795 fn post_hook_runs_after_builtin() {
1796 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1797 let stream = LogStream::new(lines.into_iter());
1798 let mut tracker = SessionTracker::new(stream).with_post_hook(|_entry, state| {
1799 if state.channel_state.as_deref() == Some("CS_ROUTING") {
1800 state
1801 .variables
1802 .insert("routing_seen".to_string(), "true".to_string());
1803 }
1804 });
1805 let _: Vec<_> = tracker.by_ref().collect();
1806 let state = tracker.sessions().get(UUID1).unwrap();
1807 assert_eq!(
1808 state.variables.get("routing_seen").map(|s| s.as_str()),
1809 Some("true"),
1810 "post_hook can read fields set by built-in"
1811 );
1812 }
1813
1814 #[test]
1815 fn post_hook_other_leg_uuid_maintains_index_for_backlink() {
1816 let lines = vec![
1819 full_line(UUID1, TS1, "First entry"),
1820 format!(
1821 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2})"
1822 ),
1823 full_line(
1824 UUID2,
1825 TS2,
1826 "New Channel sofia/internal/target@192.0.2.9 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1827 ),
1828 ];
1829 let stream = LogStream::new(lines.into_iter());
1830 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1831 if let MessageKind::Execute {
1832 application,
1833 arguments,
1834 ..
1835 } = &entry.message_kind
1836 {
1837 if application == "set" {
1838 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1839 let uuid = value.split_whitespace().next().unwrap_or("");
1840 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1841 state.other_leg_uuid = Some(uuid.to_string());
1842 }
1843 }
1844 }
1845 }
1846 });
1847 let _: Vec<_> = tracker.by_ref().collect();
1848
1849 let a_leg = tracker.sessions().get(UUID1).unwrap();
1850 assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1851
1852 let b_leg = tracker.sessions().get(UUID2).unwrap();
1853 assert_eq!(
1854 b_leg.other_leg_uuid.as_deref(),
1855 Some(UUID1),
1856 "B-leg back-links via by_other_leg index populated by the hook"
1857 );
1858 }
1859
1860 #[test]
1861 fn pre_hook_channel_name_maintains_index_for_originate_fallback() {
1862 let lines = vec![
1865 full_line(UUID2, TS1, "custom-channel-announce sofia/custom/6244"),
1866 full_line(
1867 UUID1,
1868 TS2,
1869 "Originate Resulted in Success: [sofia/custom/6244]",
1870 ),
1871 ];
1872 let stream = LogStream::new(lines.into_iter());
1873 let mut tracker = SessionTracker::new(stream).with_pre_hook(|entry, state| {
1874 if let Some(chan) = entry.message.strip_prefix("custom-channel-announce ") {
1875 state.channel_name = Some(chan.to_string());
1876 }
1877 });
1878 let _: Vec<_> = tracker.by_ref().collect();
1879
1880 let a_leg = tracker.sessions().get(UUID1).unwrap();
1881 assert_eq!(
1882 a_leg.other_leg_uuid.as_deref(),
1883 Some(UUID2),
1884 "fallback finds hook-named B-leg via by_channel_name index"
1885 );
1886 let b_leg = tracker.sessions().get(UUID2).unwrap();
1887 assert_eq!(b_leg.other_leg_uuid.as_deref(), Some(UUID1));
1888 }
1889
1890 #[test]
1891 fn parse_hangup_extracts_cause() {
1892 assert_eq!(
1893 parse_hangup("Hangup sofia/internal/1234 [NORMAL_CLEARING]"),
1894 Some("NORMAL_CLEARING".to_string())
1895 );
1896 assert_eq!(
1897 parse_hangup("Hangup sofia/internal/1234 [USER_BUSY]"),
1898 Some("USER_BUSY".to_string())
1899 );
1900 assert_eq!(parse_hangup("Some other message"), None);
1901 assert_eq!(parse_hangup("New Channel sofia/internal/1234 [uuid]"), None);
1902 }
1903
1904 #[test]
1905 fn is_answered_detects_answer_event() {
1906 assert!(is_answered("sofia/internal/1234 has been answered"));
1907 assert!(!is_answered("sofia/internal/1234 is ringing"));
1908 assert!(!is_answered("New Channel sofia/internal/1234"));
1909 }
1910
1911 #[test]
1912 fn hangup_cause_from_lifecycle() {
1913 let lines = vec![full_line(
1914 UUID1,
1915 TS1,
1916 "Hangup sofia/internal/+15550001234@192.0.2.1 [NORMAL_CLEARING]",
1917 )];
1918 let entries = collect_enriched(lines);
1919 let session = entries[0].session.as_ref().unwrap();
1920 assert_eq!(
1921 session.hangup_cause.as_deref(),
1922 Some("NORMAL_CLEARING"),
1923 "hangup_cause extracted from ChannelLifecycle Hangup"
1924 );
1925 }
1926
1927 #[test]
1928 fn answered_at_from_lifecycle() {
1929 let lines = vec![full_line(
1930 UUID1,
1931 TS1,
1932 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1933 )];
1934 let entries = collect_enriched(lines);
1935 let session = entries[0].session.as_ref().unwrap();
1936 assert_eq!(
1937 session.answered_at.as_deref(),
1938 Some(TS1),
1939 "answered_at captures timestamp when 'has been answered' seen"
1940 );
1941 }
1942
1943 #[test]
1944 fn answered_at_not_overwritten() {
1945 let lines = vec![
1946 full_line(
1947 UUID1,
1948 TS1,
1949 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1950 ),
1951 full_line(
1952 UUID1,
1953 TS2,
1954 "sofia/internal/+15550001234@192.0.2.1 has been answered",
1955 ),
1956 ];
1957 let entries = collect_enriched(lines);
1958 let session = entries[1].session.as_ref().unwrap();
1959 assert_eq!(
1960 session.answered_at.as_deref(),
1961 Some(TS1),
1962 "answered_at preserves first answer timestamp"
1963 );
1964 }
1965
1966 #[test]
1967 fn caller_id_name_from_channel_data() {
1968 let lines = vec![
1969 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1970 format!("{UUID1} Caller-Caller-ID-Name: [Test Caller Name]"),
1971 ];
1972 let entries = collect_enriched(lines);
1973 let session = entries[0].session.as_ref().unwrap();
1974 assert_eq!(
1975 session.caller_id_name.as_deref(),
1976 Some("Test Caller Name"),
1977 "caller_id_name extracted from CHANNEL_DATA"
1978 );
1979 }
1980}