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