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