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