Skip to main content

freeswitch_log_parser/
session.rs

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/// Mutable per-UUID state accumulator, updated as entries are processed.
13///
14/// Fields are `None` until the corresponding data is first seen in the stream.
15/// Variables accumulate from CHANNEL_DATA dumps, `set()`/`export()` executions,
16/// `SET`/`EXPORT` log lines, and inline `variable_*` lines.
17#[derive(Debug, Clone, Default)]
18pub struct SessionState {
19    /// `None` until a `Channel-Name` field is encountered.
20    pub channel_name: Option<String>,
21    /// `None` until a state change or `Channel-State` field is encountered.
22    pub channel_state: Option<String>,
23    /// First dialplan context seen; set once and never overwritten.
24    pub initial_context: Option<String>,
25    /// Destination of the first `Processing` line = the dialed number at ingress;
26    /// set once and never overwritten (unlike last-wins `dialplan_to`).
27    pub initial_destination: Option<String>,
28    /// Current dialplan context; updated on each transfer/continue.
29    pub dialplan_context: Option<String>,
30    /// Source extension in the dialplan routing; `None` until a dialplan line is processed.
31    pub dialplan_from: Option<String>,
32    /// Target extension in the dialplan routing; `None` until a dialplan line is processed.
33    pub dialplan_to: Option<String>,
34    /// Call direction from `Call-Direction` CHANNEL_DATA field; `None` until seen.
35    pub call_direction: Option<CallDirection>,
36    /// Caller ID number from `Caller-Caller-ID-Number` CHANNEL_DATA field; `None` until seen.
37    pub caller_id_number: Option<String>,
38    /// Caller ID name from `Caller-Caller-ID-Name` CHANNEL_DATA field; `None` until seen.
39    pub caller_id_name: Option<String>,
40    /// Destination number from `Caller-Destination-Number` CHANNEL_DATA field; `None` until seen.
41    pub destination_number: Option<String>,
42    /// Hangup cause extracted from ChannelLifecycle Hangup detail; `None` until hangup seen.
43    pub hangup_cause: Option<String>,
44    /// Timestamp when "has been answered" lifecycle event was seen; `None` until answered.
45    pub answered_at: Option<String>,
46    /// Other leg's UUID; `None` until bridged. Set from `Originate Resulted in Success` on A-leg,
47    /// and from `New Channel` on B-leg (back-pointing to A-leg via originate context).
48    pub other_leg_uuid: Option<String>,
49    /// Pending bridge target channel from `EXECUTE bridge()`, consumed when B-leg `New Channel` matches.
50    pub(crate) pending_bridge_target: Option<String>,
51    /// All variables learned so far, with the `variable_` prefix stripped from names.
52    pub variables: HashMap<String, String>,
53}
54
55/// Changes to indexed fields, diffed across hooks and built-in extraction
56/// for index maintenance.
57#[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/// Immutable point-in-time copy of a session's state, attached to each [`EnrichedEntry`].
89///
90/// Does not include `variables` to keep snapshots lightweight — access the full
91/// variable map via [`SessionTracker::sessions()`].
92#[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    /// Canonical extraction for message kinds that appear on both primary and
207    /// attached lines. Entry-only kinds (Execute, ChannelLifecycle — the
208    /// latter needs the entry timestamp) stay in `update_from_entry`.
209    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    /// `Processing <caller>-><dest> in context <ctx>` — emitted by the dialplan
238    /// hunt on both primary and attached lines; anchored on raw text because
239    /// `classify_message` folds it into `Dialplan` with the prefix stripped.
240    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            // A ChannelData block already carries these — re-applying the raw
256            // attached lines would clobber reassembled multi-line values with
257            // their opening fragment.
258            MessageKind::Variable { .. } | MessageKind::ChannelField { .. }
259                if block_provides_channel_data => {}
260            kind => self.apply_kind(kind),
261        }
262        self.apply_processing(msg);
263    }
264}
265
266struct DialplanContext {
267    from: String,
268    to: String,
269    context: String,
270}
271
272fn parse_dialplan_context(detail: &str) -> Option<DialplanContext> {
273    if !detail.starts_with("parsing [") {
274        return None;
275    }
276    let rest = &detail["parsing [".len()..];
277    let bracket_end = rest.find(']')?;
278    let inner = &rest[..bracket_end];
279
280    let arrow = inner.find("->")?;
281    let from_part = &inner[..arrow];
282    let to_part = &inner[arrow + 2..];
283
284    let context = if rest.len() > bracket_end + 1 {
285        let after = rest[bracket_end + 1..].trim();
286        if let Some(stripped) = after.strip_prefix("continue=") {
287            let _ = stripped;
288        }
289        from_part.to_string()
290    } else {
291        from_part.to_string()
292    };
293
294    Some(DialplanContext {
295        from: from_part.to_string(),
296        to: to_part.to_string(),
297        context,
298    })
299}
300
301/// Parse `Processing <name> <<number>>-><dest> in context <ctx>`. Field 1 (caller_id_name) is
302/// free-form and may contain spaces, `->`, `<`, so anchor on the fixed frame: the rightmost
303/// ` in context ` and the last `>->` (the `>` closing `<number>` immediately precedes `->`, and
304/// only the caller_id_number→destination boundary has that shape). Falls back to the last bare
305/// `->` for the bracketless `from->to` shape.
306fn parse_processing_line(msg: &str) -> Option<DialplanContext> {
307    let proc_idx = msg.find("Processing ")?;
308    let after_proc = &msg[proc_idx + "Processing ".len()..];
309
310    let ctx_idx = after_proc.rfind(" in context ")?;
311    let head = &after_proc[..ctx_idx];
312    let context = after_proc[ctx_idx + " in context ".len()..]
313        .split_whitespace()
314        .next()?;
315
316    let (from, to) = match head.rfind(">->") {
317        Some(i) => (&head[..i + 1], &head[i + ">->".len()..]),
318        None => {
319            let i = head.rfind("->")?;
320            (&head[..i], &head[i + "->".len()..])
321        }
322    };
323
324    Some(DialplanContext {
325        from: from.to_string(),
326        to: to.to_string(),
327        context: context.to_string(),
328    })
329}
330
331fn parse_new_channel(detail: &str) -> Option<String> {
332    let rest = detail.strip_prefix("New Channel ")?;
333    let bracket = rest.rfind(" [")?;
334    Some(rest[..bracket].to_string())
335}
336
337fn parse_state_change(detail: &str) -> Option<String> {
338    let arrow = detail.find(" -> ")?;
339    Some(detail[arrow + 4..].trim().to_string())
340}
341
342fn parse_hangup(detail: &str) -> Option<String> {
343    if !detail.contains("Hangup ") {
344        return None;
345    }
346    let start = detail.rfind('[')?;
347    let end = detail[start..].find(']')?;
348    Some(detail[start + 1..start + end].to_string())
349}
350
351fn is_answered(detail: &str) -> bool {
352    detail.contains("has been answered")
353}
354
355/// Extract `origination_uuid` and the bridge target channel from bridge() arguments.
356/// Uses `BridgeDialString` from freeswitch-types for correct parsing of `[]`, `{}`,
357/// `|` failover, and `,` simultaneous ring syntax.
358fn parse_bridge_args(arguments: &str) -> Option<BridgeInfo> {
359    let dial = BridgeDialString::from_str(arguments).ok()?;
360    let first_ep = dial.groups().first()?.first()?;
361    let origination_uuid = first_ep
362        .variables()
363        .and_then(|v| v.get("origination_uuid"))
364        .map(|s| s.to_string());
365    let mut bare = first_ep.clone();
366    bare.set_variables(None);
367    let target_channel = bare.to_string();
368    Some(BridgeInfo {
369        origination_uuid,
370        target_channel,
371    })
372}
373
374struct BridgeInfo {
375    origination_uuid: Option<String>,
376    target_channel: String,
377}
378
379/// Parse "Originate Resulted in Success: [channel] Peer UUID: uuid"
380fn parse_originate_success(msg: &str) -> Option<String> {
381    let marker = "Peer UUID: ";
382    let idx = msg.find(marker)?;
383    let uuid = msg[idx + marker.len()..].trim();
384    if uuid.is_empty() {
385        None
386    } else {
387        Some(uuid.to_string())
388    }
389}
390
391/// Parse the bracketed channel name from "Originate Resulted in Success: [<chan>] …".
392/// Used as a fallback when the `Peer UUID:` suffix is absent (FS 1.10.5-dev and
393/// similar builds). Returns the channel name borrowed from `msg`.
394fn parse_originate_channel(msg: &str) -> Option<&str> {
395    let start = msg.find(" [")? + 2;
396    let end = msg[start..].find(']')?;
397    let chan = &msg[start..start + end];
398    if chan.is_empty() {
399        None
400    } else {
401        Some(chan)
402    }
403}
404
405/// Terminal channel-/callstate values — sessions left in one of these are
406/// stragglers from prior calls and must not be considered candidates when
407/// disambiguating channel-name collisions in the originate-success fallback.
408///
409/// Covers both `Channel-State` (`CS_*`) and `Callstate` (`HANGUP`). `DOWN` is
410/// excluded because it doubles as the initial Callstate before any change is
411/// observed.
412fn is_terminal_channel_state(state: Option<&str>) -> bool {
413    matches!(
414        state,
415        Some("CS_HANGUP" | "CS_REPORTING" | "CS_DESTROY" | "CS_NONE" | "HANGUP")
416    )
417}
418
419/// A [`LogEntry`] paired with the session's state snapshot at that point in time.
420#[derive(Debug)]
421pub struct EnrichedEntry {
422    pub entry: LogEntry,
423    /// `None` for system lines (entries with an empty UUID).
424    pub session: Option<SessionSnapshot>,
425}
426
427/// Layer 3 per-session state machine — tracks per-UUID state (dialplan context,
428/// channel state, variables) across entries and yields [`EnrichedEntry`] values.
429///
430/// Wraps a [`LogStream`] and maintains a `HashMap<String, SessionState>` keyed by UUID.
431/// Sessions are never automatically cleaned up; call [`remove_session()`](SessionTracker::remove_session)
432/// when a call ends.
433pub struct SessionTracker<I> {
434    inner: LogStream<I>,
435    sessions: HashMap<String, SessionState>,
436    by_channel_name: HashMap<String, HashSet<String>>,
437    by_pending_target: HashMap<String, String>,
438    by_other_leg: HashMap<String, String>,
439    pre_hook: Option<SessionHook>,
440    post_hook: Option<SessionHook>,
441}
442
443impl<I: Iterator<Item = String>> SessionTracker<I> {
444    /// Wrap a [`LogStream`] to add per-session state tracking.
445    pub fn new(inner: LogStream<I>) -> Self {
446        SessionTracker {
447            inner,
448            sessions: HashMap::new(),
449            by_channel_name: HashMap::new(),
450            by_pending_target: HashMap::new(),
451            by_other_leg: HashMap::new(),
452            pre_hook: None,
453            post_hook: None,
454        }
455    }
456
457    /// Register a hook that runs BEFORE built-in field extraction.
458    ///
459    /// Use this to override how specific fields are extracted. Fields set
460    /// by the pre-hook may be preserved by built-in extraction if it uses
461    /// `is_none()` guards. Indexed fields set by the hook (`channel_name`,
462    /// `other_leg_uuid`) feed cross-session leg correlation like built-in
463    /// extraction does.
464    pub fn with_pre_hook<F>(mut self, hook: F) -> Self
465    where
466        F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
467    {
468        self.pre_hook = Some(Box::new(hook));
469        self
470    }
471
472    /// Register a hook that runs AFTER all built-in processing.
473    ///
474    /// Use this for custom field extraction and relationship detection.
475    /// The hook can read fields populated by built-in extraction and
476    /// fill gaps with application-specific patterns (e.g., `uuid_bridge`
477    /// API results, custom SIP headers). Indexed fields set by the hook
478    /// (`channel_name`, `other_leg_uuid`) feed cross-session leg
479    /// correlation like built-in extraction does.
480    ///
481    /// # Example
482    ///
483    /// ```
484    /// use freeswitch_log_parser::{LogStream, SessionTracker, MessageKind};
485    ///
486    /// let stream = LogStream::new(std::iter::empty::<String>());
487    /// let tracker = SessionTracker::new(stream)
488    ///     .with_post_hook(|entry, state| {
489    ///         if let MessageKind::Execute { application, arguments, .. } = &entry.message_kind {
490    ///             if application == "set" && arguments.starts_with("api_result=+OK ") {
491    ///                 // extract UUID and set state.other_leg_uuid
492    ///             }
493    ///         }
494    ///     });
495    /// ```
496    pub fn with_post_hook<F>(mut self, hook: F) -> Self
497    where
498        F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
499    {
500        self.post_hook = Some(Box::new(hook));
501        self
502    }
503
504    /// All currently tracked sessions, keyed by UUID.
505    pub fn sessions(&self) -> &HashMap<String, SessionState> {
506        &self.sessions
507    }
508
509    /// Remove and return a session's accumulated state. Call this when a call ends
510    /// (e.g. `CS_DESTROY` or hangup) to free memory.
511    pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
512        let state = self.sessions.remove(uuid)?;
513        if let Some(chan) = &state.channel_name {
514            if let Some(set) = self.by_channel_name.get_mut(chan) {
515                set.remove(uuid);
516                if set.is_empty() {
517                    self.by_channel_name.remove(chan);
518                }
519            }
520        }
521        if let Some(target) = &state.pending_bridge_target {
522            self.by_pending_target.remove(target);
523        }
524        if let Some(other) = &state.other_leg_uuid {
525            self.by_other_leg.remove(other);
526        }
527        Some(state)
528    }
529
530    /// Delegates to [`LogStream::stats()`].
531    pub fn stats(&self) -> &ParseStats {
532        self.inner.stats()
533    }
534
535    /// Delegates to [`LogStream::drain_unclassified()`].
536    pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
537        self.inner.drain_unclassified()
538    }
539
540    fn apply_index_changes(&mut self, uuid: &str, changes: &IndexedFieldChanges) {
541        if let Some((old, new)) = &changes.channel_name {
542            if let Some(old_name) = old {
543                if let Some(set) = self.by_channel_name.get_mut(old_name) {
544                    set.remove(uuid);
545                    if set.is_empty() {
546                        self.by_channel_name.remove(old_name);
547                    }
548                }
549            }
550            if let Some(new_name) = new {
551                self.by_channel_name
552                    .entry(new_name.clone())
553                    .or_default()
554                    .insert(uuid.to_string());
555            }
556        }
557        if let Some((old, new)) = &changes.pending_bridge_target {
558            if let Some(old_target) = old {
559                self.by_pending_target.remove(old_target);
560            }
561            if let Some(new_target) = new {
562                self.by_pending_target
563                    .insert(new_target.clone(), uuid.to_string());
564            }
565        }
566        if let Some((old, new)) = &changes.other_leg_uuid {
567            match new {
568                Some(new_leg) => self.index_other_leg(uuid, old.clone(), new_leg),
569                None => {
570                    if let Some(old_leg) = old {
571                        self.by_other_leg.remove(old_leg);
572                    }
573                }
574            }
575        }
576    }
577
578    /// Record `uuid`'s `other_leg_uuid` transition in `by_other_leg`,
579    /// removing the superseded key so a stale entry cannot mislink a later
580    /// `New Channel` back-link. Every write to the index goes through here.
581    fn index_other_leg(&mut self, uuid: &str, old_leg: Option<String>, new_leg: &str) {
582        if let Some(old) = old_leg {
583            if old != new_leg {
584                self.by_other_leg.remove(&old);
585            }
586        }
587        self.by_other_leg
588            .insert(new_leg.to_string(), uuid.to_string());
589    }
590
591    /// Cross-session leg linking. Called after `update_from_entry` so per-session
592    /// state (bridge target, channel name) is already populated.
593    fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
594        // 1. "Originate Resulted in Success ... Peer UUID: BLEG" — authoritative
595        if entry.message.contains("Originate Resulted in Success") {
596            let a_uuid = uuid.to_string();
597            if let Some(peer_uuid) = parse_originate_success(&entry.message) {
598                let a_old_pending = self
599                    .sessions
600                    .get(&a_uuid)
601                    .and_then(|s| s.pending_bridge_target.clone());
602
603                let mut a_old_leg = None;
604                if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
605                    a_old_leg = a_state.other_leg_uuid.replace(peer_uuid.clone());
606                    a_state.pending_bridge_target = None;
607                }
608                self.index_other_leg(&a_uuid, a_old_leg, &peer_uuid);
609                if let Some(old_target) = a_old_pending {
610                    self.by_pending_target.remove(&old_target);
611                }
612
613                let b_state = self.sessions.entry(peer_uuid.clone()).or_default();
614                let b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
615                self.index_other_leg(&peer_uuid, b_old_leg, &a_uuid);
616            } else if let Some(chan) = parse_originate_channel(&entry.message) {
617                // Fallback for FS builds without `Peer UUID:` suffix (e.g. 1.10.5-dev):
618                // link via unique non-terminated b-leg session whose channel_name
619                // matches. Candidates in terminal states are stragglers; if zero or
620                // multiple live candidates remain, skip (correctness over coverage).
621                let candidates: Vec<String> = self
622                    .by_channel_name
623                    .get(chan)
624                    .map(|set| {
625                        set.iter()
626                            .filter(|u| *u != &a_uuid)
627                            .filter(|u| {
628                                self.sessions
629                                    .get(*u)
630                                    .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
631                                    .unwrap_or(false)
632                            })
633                            .cloned()
634                            .collect()
635                    })
636                    .unwrap_or_default();
637
638                if candidates.len() == 1 {
639                    let b_uuid = candidates.into_iter().next().unwrap();
640                    let a_old_pending = self
641                        .sessions
642                        .get(&a_uuid)
643                        .and_then(|s| s.pending_bridge_target.clone());
644
645                    let mut a_old_leg = None;
646                    if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
647                        a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
648                        a_state.pending_bridge_target = None;
649                    }
650                    let mut b_old_leg = None;
651                    if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
652                        b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
653                    }
654
655                    self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
656                    self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
657                    if let Some(old_target) = a_old_pending {
658                        self.by_pending_target.remove(&old_target);
659                    }
660                }
661            }
662            return;
663        }
664
665        // 2. New Channel on this UUID — check if any other session has a pending bridge
666        //    with origination_uuid matching this UUID, or target matching this channel name.
667        if let MessageKind::ChannelLifecycle { detail } = &entry.message_kind {
668            if let Some(channel_name) = parse_new_channel(detail) {
669                let b_uuid = uuid.to_string();
670
671                // O(1) index lookups instead of full scan
672                let a_uuid_found = self
673                    .by_other_leg
674                    .get(&b_uuid)
675                    .cloned()
676                    .or_else(|| self.by_pending_target.get(&channel_name).cloned())
677                    .filter(|a| a != &b_uuid);
678
679                if let Some(a_uuid) = a_uuid_found {
680                    let a_old_pending = self
681                        .sessions
682                        .get(&a_uuid)
683                        .and_then(|s| s.pending_bridge_target.clone());
684
685                    let mut a_old_leg = None;
686                    if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
687                        a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
688                        a_state.pending_bridge_target = None;
689                    }
690                    let mut b_old_leg = None;
691                    if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
692                        b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
693                    }
694
695                    self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
696                    self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
697                    if let Some(old_target) = a_old_pending {
698                        self.by_pending_target.remove(&old_target);
699                    }
700                }
701            }
702        }
703    }
704}
705
706impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
707    type Item = EnrichedEntry;
708
709    fn next(&mut self) -> Option<EnrichedEntry> {
710        let entry = self.inner.next()?;
711
712        if entry.uuid.is_empty() {
713            return Some(EnrichedEntry {
714                entry,
715                session: None,
716            });
717        }
718
719        let uuid = entry.uuid.clone();
720        let state = self.sessions.entry(uuid.clone()).or_default();
721
722        // Snapshot indexed fields before the pre-hook and diff after the
723        // post-hook so hook-set fields maintain the cross-session indexes
724        // exactly like built-in extraction.
725        let old_channel_name = state.channel_name.clone();
726        let old_pending_bridge_target = state.pending_bridge_target.clone();
727        let old_other_leg_uuid = state.other_leg_uuid.clone();
728
729        if let Some(hook) = &self.pre_hook {
730            hook(&entry, state);
731        }
732
733        state.update_from_entry(&entry);
734
735        self.link_legs(&uuid, &entry);
736
737        if let Some(hook) = &self.post_hook {
738            let state = self.sessions.get_mut(&uuid).unwrap();
739            hook(&entry, state);
740        }
741
742        let changes = IndexedFieldChanges::diff(
743            old_channel_name,
744            old_pending_bridge_target,
745            old_other_leg_uuid,
746            self.sessions.get(&uuid).unwrap(),
747        );
748        self.apply_index_changes(&uuid, &changes);
749
750        let snapshot = self.sessions.get(&uuid).unwrap().snapshot();
751
752        Some(EnrichedEntry {
753            entry,
754            session: Some(snapshot),
755        })
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
764    const UUID2: &str = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
765    const UUID3: &str = "c3d4e5f6-a7b8-9012-cdef-234567890123";
766    const TS1: &str = "2025-01-15 10:30:45.123456";
767    const TS2: &str = "2025-01-15 10:30:46.234567";
768
769    fn full_line(uuid: &str, ts: &str, msg: &str) -> String {
770        format!("{uuid} {ts} 95.97% [DEBUG] sofia.c:100 {msg}")
771    }
772
773    fn collect_enriched(lines: Vec<String>) -> Vec<EnrichedEntry> {
774        let stream = LogStream::new(lines.into_iter());
775        SessionTracker::new(stream).collect()
776    }
777
778    #[test]
779    fn system_line_no_session() {
780        let lines = vec![format!(
781            "{TS1} 95.97% [INFO] mod_event_socket.c:1772 Event Socket command"
782        )];
783        let entries = collect_enriched(lines);
784        assert_eq!(entries.len(), 1);
785        assert!(entries[0].session.is_none());
786    }
787
788    #[test]
789    fn dialplan_context_propagation() {
790        let lines = vec![
791            full_line(UUID1, TS1, "CHANNEL_DATA:"),
792            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
793            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer"),
794            format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true"),
795            full_line(UUID1, TS2, "Some later event"),
796        ];
797        let entries = collect_enriched(lines);
798        let last = entries.last().unwrap();
799        let session = last.session.as_ref().unwrap();
800        assert_eq!(session.dialplan_context.as_deref(), Some("public"));
801        assert_eq!(session.dialplan_from.as_deref(), Some("public"));
802        assert_eq!(session.dialplan_to.as_deref(), Some("global"));
803    }
804
805    #[test]
806    fn processing_line_extracts_context() {
807        let lines = vec![full_line(
808            UUID1,
809            TS1,
810            "Processing 5551234567->5559876543 in context public",
811        )];
812        let entries = collect_enriched(lines);
813        let session = entries[0].session.as_ref().unwrap();
814        assert_eq!(session.dialplan_context.as_deref(), Some("public"));
815        assert_eq!(session.dialplan_from.as_deref(), Some("5551234567"));
816        assert_eq!(session.dialplan_to.as_deref(), Some("5559876543"));
817    }
818
819    #[test]
820    fn initial_context_preserved_across_transfers() {
821        let lines = vec![
822            full_line(
823                UUID1,
824                TS1,
825                "Processing 5551234567->5559876543 in context public",
826            ),
827            full_line(
828                UUID1,
829                TS2,
830                "Processing 5551234567->start_recording in context recordings",
831            ),
832        ];
833        let stream = LogStream::new(lines.into_iter());
834        let mut tracker = SessionTracker::new(stream);
835        let entries: Vec<_> = tracker.by_ref().collect();
836
837        let first = entries[0].session.as_ref().unwrap();
838        assert_eq!(
839            first.initial_context.as_deref(),
840            Some("public"),
841            "initial_context set on first Processing line"
842        );
843        assert_eq!(first.dialplan_context.as_deref(), Some("public"));
844
845        let state = tracker.sessions().get(UUID1).unwrap();
846        assert_eq!(
847            state.initial_context.as_deref(),
848            Some("public"),
849            "initial_context keeps the first context seen"
850        );
851        assert_eq!(
852            state.dialplan_context.as_deref(),
853            Some("recordings"),
854            "dialplan_context tracks the current context"
855        );
856        assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
857    }
858
859    #[test]
860    fn new_channel_sets_channel_name() {
861        let lines = vec![full_line(
862            UUID1,
863            TS1,
864            "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
865        )];
866        let entries = collect_enriched(lines);
867        let session = entries[0].session.as_ref().unwrap();
868        assert_eq!(
869            session.channel_name.as_deref(),
870            Some("sofia/internal-v4/sos")
871        );
872    }
873
874    #[test]
875    fn originate_success_links_both_legs() {
876        // "Originate Resulted in Success" contains both the A-leg UUID (line prefix)
877        // and B-leg UUID (Peer UUID field). Both legs should learn about each other.
878        let lines = vec![
879            full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
880            full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/esinet1-v6-tcp/sip:target.example.com] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901"),
881        ];
882        let stream = LogStream::new(lines.into_iter());
883        let mut tracker = SessionTracker::new(stream);
884        let _: Vec<_> = tracker.by_ref().collect();
885
886        let a_leg = tracker.sessions().get(UUID1).unwrap();
887        assert_eq!(
888            a_leg.other_leg_uuid.as_deref(),
889            Some(UUID2),
890            "A-leg other_leg_uuid set from Originate Resulted in Success"
891        );
892
893        let b_leg = tracker.sessions().get(UUID2).unwrap();
894        assert_eq!(
895            b_leg.other_leg_uuid.as_deref(),
896            Some(UUID1),
897            "B-leg other_leg_uuid points back to A-leg"
898        );
899    }
900
901    #[test]
902    fn originate_success_channel_fallback_links_legs() {
903        // FS 1.10.5-dev and similar omit `Peer UUID:` from "Originate Resulted in Success".
904        // The b-leg's New Channel populates channel_name 3.5 s before originate; the
905        // fallback path matches by channel name when the Peer UUID is absent.
906        let lines = vec![
907            full_line(
908                UUID2,
909                TS1,
910                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
911            ),
912            full_line(
913                UUID1,
914                TS2,
915                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
916            ),
917        ];
918        let stream = LogStream::new(lines.into_iter());
919        let mut tracker = SessionTracker::new(stream);
920        let _: Vec<_> = tracker.by_ref().collect();
921
922        let a_leg = tracker.sessions().get(UUID1).unwrap();
923        assert_eq!(
924            a_leg.other_leg_uuid.as_deref(),
925            Some(UUID2),
926            "A-leg linked to B-leg via channel-name fallback when Peer UUID absent"
927        );
928
929        let b_leg = tracker.sessions().get(UUID2).unwrap();
930        assert_eq!(
931            b_leg.other_leg_uuid.as_deref(),
932            Some(UUID1),
933            "B-leg linked back to A-leg"
934        );
935    }
936
937    #[test]
938    fn originate_success_peer_uuid_wins_over_channel_fallback() {
939        // When Peer UUID is present, channel-name fallback must not fire — even if
940        // another session shares the channel name. Peer UUID is authoritative.
941        let lines = vec![
942            full_line(
943                UUID2,
944                TS1,
945                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
946            ),
947            full_line(
948                UUID3,
949                TS1,
950                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
951            ),
952            full_line(
953                UUID1,
954                TS2,
955                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901",
956            ),
957        ];
958        let stream = LogStream::new(lines.into_iter());
959        let mut tracker = SessionTracker::new(stream);
960        let _: Vec<_> = tracker.by_ref().collect();
961
962        let a_leg = tracker.sessions().get(UUID1).unwrap();
963        assert_eq!(
964            a_leg.other_leg_uuid.as_deref(),
965            Some(UUID2),
966            "Peer UUID wins over channel-name match"
967        );
968
969        let decoy = tracker.sessions().get(UUID3).unwrap();
970        assert_eq!(
971            decoy.other_leg_uuid, None,
972            "Decoy session sharing channel name is not touched"
973        );
974    }
975
976    #[test]
977    fn originate_success_channel_fallback_skips_when_ambiguous() {
978        // Two b-leg candidates share the same channel name. The fallback must not
979        // guess — correctness over coverage.
980        let lines = vec![
981            full_line(
982                UUID2,
983                TS1,
984                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
985            ),
986            full_line(
987                UUID3,
988                TS1,
989                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
990            ),
991            full_line(
992                UUID1,
993                TS2,
994                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
995            ),
996        ];
997        let stream = LogStream::new(lines.into_iter());
998        let mut tracker = SessionTracker::new(stream);
999        let _: Vec<_> = tracker.by_ref().collect();
1000
1001        let a_leg = tracker.sessions().get(UUID1).unwrap();
1002        assert_eq!(
1003            a_leg.other_leg_uuid, None,
1004            "Ambiguous channel name yields no link"
1005        );
1006        assert_eq!(tracker.sessions().get(UUID2).unwrap().other_leg_uuid, None);
1007        assert_eq!(tracker.sessions().get(UUID3).unwrap().other_leg_uuid, None);
1008    }
1009
1010    #[test]
1011    fn originate_success_channel_fallback_skips_terminated_candidates() {
1012        // Two b-leg sessions share the same channel_name, but one is in
1013        // CS_DESTROY (stale prior call on the same registered phone). The
1014        // liveness filter must drop the terminated candidate so the live one
1015        // becomes the unambiguous match.
1016        let lines = vec![
1017            full_line(
1018                UUID2,
1019                TS1,
1020                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1021            ),
1022            full_line(
1023                UUID2,
1024                TS1,
1025                "(sofia/internal/6244@192.0.2.72:50744) State Change CS_EXECUTE -> CS_DESTROY",
1026            ),
1027            full_line(
1028                UUID3,
1029                TS1,
1030                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1031            ),
1032            full_line(
1033                UUID1,
1034                TS2,
1035                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1036            ),
1037        ];
1038        let stream = LogStream::new(lines.into_iter());
1039        let mut tracker = SessionTracker::new(stream);
1040        let _: Vec<_> = tracker.by_ref().collect();
1041
1042        let a_leg = tracker.sessions().get(UUID1).unwrap();
1043        assert_eq!(
1044            a_leg.other_leg_uuid.as_deref(),
1045            Some(UUID3),
1046            "Live b-leg wins over CS_DESTROY straggler"
1047        );
1048
1049        let live_b = tracker.sessions().get(UUID3).unwrap();
1050        assert_eq!(
1051            live_b.other_leg_uuid.as_deref(),
1052            Some(UUID1),
1053            "Live b-leg points back to a-leg"
1054        );
1055
1056        let stale_b = tracker.sessions().get(UUID2).unwrap();
1057        assert_eq!(
1058            stale_b.other_leg_uuid, None,
1059            "Terminated b-leg is not touched"
1060        );
1061    }
1062
1063    #[test]
1064    fn originate_success_channel_fallback_skips_when_no_match() {
1065        // a-leg fires Originate with a bracketed channel name no session has.
1066        // Must not panic, must not create a spurious link.
1067        let lines = vec![full_line(
1068            UUID1,
1069            TS2,
1070            "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1071        )];
1072        let stream = LogStream::new(lines.into_iter());
1073        let mut tracker = SessionTracker::new(stream);
1074        let _: Vec<_> = tracker.by_ref().collect();
1075
1076        let a_leg = tracker.sessions().get(UUID1).unwrap();
1077        assert_eq!(a_leg.other_leg_uuid, None);
1078        assert_eq!(a_leg.pending_bridge_target, None);
1079    }
1080
1081    #[test]
1082    fn bridge_origination_uuid_links_a_leg_immediately() {
1083        // bridge([origination_uuid=BLEG_UUID,...]) guarantees B-leg UUID from execute args alone.
1084        // A-leg knows B-leg immediately, B-leg learns A-leg when New Channel appears.
1085        let lines = vec![
1086            full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal-v6/1232@[2001:db8::10] bridge([origination_uuid=b2c3d4e5-f6a7-8901-bcde-f12345678901,leg_timeout=2]sofia/esinet1-v6-tcp/sip:target.example.com)"),
1087            full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1088        ];
1089        let stream = LogStream::new(lines.into_iter());
1090        let mut tracker = SessionTracker::new(stream);
1091        let _: Vec<_> = tracker.by_ref().collect();
1092
1093        let a_leg = tracker.sessions().get(UUID1).unwrap();
1094        assert_eq!(
1095            a_leg.other_leg_uuid.as_deref(),
1096            Some(UUID2),
1097            "A-leg knows B-leg UUID from origination_uuid in bridge args"
1098        );
1099
1100        let b_leg = tracker.sessions().get(UUID2).unwrap();
1101        assert_eq!(
1102            b_leg.other_leg_uuid.as_deref(),
1103            Some(UUID1),
1104            "B-leg knows A-leg once New Channel correlates"
1105        );
1106    }
1107
1108    #[test]
1109    fn bridge_target_matches_new_channel() {
1110        // bridge() without origination_uuid — B-leg UUID is auto-generated by FS.
1111        // Match via bridge target channel matching next New Channel with same target.
1112        let lines = vec![
1113            full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1114            full_line(UUID1, TS1, "Parsing session specific variables"),
1115            full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1116        ];
1117        let stream = LogStream::new(lines.into_iter());
1118        let mut tracker = SessionTracker::new(stream);
1119        let _: Vec<_> = tracker.by_ref().collect();
1120
1121        let a_leg = tracker.sessions().get(UUID1).unwrap();
1122        assert_eq!(
1123            a_leg.other_leg_uuid.as_deref(),
1124            Some(UUID2),
1125            "A-leg linked to B-leg via bridge target matching New Channel"
1126        );
1127
1128        let b_leg = tracker.sessions().get(UUID2).unwrap();
1129        assert_eq!(
1130            b_leg.other_leg_uuid.as_deref(),
1131            Some(UUID1),
1132            "B-leg linked back to A-leg"
1133        );
1134    }
1135
1136    #[test]
1137    fn originate_success_corrects_wrong_target_match() {
1138        // Bridge target matching guessed UUID2 as B-leg, but originate success reveals
1139        // the actual B-leg is UUID3. The authoritative success message must override.
1140        let lines = vec![
1141            full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1142            full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1143            full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/gateway/carrier/+15559876543] Peer UUID: c3d4e5f6-a7b8-9012-cdef-234567890123"),
1144        ];
1145        let stream = LogStream::new(lines.into_iter());
1146        let mut tracker = SessionTracker::new(stream);
1147        let _: Vec<_> = tracker.by_ref().collect();
1148
1149        let a_leg = tracker.sessions().get(UUID1).unwrap();
1150        assert_eq!(
1151            a_leg.other_leg_uuid.as_deref(),
1152            Some(UUID3),
1153            "Originate success overrides earlier target-match guess"
1154        );
1155
1156        let real_b_leg = tracker.sessions().get(UUID3).unwrap();
1157        assert_eq!(
1158            real_b_leg.other_leg_uuid.as_deref(),
1159            Some(UUID1),
1160            "Real B-leg points back to A-leg"
1161        );
1162    }
1163
1164    #[test]
1165    fn channel_data_other_leg_uuid() {
1166        // Other-Leg-Unique-ID in CHANNEL_DATA (post-bridge info dump) sets other_leg_uuid
1167        let lines = vec![
1168            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1169            format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1170        ];
1171        let stream = LogStream::new(lines.into_iter());
1172        let mut tracker = SessionTracker::new(stream);
1173        let _: Vec<_> = tracker.by_ref().collect();
1174
1175        let state = tracker.sessions().get(UUID1).unwrap();
1176        assert_eq!(
1177            state.other_leg_uuid.as_deref(),
1178            Some(UUID2),
1179            "other_leg_uuid set from Other-Leg-Unique-ID CHANNEL_DATA field"
1180        );
1181    }
1182
1183    #[test]
1184    fn relink_removes_stale_by_other_leg_entry() {
1185        // B first points at C (Other-Leg-Unique-ID), then an authoritative
1186        // Peer UUID relinks B to A. The superseded C-keyed by_other_leg entry
1187        // must be removed — otherwise a later New Channel on C back-links to
1188        // B and clobbers the authoritative A<->B pair.
1189        let lines = vec![
1190            full_line(UUID2, TS1, "CHANNEL_DATA:"),
1191            format!("{UUID2} Other-Leg-Unique-ID: [{UUID3}]"),
1192            full_line(
1193                UUID1,
1194                TS2,
1195                &format!(
1196                    "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: {UUID2}"
1197                ),
1198            ),
1199            full_line(
1200                UUID3,
1201                TS2,
1202                &format!("New Channel sofia/external/dest@192.0.2.9 [{UUID3}]"),
1203            ),
1204        ];
1205        let stream = LogStream::new(lines.into_iter());
1206        let mut tracker = SessionTracker::new(stream);
1207        let _: Vec<_> = tracker.by_ref().collect();
1208
1209        let a_leg = tracker.sessions().get(UUID1).unwrap();
1210        assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1211
1212        let b_leg = tracker.sessions().get(UUID2).unwrap();
1213        assert_eq!(
1214            b_leg.other_leg_uuid.as_deref(),
1215            Some(UUID1),
1216            "authoritative Peer UUID link must survive the unrelated New Channel"
1217        );
1218
1219        let c_leg = tracker.sessions().get(UUID3).unwrap();
1220        assert_eq!(
1221            c_leg.other_leg_uuid, None,
1222            "New Channel on C must not back-link via the superseded index entry"
1223        );
1224    }
1225
1226    #[test]
1227    fn channel_data_populates_session() {
1228        let lines = vec![
1229            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1230            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1231            format!("{UUID1} Channel-State: [CS_EXECUTE]"),
1232            "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1233            "variable_direction: [inbound]".to_string(),
1234        ];
1235        let entries = collect_enriched(lines);
1236        assert_eq!(entries.len(), 1);
1237        let session = entries[0].session.as_ref().unwrap();
1238        assert_eq!(
1239            session.channel_name.as_deref(),
1240            Some("sofia/internal/+15550001234@192.0.2.1")
1241        );
1242        assert_eq!(session.channel_state.as_deref(), Some("CS_EXECUTE"));
1243    }
1244
1245    #[test]
1246    fn variables_learned_from_channel_data() {
1247        let lines = vec![
1248            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1249            "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1250            "variable_direction: [inbound]".to_string(),
1251        ];
1252        let stream = LogStream::new(lines.into_iter());
1253        let mut tracker = SessionTracker::new(stream);
1254        let _: Vec<_> = tracker.by_ref().collect();
1255        let state = tracker.sessions().get(UUID1).unwrap();
1256        assert_eq!(
1257            state.variables.get("sip_call_id").map(|s| s.as_str()),
1258            Some("test123@192.0.2.1")
1259        );
1260        assert_eq!(
1261            state.variables.get("direction").map(|s| s.as_str()),
1262            Some("inbound")
1263        );
1264    }
1265
1266    #[test]
1267    fn multi_line_variable_survives_attached_rescan() {
1268        // The block carries the reassembled multi-line value; re-scanning the
1269        // raw attached opening fragment must not clobber it back to "v=0".
1270        let lines = vec![
1271            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1272            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1273            format!("{UUID1} variable_switch_r_sdp: [v=0"),
1274            "o=FreeSWITCH 1737000000 1737000001 IN IP4 192.0.2.10".to_string(),
1275            "s=FreeSWITCH".to_string(),
1276            "c=IN IP4 192.0.2.10".to_string(),
1277            "m=audio 30000 RTP/AVP 0 101".to_string(),
1278            "]".to_string(),
1279            format!("{UUID1} variable_direction: [inbound]"),
1280        ];
1281        let stream = LogStream::new(lines.into_iter());
1282        let mut tracker = SessionTracker::new(stream);
1283        let _: Vec<_> = tracker.by_ref().collect();
1284
1285        let state = tracker.sessions().get(UUID1).unwrap();
1286        let sdp = state
1287            .variables
1288            .get("switch_r_sdp")
1289            .expect("switch_r_sdp variable present");
1290        assert!(
1291            sdp.contains('\n'),
1292            "expected full reassembled value, got fragment: {sdp:?}"
1293        );
1294        assert!(sdp.starts_with("v=0\n"));
1295        assert!(sdp.contains("m=audio 30000 RTP/AVP 0 101"));
1296        assert_eq!(
1297            state.variables.get("direction").map(|s| s.as_str()),
1298            Some("inbound")
1299        );
1300        assert_eq!(
1301            state.channel_name.as_deref(),
1302            Some("sofia/internal/+15550001234@192.0.2.1")
1303        );
1304    }
1305
1306    #[test]
1307    fn attached_processing_line_updates_context() {
1308        // Format C continuation: a `Processing ...` line attached under a
1309        // primary entry must update dialplan context like the primary path.
1310        let lines = vec![
1311            full_line(UUID1, TS1, "Ring-Ready sofia/internal-v4/sos!"),
1312            format!(
1313                "{UUID1} Processing Extension 1263 <1263>->start_recording in context recordings"
1314            ),
1315        ];
1316        let stream = LogStream::new(lines.into_iter());
1317        let mut tracker = SessionTracker::new(stream);
1318        let _: Vec<_> = tracker.by_ref().collect();
1319
1320        let state = tracker.sessions().get(UUID1).unwrap();
1321        assert_eq!(state.dialplan_context.as_deref(), Some("recordings"));
1322        assert_eq!(
1323            state.dialplan_from.as_deref(),
1324            Some("Extension 1263 <1263>")
1325        );
1326        assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1327        assert_eq!(
1328            state.initial_destination.as_deref(),
1329            Some("start_recording")
1330        );
1331    }
1332
1333    #[test]
1334    fn variables_learned_from_set_execute() {
1335        let lines = vec![
1336            full_line(UUID1, TS1, "First"),
1337            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(call_direction=inbound)"),
1338            full_line(UUID1, TS2, "After set"),
1339        ];
1340        let stream = LogStream::new(lines.into_iter());
1341        let mut tracker = SessionTracker::new(stream);
1342        let entries: Vec<_> = tracker.by_ref().collect();
1343        assert_eq!(entries.len(), 3);
1344        let state = tracker.sessions().get(UUID1).unwrap();
1345        assert_eq!(
1346            state.variables.get("call_direction").map(|s| s.as_str()),
1347            Some("inbound")
1348        );
1349    }
1350
1351    #[test]
1352    fn variables_learned_from_export_execute() {
1353        let lines = vec![
1354            full_line(UUID1, TS1, "First"),
1355            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)"),
1356        ];
1357        let stream = LogStream::new(lines.into_iter());
1358        let mut tracker = SessionTracker::new(stream);
1359        let _: Vec<_> = tracker.by_ref().collect();
1360        let state = tracker.sessions().get(UUID1).unwrap();
1361        assert_eq!(
1362            state.variables.get("originate_timeout").map(|s| s.as_str()),
1363            Some("3600")
1364        );
1365    }
1366
1367    #[test]
1368    fn session_isolation_between_uuids() {
1369        let lines = vec![
1370            full_line(
1371                UUID1,
1372                TS1,
1373                "Processing 5551111111->5552222222 in context public",
1374            ),
1375            full_line(
1376                UUID2,
1377                TS2,
1378                "Processing 5553333333->5554444444 in context private",
1379            ),
1380        ];
1381        let stream = LogStream::new(lines.into_iter());
1382        let mut tracker = SessionTracker::new(stream);
1383        let _: Vec<_> = tracker.by_ref().collect();
1384        let s1 = tracker.sessions().get(UUID1).unwrap();
1385        let s2 = tracker.sessions().get(UUID2).unwrap();
1386        assert_eq!(s1.dialplan_context.as_deref(), Some("public"));
1387        assert_eq!(s2.dialplan_context.as_deref(), Some("private"));
1388        assert_eq!(s1.dialplan_from.as_deref(), Some("5551111111"));
1389        assert_eq!(s2.dialplan_from.as_deref(), Some("5553333333"));
1390    }
1391
1392    #[test]
1393    fn processing_line_with_regex_type_and_angle_bracket_caller() {
1394        let lines = vec![full_line(
1395            UUID1,
1396            TS1,
1397            "Processing Emergency S R <5550001234>->start_recording in context recordings",
1398        )];
1399        let entries = collect_enriched(lines);
1400        let session = entries[0].session.as_ref().unwrap();
1401        assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1402        assert_eq!(session.dialplan_context.as_deref(), Some("recordings"));
1403        assert_eq!(
1404            session.dialplan_from.as_deref(),
1405            Some("Emergency S R <5550001234>")
1406        );
1407        assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1408    }
1409
1410    #[test]
1411    fn processing_line_extension_format() {
1412        let lines = vec![full_line(
1413            UUID1,
1414            TS1,
1415            "Processing Extension 1263 <1263>->start_recording in context recordings",
1416        )];
1417        let entries = collect_enriched(lines);
1418        let session = entries[0].session.as_ref().unwrap();
1419        assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1420        assert_eq!(
1421            session.dialplan_from.as_deref(),
1422            Some("Extension 1263 <1263>")
1423        );
1424        assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1425    }
1426
1427    #[test]
1428    fn parse_processing_line_anchors_on_last_arrow() {
1429        let dest = |msg: &str| parse_processing_line(msg).map(|dp| dp.to);
1430        assert_eq!(
1431            dest("Processing Anonymous <anonymous>->5550001234 in context public").as_deref(),
1432            Some("5550001234"),
1433        );
1434        assert_eq!(
1435            dest("Processing 5550009999 <5550009999>->5550001234 in context public").as_deref(),
1436            Some("5550001234"),
1437        );
1438        assert_eq!(
1439            dest("Processing Jane Doe <5550009999>->5550001234 in context internal").as_deref(),
1440            Some("5550001234"),
1441        );
1442        // Hostile caller_id_name containing `->` must not be mistaken for the boundary.
1443        assert_eq!(
1444            dest("Processing Weird -> Name <5550009999>->5550001234 in context internal")
1445                .as_deref(),
1446            Some("5550001234"),
1447        );
1448        // Feature-context destination is non-numeric but still parsed.
1449        assert_eq!(
1450            dest("Processing Jane Doe <5550009999>->start_recording in context features")
1451                .as_deref(),
1452            Some("start_recording"),
1453        );
1454    }
1455
1456    #[test]
1457    fn initial_destination_first_wins() {
1458        let lines = vec![
1459            full_line(
1460                UUID1,
1461                TS1,
1462                "Processing Jane Doe <5550009999>->5550001234 in context public",
1463            ),
1464            full_line(
1465                UUID1,
1466                TS2,
1467                "Processing Jane Doe <5550009999>->5550001234 in context transit",
1468            ),
1469            full_line(
1470                UUID1,
1471                TS2,
1472                "Processing Jane Doe <5550009999>->start_recording in context features",
1473            ),
1474            full_line(
1475                UUID1,
1476                TS2,
1477                "Processing Jane Doe <5550009999>->check_end_call in context features",
1478            ),
1479        ];
1480        let stream = LogStream::new(lines.into_iter());
1481        let mut tracker = SessionTracker::new(stream);
1482        let _: Vec<_> = tracker.by_ref().collect();
1483
1484        let state = tracker.sessions().get(UUID1).unwrap();
1485        assert_eq!(
1486            state.initial_destination.as_deref(),
1487            Some("5550001234"),
1488            "initial_destination keeps the dialed number from the first Processing line"
1489        );
1490        assert_eq!(
1491            state.dialplan_to.as_deref(),
1492            Some("check_end_call"),
1493            "dialplan_to is last-wins and gets clobbered by feature-context routing"
1494        );
1495    }
1496
1497    #[test]
1498    fn state_change_updates_channel_state() {
1499        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1500        let entries = collect_enriched(lines);
1501        let session = entries[0].session.as_ref().unwrap();
1502        assert_eq!(session.channel_state.as_deref(), Some("CS_ROUTING"));
1503    }
1504
1505    #[test]
1506    fn callstate_change_updates_channel_state() {
1507        let lines = vec![full_line(
1508            UUID1,
1509            TS1,
1510            "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1511        )];
1512        let entries = collect_enriched(lines);
1513        let session = entries[0].session.as_ref().unwrap();
1514        assert_eq!(session.channel_state.as_deref(), Some("RINGING"));
1515    }
1516
1517    #[test]
1518    fn state_change_overrides_callstate() {
1519        let lines = vec![
1520            full_line(
1521                UUID1,
1522                TS1,
1523                "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1524            ),
1525            full_line(
1526                UUID1,
1527                TS2,
1528                "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1529            ),
1530        ];
1531        let entries = collect_enriched(lines);
1532        assert_eq!(
1533            entries[0]
1534                .session
1535                .as_ref()
1536                .unwrap()
1537                .channel_state
1538                .as_deref(),
1539            Some("RINGING")
1540        );
1541        assert_eq!(
1542            entries[1]
1543                .session
1544                .as_ref()
1545                .unwrap()
1546                .channel_state
1547                .as_deref(),
1548            Some("CS_EXCHANGE_MEDIA")
1549        );
1550    }
1551
1552    #[test]
1553    fn bleg_lifecycle_extracts_data_from_processing() {
1554        let lines = vec![
1555            full_line(
1556                UUID1,
1557                TS1,
1558                "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1559            ),
1560            full_line(
1561                UUID1,
1562                TS1,
1563                "(sofia/internal-v4/sos) State Change CS_NEW -> CS_INIT",
1564            ),
1565            full_line(
1566                UUID1,
1567                TS1,
1568                "(sofia/internal-v4/sos) State Change CS_INIT -> CS_ROUTING",
1569            ),
1570            full_line(
1571                UUID1,
1572                TS1,
1573                "(sofia/internal-v4/sos) State Change CS_ROUTING -> CS_CONSUME_MEDIA",
1574            ),
1575            full_line(
1576                UUID1,
1577                TS1,
1578                "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1579            ),
1580            full_line(
1581                UUID1,
1582                TS2,
1583                "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1584            ),
1585            full_line(
1586                UUID1,
1587                TS2,
1588                "Processing Emergency S R <5550001234>->start_recording in context recordings",
1589            ),
1590            full_line(
1591                UUID1,
1592                TS2,
1593                "(sofia/internal-v4/sos) State Change CS_EXCHANGE_MEDIA -> CS_HANGUP",
1594            ),
1595        ];
1596        let entries = collect_enriched(lines);
1597
1598        let after_ringing = entries[4].session.as_ref().unwrap();
1599        assert_eq!(after_ringing.channel_state.as_deref(), Some("RINGING"));
1600        assert!(after_ringing.initial_context.is_none());
1601
1602        let after_processing = entries[6].session.as_ref().unwrap();
1603        assert_eq!(
1604            after_processing.channel_state.as_deref(),
1605            Some("CS_EXCHANGE_MEDIA")
1606        );
1607        assert_eq!(
1608            after_processing.initial_context.as_deref(),
1609            Some("recordings")
1610        );
1611        assert_eq!(
1612            after_processing.dialplan_from.as_deref(),
1613            Some("Emergency S R <5550001234>")
1614        );
1615        assert_eq!(
1616            after_processing.dialplan_to.as_deref(),
1617            Some("start_recording")
1618        );
1619
1620        let after_hangup = entries[7].session.as_ref().unwrap();
1621        assert_eq!(after_hangup.channel_state.as_deref(), Some("CS_HANGUP"));
1622        assert_eq!(after_hangup.initial_context.as_deref(), Some("recordings"));
1623    }
1624
1625    #[test]
1626    fn channel_name_from_new_channel() {
1627        let lines = vec![full_line(
1628            UUID1,
1629            TS1,
1630            "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1631        )];
1632        let entries = collect_enriched(lines);
1633        let session = entries[0].session.as_ref().unwrap();
1634        assert_eq!(
1635            session.channel_name.as_deref(),
1636            Some("sofia/internal-v4/sos")
1637        );
1638    }
1639
1640    #[test]
1641    fn remove_session() {
1642        let lines = vec![full_line(
1643            UUID1,
1644            TS1,
1645            "Processing 5551111111->5552222222 in context public",
1646        )];
1647        let stream = LogStream::new(lines.into_iter());
1648        let mut tracker = SessionTracker::new(stream);
1649        let _: Vec<_> = tracker.by_ref().collect();
1650        assert!(tracker.sessions().contains_key(UUID1));
1651        let removed = tracker.remove_session(UUID1).unwrap();
1652        assert_eq!(removed.dialplan_context.as_deref(), Some("public"));
1653        assert!(!tracker.sessions().contains_key(UUID1));
1654    }
1655
1656    #[test]
1657    fn stats_delegation() {
1658        let lines = vec![
1659            full_line(UUID1, TS1, "First"),
1660            full_line(UUID1, TS2, "Second"),
1661        ];
1662        let stream = LogStream::new(lines.into_iter());
1663        let mut tracker = SessionTracker::new(stream);
1664        let _: Vec<_> = tracker.by_ref().collect();
1665        assert_eq!(tracker.stats().lines_processed, 2);
1666    }
1667
1668    #[test]
1669    fn snapshot_reflects_cumulative_state() {
1670        let lines = vec![
1671            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1672            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1673            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"),
1674            full_line(
1675                UUID1,
1676                TS2,
1677                "Processing 5551111111->5552222222 in context public",
1678            ),
1679        ];
1680        let entries = collect_enriched(lines);
1681        assert_eq!(entries.len(), 3);
1682        let first = entries[0].session.as_ref().unwrap();
1683        assert_eq!(
1684            first.channel_name.as_deref(),
1685            Some("sofia/internal/+15550001234@192.0.2.1"),
1686        );
1687        assert!(first.dialplan_context.is_none());
1688
1689        let last = entries[2].session.as_ref().unwrap();
1690        assert_eq!(
1691            last.channel_name.as_deref(),
1692            Some("sofia/internal/+15550001234@192.0.2.1"),
1693        );
1694        assert_eq!(last.dialplan_context.as_deref(), Some("public"));
1695    }
1696
1697    #[test]
1698    fn post_hook_sets_other_leg_uuid() {
1699        let lines = vec![
1700            full_line(UUID1, TS1, "First entry"),
1701            format!(
1702                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2} Job-UUID: ...)"
1703            ),
1704        ];
1705        let stream = LogStream::new(lines.into_iter());
1706        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1707            if let MessageKind::Execute {
1708                application,
1709                arguments,
1710                ..
1711            } = &entry.message_kind
1712            {
1713                if application == "set" {
1714                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1715                        let uuid = value.split_whitespace().next().unwrap_or("");
1716                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1717                            state.other_leg_uuid = Some(uuid.to_string());
1718                        }
1719                    }
1720                }
1721            }
1722        });
1723
1724        let entries: Vec<_> = tracker.by_ref().collect();
1725        assert_eq!(entries.len(), 2);
1726
1727        let session = entries[1].session.as_ref().unwrap();
1728        assert_eq!(
1729            session.other_leg_uuid.as_deref(),
1730            Some(UUID2),
1731            "post_hook should detect uuid_bridge API result"
1732        );
1733    }
1734
1735    #[test]
1736    fn post_hook_does_not_override_builtin() {
1737        let lines = vec![
1738            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1739            format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1740            format!(
1741                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID3} Job-UUID: ...)"
1742            ),
1743        ];
1744        let stream = LogStream::new(lines.into_iter());
1745        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1746            if let MessageKind::Execute {
1747                application,
1748                arguments,
1749                ..
1750            } = &entry.message_kind
1751            {
1752                if application == "set" {
1753                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1754                        let uuid = value.split_whitespace().next().unwrap_or("");
1755                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1756                            state.other_leg_uuid = Some(uuid.to_string());
1757                        }
1758                    }
1759                }
1760            }
1761        });
1762
1763        let entries: Vec<_> = tracker.by_ref().collect();
1764        assert_eq!(entries.len(), 2);
1765
1766        let session = entries[1].session.as_ref().unwrap();
1767        assert_eq!(
1768            session.other_leg_uuid.as_deref(),
1769            Some(UUID2),
1770            "built-in Other-Leg-Unique-ID takes precedence over hook"
1771        );
1772    }
1773
1774    #[test]
1775    fn pre_hook_runs_before_builtin() {
1776        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1777        let stream = LogStream::new(lines.into_iter());
1778        let mut tracker = SessionTracker::new(stream).with_pre_hook(|_entry, state| {
1779            state.channel_state = Some("PRE_SET".to_string());
1780        });
1781        let entries: Vec<_> = tracker.by_ref().collect();
1782        assert_eq!(
1783            entries[0]
1784                .session
1785                .as_ref()
1786                .unwrap()
1787                .channel_state
1788                .as_deref(),
1789            Some("CS_ROUTING"),
1790            "built-in overwrites pre_hook value when no guard"
1791        );
1792    }
1793
1794    #[test]
1795    fn post_hook_runs_after_builtin() {
1796        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1797        let stream = LogStream::new(lines.into_iter());
1798        let mut tracker = SessionTracker::new(stream).with_post_hook(|_entry, state| {
1799            if state.channel_state.as_deref() == Some("CS_ROUTING") {
1800                state
1801                    .variables
1802                    .insert("routing_seen".to_string(), "true".to_string());
1803            }
1804        });
1805        let _: Vec<_> = tracker.by_ref().collect();
1806        let state = tracker.sessions().get(UUID1).unwrap();
1807        assert_eq!(
1808            state.variables.get("routing_seen").map(|s| s.as_str()),
1809            Some("true"),
1810            "post_hook can read fields set by built-in"
1811        );
1812    }
1813
1814    #[test]
1815    fn post_hook_other_leg_uuid_maintains_index_for_backlink() {
1816        // A hook-set other_leg_uuid must reach by_other_leg so the B-leg's
1817        // later New Channel back-links to the A-leg like built-in sources do.
1818        let lines = vec![
1819            full_line(UUID1, TS1, "First entry"),
1820            format!(
1821                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2})"
1822            ),
1823            full_line(
1824                UUID2,
1825                TS2,
1826                "New Channel sofia/internal/target@192.0.2.9 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1827            ),
1828        ];
1829        let stream = LogStream::new(lines.into_iter());
1830        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1831            if let MessageKind::Execute {
1832                application,
1833                arguments,
1834                ..
1835            } = &entry.message_kind
1836            {
1837                if application == "set" {
1838                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1839                        let uuid = value.split_whitespace().next().unwrap_or("");
1840                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1841                            state.other_leg_uuid = Some(uuid.to_string());
1842                        }
1843                    }
1844                }
1845            }
1846        });
1847        let _: Vec<_> = tracker.by_ref().collect();
1848
1849        let a_leg = tracker.sessions().get(UUID1).unwrap();
1850        assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1851
1852        let b_leg = tracker.sessions().get(UUID2).unwrap();
1853        assert_eq!(
1854            b_leg.other_leg_uuid.as_deref(),
1855            Some(UUID1),
1856            "B-leg back-links via by_other_leg index populated by the hook"
1857        );
1858    }
1859
1860    #[test]
1861    fn pre_hook_channel_name_maintains_index_for_originate_fallback() {
1862        // A hook-set channel_name must reach by_channel_name so the
1863        // originate-success channel-name fallback can find the B-leg.
1864        let lines = vec![
1865            full_line(UUID2, TS1, "custom-channel-announce sofia/custom/6244"),
1866            full_line(
1867                UUID1,
1868                TS2,
1869                "Originate Resulted in Success: [sofia/custom/6244]",
1870            ),
1871        ];
1872        let stream = LogStream::new(lines.into_iter());
1873        let mut tracker = SessionTracker::new(stream).with_pre_hook(|entry, state| {
1874            if let Some(chan) = entry.message.strip_prefix("custom-channel-announce ") {
1875                state.channel_name = Some(chan.to_string());
1876            }
1877        });
1878        let _: Vec<_> = tracker.by_ref().collect();
1879
1880        let a_leg = tracker.sessions().get(UUID1).unwrap();
1881        assert_eq!(
1882            a_leg.other_leg_uuid.as_deref(),
1883            Some(UUID2),
1884            "fallback finds hook-named B-leg via by_channel_name index"
1885        );
1886        let b_leg = tracker.sessions().get(UUID2).unwrap();
1887        assert_eq!(b_leg.other_leg_uuid.as_deref(), Some(UUID1));
1888    }
1889
1890    #[test]
1891    fn parse_hangup_extracts_cause() {
1892        assert_eq!(
1893            parse_hangup("Hangup sofia/internal/1234 [NORMAL_CLEARING]"),
1894            Some("NORMAL_CLEARING".to_string())
1895        );
1896        assert_eq!(
1897            parse_hangup("Hangup sofia/internal/1234 [USER_BUSY]"),
1898            Some("USER_BUSY".to_string())
1899        );
1900        assert_eq!(parse_hangup("Some other message"), None);
1901        assert_eq!(parse_hangup("New Channel sofia/internal/1234 [uuid]"), None);
1902    }
1903
1904    #[test]
1905    fn is_answered_detects_answer_event() {
1906        assert!(is_answered("sofia/internal/1234 has been answered"));
1907        assert!(!is_answered("sofia/internal/1234 is ringing"));
1908        assert!(!is_answered("New Channel sofia/internal/1234"));
1909    }
1910
1911    #[test]
1912    fn hangup_cause_from_lifecycle() {
1913        let lines = vec![full_line(
1914            UUID1,
1915            TS1,
1916            "Hangup sofia/internal/+15550001234@192.0.2.1 [NORMAL_CLEARING]",
1917        )];
1918        let entries = collect_enriched(lines);
1919        let session = entries[0].session.as_ref().unwrap();
1920        assert_eq!(
1921            session.hangup_cause.as_deref(),
1922            Some("NORMAL_CLEARING"),
1923            "hangup_cause extracted from ChannelLifecycle Hangup"
1924        );
1925    }
1926
1927    #[test]
1928    fn answered_at_from_lifecycle() {
1929        let lines = vec![full_line(
1930            UUID1,
1931            TS1,
1932            "sofia/internal/+15550001234@192.0.2.1 has been answered",
1933        )];
1934        let entries = collect_enriched(lines);
1935        let session = entries[0].session.as_ref().unwrap();
1936        assert_eq!(
1937            session.answered_at.as_deref(),
1938            Some(TS1),
1939            "answered_at captures timestamp when 'has been answered' seen"
1940        );
1941    }
1942
1943    #[test]
1944    fn answered_at_not_overwritten() {
1945        let lines = vec![
1946            full_line(
1947                UUID1,
1948                TS1,
1949                "sofia/internal/+15550001234@192.0.2.1 has been answered",
1950            ),
1951            full_line(
1952                UUID1,
1953                TS2,
1954                "sofia/internal/+15550001234@192.0.2.1 has been answered",
1955            ),
1956        ];
1957        let entries = collect_enriched(lines);
1958        let session = entries[1].session.as_ref().unwrap();
1959        assert_eq!(
1960            session.answered_at.as_deref(),
1961            Some(TS1),
1962            "answered_at preserves first answer timestamp"
1963        );
1964    }
1965
1966    #[test]
1967    fn caller_id_name_from_channel_data() {
1968        let lines = vec![
1969            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1970            format!("{UUID1} Caller-Caller-ID-Name: [Test Caller Name]"),
1971        ];
1972        let entries = collect_enriched(lines);
1973        let session = entries[0].session.as_ref().unwrap();
1974        assert_eq!(
1975            session.caller_id_name.as_deref(),
1976            Some("Test Caller Name"),
1977            "caller_id_name extracted from CHANNEL_DATA"
1978        );
1979    }
1980}