Skip to main content

freeswitch_log_parser/session/
mod.rs

1pub mod conference;
2mod loopback;
3pub mod media;
4
5use std::collections::{HashMap, HashSet};
6use std::str::FromStr;
7
8use freeswitch_types::variables::VariableName;
9use freeswitch_types::{BridgeDialString, CallDirection, DialString};
10
11use crate::line::parse_line;
12use crate::message::{classify_message, MessageKind};
13use crate::stream::{Block, LogEntry, LogStream, ParseStats, UnclassifiedLine};
14use conference::{ConferenceEvent, ConferenceMembership, ConferenceRegistry};
15use media::SessionMedia;
16
17type SessionHook = Box<dyn Fn(&LogEntry, &mut SessionState) + Send>;
18
19/// Mutable per-UUID state accumulator, updated as entries are processed.
20///
21/// Fields are `None` until the corresponding data is first seen in the stream.
22/// Variables accumulate from CHANNEL_DATA dumps, `set()`/`export()` executions,
23/// `SET`/`EXPORT` log lines, and inline `variable_*` lines.
24#[derive(Debug, Clone, Default)]
25pub struct SessionState {
26    /// `None` until a `Channel-Name` field is encountered.
27    pub channel_name: Option<String>,
28    /// `None` until a state change or `Channel-State` field is encountered.
29    pub channel_state: Option<String>,
30    /// First dialplan context seen; set once and never overwritten.
31    pub initial_context: Option<String>,
32    /// Destination of the first `Processing` line = the dialed number at ingress;
33    /// set once and never overwritten (unlike last-wins `dialplan_to`).
34    pub initial_destination: Option<String>,
35    /// Current dialplan context; updated on each transfer/continue.
36    pub dialplan_context: Option<String>,
37    /// Source extension in the dialplan routing; `None` until a dialplan line is processed.
38    pub dialplan_from: Option<String>,
39    /// Target extension in the dialplan routing; `None` until a dialplan line is processed.
40    pub dialplan_to: Option<String>,
41    /// Call direction from `Call-Direction` CHANNEL_DATA field; `None` until seen.
42    pub call_direction: Option<CallDirection>,
43    /// Caller ID number from `Caller-Caller-ID-Number` CHANNEL_DATA field; `None` until seen.
44    pub caller_id_number: Option<String>,
45    /// Caller ID name from `Caller-Caller-ID-Name` CHANNEL_DATA field; `None` until seen.
46    pub caller_id_name: Option<String>,
47    /// Destination number from `Caller-Destination-Number` CHANNEL_DATA field; `None` until seen.
48    pub destination_number: Option<String>,
49    /// Hangup cause extracted from ChannelLifecycle Hangup detail; `None` until hangup seen.
50    pub hangup_cause: Option<String>,
51    /// Timestamp when "has been answered" lifecycle event was seen; `None` until answered.
52    pub answered_at: Option<String>,
53    /// Other leg's UUID; `None` until bridged. Set from `Originate Resulted in Success` on A-leg,
54    /// and from `New Channel` on B-leg (back-pointing to A-leg via originate context).
55    pub other_leg_uuid: Option<String>,
56    /// Conference this session is currently a member of; `None` once it leaves.
57    pub conference: Option<ConferenceMembership>,
58    /// Codecs negotiated on this leg, by media type and direction.
59    pub media: SessionMedia,
60    /// Pending bridge target channel from `EXECUTE bridge()`, consumed when B-leg `New Channel` matches.
61    pub(crate) pending_bridge_target: Option<String>,
62    /// All variables learned so far, with the `variable_` prefix stripped from names.
63    pub variables: HashMap<String, String>,
64}
65
66/// Changes to indexed fields, diffed across hooks and built-in extraction
67/// for index maintenance.
68#[derive(Default)]
69struct IndexedFieldChanges {
70    channel_name: Option<(Option<String>, Option<String>)>,
71    pending_bridge_target: Option<(Option<String>, Option<String>)>,
72    other_leg_uuid: Option<(Option<String>, Option<String>)>,
73    conference: Option<(Option<ConferenceMembership>, Option<ConferenceMembership>)>,
74}
75
76/// Indexed fields as they stood before the pre-hook, held for the post-hook diff.
77#[derive(Default)]
78struct IndexedFields {
79    channel_name: Option<String>,
80    pending_bridge_target: Option<String>,
81    other_leg_uuid: Option<String>,
82    conference: Option<ConferenceMembership>,
83}
84
85impl IndexedFields {
86    fn of(state: &SessionState) -> Self {
87        IndexedFields {
88            channel_name: state.channel_name.clone(),
89            pending_bridge_target: state.pending_bridge_target.clone(),
90            other_leg_uuid: state.other_leg_uuid.clone(),
91            conference: state.conference.clone(),
92        }
93    }
94}
95
96impl IndexedFieldChanges {
97    fn diff(old: IndexedFields, state: &SessionState) -> Self {
98        let IndexedFields {
99            channel_name: old_channel_name,
100            pending_bridge_target: old_pending_bridge_target,
101            other_leg_uuid: old_other_leg_uuid,
102            conference: old_conference,
103        } = old;
104        let mut changes = IndexedFieldChanges::default();
105        if state.conference != old_conference {
106            changes.conference = Some((old_conference, state.conference.clone()));
107        }
108        if state.channel_name != old_channel_name {
109            changes.channel_name = Some((old_channel_name, state.channel_name.clone()));
110        }
111        if state.pending_bridge_target != old_pending_bridge_target {
112            changes.pending_bridge_target = Some((
113                old_pending_bridge_target,
114                state.pending_bridge_target.clone(),
115            ));
116        }
117        if state.other_leg_uuid != old_other_leg_uuid {
118            changes.other_leg_uuid = Some((old_other_leg_uuid, state.other_leg_uuid.clone()));
119        }
120        changes
121    }
122}
123
124/// Immutable point-in-time copy of a session's state, attached to each [`EnrichedEntry`].
125///
126/// Does not include `variables` to keep snapshots lightweight — access the full
127/// variable map via [`SessionTracker::sessions()`].
128#[derive(Debug, Clone)]
129pub struct SessionSnapshot {
130    pub channel_name: Option<String>,
131    pub channel_state: Option<String>,
132    pub initial_context: Option<String>,
133    pub initial_destination: Option<String>,
134    pub dialplan_context: Option<String>,
135    pub dialplan_from: Option<String>,
136    pub dialplan_to: Option<String>,
137    pub call_direction: Option<CallDirection>,
138    pub caller_id_number: Option<String>,
139    pub caller_id_name: Option<String>,
140    pub destination_number: Option<String>,
141    pub hangup_cause: Option<String>,
142    pub answered_at: Option<String>,
143    pub other_leg_uuid: Option<String>,
144    pub conference: Option<ConferenceMembership>,
145    pub media: SessionMedia,
146}
147
148impl SessionState {
149    /// Value of a typed channel variable, or `None` if this session never saw it.
150    ///
151    /// Accepts any of `freeswitch-types`' variable-name enums, and spares the
152    /// caller from knowing that [`variables`](Self::variables) keys are stored
153    /// with the `variable_` prefix stripped.
154    pub fn variable<V: VariableName>(&self, var: V) -> Option<&str> {
155        self.variables.get(var.as_str()).map(String::as_str)
156    }
157
158    fn snapshot(&self) -> SessionSnapshot {
159        SessionSnapshot {
160            channel_name: self.channel_name.clone(),
161            channel_state: self.channel_state.clone(),
162            initial_context: self.initial_context.clone(),
163            initial_destination: self.initial_destination.clone(),
164            dialplan_context: self.dialplan_context.clone(),
165            dialplan_from: self.dialplan_from.clone(),
166            dialplan_to: self.dialplan_to.clone(),
167            call_direction: self.call_direction,
168            caller_id_number: self.caller_id_number.clone(),
169            caller_id_name: self.caller_id_name.clone(),
170            destination_number: self.destination_number.clone(),
171            hangup_cause: self.hangup_cause.clone(),
172            answered_at: self.answered_at.clone(),
173            other_leg_uuid: self.other_leg_uuid.clone(),
174            conference: self.conference.clone(),
175            media: self.media.clone(),
176        }
177    }
178
179    fn update_from_entry(&mut self, entry: &LogEntry) {
180        let block_has_channel_data = matches!(entry.block, Some(Block::ChannelData { .. }));
181        if let Some(Block::ChannelData { fields, variables }) = &entry.block {
182            for (name, value) in fields {
183                match name.as_str() {
184                    "Channel-Name" => self.channel_name = Some(value.clone()),
185                    "Channel-State" => self.channel_state = Some(value.clone()),
186                    "Call-Direction" => {
187                        self.call_direction = CallDirection::from_str(value).ok();
188                    }
189                    "Caller-Caller-ID-Number" => {
190                        self.caller_id_number = Some(value.clone());
191                    }
192                    "Caller-Caller-ID-Name" => {
193                        self.caller_id_name = Some(value.clone());
194                    }
195                    "Caller-Destination-Number" => {
196                        self.destination_number = Some(value.clone());
197                    }
198                    "Other-Leg-Unique-ID" => {
199                        self.other_leg_uuid = Some(value.clone());
200                    }
201                    _ => {}
202                }
203            }
204            for (name, value) in variables {
205                let var_name = name.strip_prefix("variable_").unwrap_or(name);
206                self.variables.insert(var_name.to_string(), value.clone());
207            }
208        }
209
210        match &entry.message_kind {
211            MessageKind::Execute {
212                application,
213                arguments,
214                ..
215            } => match application.as_str() {
216                "set" | "export" => {
217                    if let Some((name, value)) = arguments.split_once('=') {
218                        self.variables.insert(name.to_string(), value.to_string());
219                    }
220                }
221                "bridge" => {
222                    if let Some(info) = parse_bridge_args(arguments) {
223                        if let Some(uuid) = &info.origination_uuid {
224                            self.other_leg_uuid = Some(uuid.clone());
225                        }
226                        self.pending_bridge_target = Some(info.target_channel);
227                    }
228                }
229                _ => {}
230            },
231            MessageKind::ChannelLifecycle { detail } => {
232                if let Some(name) = parse_new_channel(detail) {
233                    if self.channel_name.is_none() {
234                        self.channel_name = Some(name);
235                    }
236                }
237                if let Some(cause) = parse_hangup(detail) {
238                    self.hangup_cause = Some(cause);
239                }
240                if is_answered(detail) && self.answered_at.is_none() {
241                    self.answered_at = Some(entry.timestamp.clone());
242                }
243            }
244            kind => self.apply_kind(kind),
245        }
246
247        self.apply_processing(&entry.message);
248        self.media.update_from_entry(entry);
249
250        for attached in &entry.attached {
251            let parsed = parse_line(attached);
252            self.update_from_message(parsed.message, block_has_channel_data);
253        }
254    }
255
256    /// Canonical extraction for message kinds that appear on both primary and
257    /// attached lines. Entry-only kinds (Execute, ChannelLifecycle — the
258    /// latter needs the entry timestamp) stay in `update_from_entry`.
259    fn apply_kind(&mut self, kind: &MessageKind) {
260        match kind {
261            MessageKind::Dialplan { detail, .. } => {
262                if let Some(dp) = parse_dialplan_context(detail) {
263                    self.initial_context.get_or_insert(dp.context.clone());
264                    self.dialplan_context = Some(dp.context);
265                    self.dialplan_from = Some(dp.from);
266                    self.dialplan_to = Some(dp.to);
267                }
268            }
269            MessageKind::Variable { name, value } => {
270                let var_name = name.strip_prefix("variable_").unwrap_or(name);
271                self.variables.insert(var_name.to_string(), value.clone());
272            }
273            MessageKind::ChannelField { name, value } => match name.as_str() {
274                "Channel-Name" => self.channel_name = Some(value.clone()),
275                "Channel-State" => self.channel_state = Some(value.clone()),
276                _ => {}
277            },
278            MessageKind::StateChange { detail } => {
279                if let Some(new_state) = parse_state_change(detail) {
280                    self.channel_state = Some(new_state);
281                }
282            }
283            _ => {}
284        }
285    }
286
287    /// `Processing <caller>-><dest> in context <ctx>` — emitted by the dialplan
288    /// hunt on both primary and attached lines; anchored on raw text because
289    /// `classify_message` folds it into `Dialplan` with the prefix stripped.
290    fn apply_processing(&mut self, msg: &str) {
291        if msg.contains("Processing ") && msg.contains(" in context ") {
292            if let Some(dp) = parse_processing_line(msg) {
293                self.initial_context.get_or_insert(dp.context.clone());
294                self.initial_destination.get_or_insert(dp.to.clone());
295                self.dialplan_context = Some(dp.context);
296                self.dialplan_from = Some(dp.from);
297                self.dialplan_to = Some(dp.to);
298            }
299        }
300    }
301
302    fn update_from_message(&mut self, msg: &str, block_provides_channel_data: bool) {
303        let kind = classify_message(msg);
304        match &kind {
305            // A ChannelData block already carries these — re-applying the raw
306            // attached lines would clobber reassembled multi-line values with
307            // their opening fragment.
308            MessageKind::Variable { .. } | MessageKind::ChannelField { .. }
309                if block_provides_channel_data => {}
310            kind => self.apply_kind(kind),
311        }
312        self.apply_processing(msg);
313    }
314}
315
316struct DialplanContext {
317    from: String,
318    to: String,
319    context: String,
320}
321
322fn parse_dialplan_context(detail: &str) -> Option<DialplanContext> {
323    if !detail.starts_with("parsing [") {
324        return None;
325    }
326    let rest = &detail["parsing [".len()..];
327    let bracket_end = rest.find(']')?;
328    let inner = &rest[..bracket_end];
329
330    let arrow = inner.find("->")?;
331    let from_part = &inner[..arrow];
332    let to_part = &inner[arrow + 2..];
333
334    Some(DialplanContext {
335        from: from_part.to_string(),
336        to: to_part.to_string(),
337        context: from_part.to_string(),
338    })
339}
340
341/// Parse `Processing <name> <<number>>-><dest> in context <ctx>`. Field 1 (caller_id_name) is
342/// free-form and may contain spaces, `->`, `<`, so anchor on the fixed frame: the rightmost
343/// ` in context ` and the last `>->` (the `>` closing `<number>` immediately precedes `->`, and
344/// only the caller_id_number→destination boundary has that shape). Falls back to the last bare
345/// `->` for the bracketless `from->to` shape.
346fn parse_processing_line(msg: &str) -> Option<DialplanContext> {
347    let proc_idx = msg.find("Processing ")?;
348    let after_proc = &msg[proc_idx + "Processing ".len()..];
349
350    let ctx_idx = after_proc.rfind(" in context ")?;
351    let head = &after_proc[..ctx_idx];
352    let context = after_proc[ctx_idx + " in context ".len()..]
353        .split_whitespace()
354        .next()?;
355
356    let (from, to) = match head.rfind(">->") {
357        Some(i) => (&head[..i + 1], &head[i + ">->".len()..]),
358        None => {
359            let i = head.rfind("->")?;
360            (&head[..i], &head[i + "->".len()..])
361        }
362    };
363
364    Some(DialplanContext {
365        from: from.to_string(),
366        to: to.to_string(),
367        context: context.to_string(),
368    })
369}
370
371fn parse_new_channel(detail: &str) -> Option<String> {
372    let rest = detail.strip_prefix("New Channel ")?;
373    let bracket = rest.rfind(" [")?;
374    Some(rest[..bracket].to_string())
375}
376
377fn parse_state_change(detail: &str) -> Option<String> {
378    let arrow = detail.find(" -> ")?;
379    Some(detail[arrow + 4..].trim().to_string())
380}
381
382fn parse_hangup(detail: &str) -> Option<String> {
383    if !detail.contains("Hangup ") {
384        return None;
385    }
386    let start = detail.rfind('[')?;
387    let end = detail[start..].find(']')?;
388    Some(detail[start + 1..start + end].to_string())
389}
390
391fn is_answered(detail: &str) -> bool {
392    detail.contains("has been answered")
393}
394
395/// Extract `origination_uuid` and the bridge target channel from bridge() arguments.
396/// Uses `BridgeDialString` from freeswitch-types for correct parsing of `[]`, `{}`,
397/// `|` failover, and `,` simultaneous ring syntax.
398///
399/// Returns `None` when the arguments do not parse as a dial string or name no
400/// endpoint at all.
401pub fn parse_bridge_args(arguments: &str) -> Option<BridgeInfo> {
402    let dial = BridgeDialString::from_str(arguments).ok()?;
403    let first_ep = dial.groups().first()?.first()?;
404    // `{}` variables apply to every endpoint, so a single-leg bridge can name
405    // the new leg's UUID there instead of in the endpoint's own `[]`.
406    let origination_uuid = first_ep
407        .variables()
408        .and_then(|v| v.get("origination_uuid"))
409        .or_else(|| dial.variables().and_then(|v| v.get("origination_uuid")))
410        .map(|s| s.to_string());
411    let mut bare = first_ep.clone();
412    bare.set_variables(None);
413    let target_channel = bare.to_string();
414    Some(BridgeInfo {
415        origination_uuid,
416        target_channel,
417    })
418}
419
420/// What a `bridge()` argument list says about the leg it is about to create.
421#[derive(Debug, Clone, PartialEq, Eq)]
422#[non_exhaustive]
423pub struct BridgeInfo {
424    /// The UUID the new leg is being forced to take, when the dial string sets one.
425    pub origination_uuid: Option<String>,
426    /// The first endpoint with its `{}`/`[]` variables removed, matching the form
427    /// the new channel will report as its channel name.
428    pub target_channel: String,
429}
430
431/// Parse "Originate Resulted in Success: [channel] Peer UUID: uuid"
432fn parse_originate_success(msg: &str) -> Option<String> {
433    let marker = "Peer UUID: ";
434    let idx = msg.find(marker)?;
435    let uuid = msg[idx + marker.len()..].trim();
436    if uuid.is_empty() {
437        None
438    } else {
439        Some(uuid.to_string())
440    }
441}
442
443/// Parse the bracketed channel name from "Originate Resulted in Success: [<chan>] …".
444/// Used as a fallback when the `Peer UUID:` suffix is absent (FS 1.10.5-dev and
445/// similar builds). Returns the channel name borrowed from `msg`.
446fn parse_originate_channel(msg: &str) -> Option<&str> {
447    let start = msg.find(" [")? + 2;
448    let end = msg[start..].find(']')?;
449    let chan = &msg[start..start + end];
450    if chan.is_empty() {
451        None
452    } else {
453        Some(chan)
454    }
455}
456
457/// Terminal channel-/callstate values — sessions left in one of these are
458/// stragglers from prior calls and must not be considered candidates when
459/// disambiguating channel-name collisions in the originate-success fallback.
460///
461/// Covers both `Channel-State` (`CS_*`) and `Callstate` (`HANGUP`). `DOWN` is
462/// excluded because it doubles as the initial Callstate before any change is
463/// observed.
464fn is_terminal_channel_state(state: Option<&str>) -> bool {
465    matches!(
466        state,
467        Some("CS_HANGUP" | "CS_REPORTING" | "CS_DESTROY" | "CS_NONE" | "HANGUP")
468    )
469}
470
471/// A [`LogEntry`] paired with the session's state snapshot at that point in time.
472#[derive(Debug)]
473pub struct EnrichedEntry {
474    pub entry: LogEntry,
475    /// `None` for system lines (entries with an empty UUID).
476    pub session: Option<SessionSnapshot>,
477}
478
479/// Layer 3 per-session state machine — tracks per-UUID state (dialplan context,
480/// channel state, variables) across entries and yields [`EnrichedEntry`] values.
481///
482/// Wraps a [`LogStream`] and maintains a `HashMap<String, SessionState>` keyed by UUID.
483/// Sessions are never automatically cleaned up; call [`remove_session()`](SessionTracker::remove_session)
484/// when a call ends.
485pub struct SessionTracker<I> {
486    inner: LogStream<I>,
487    sessions: HashMap<String, SessionState>,
488    by_channel_name: HashMap<String, HashSet<String>>,
489    by_pending_target: HashMap<String, String>,
490    by_other_leg: HashMap<String, String>,
491    conferences: ConferenceRegistry,
492    pre_hook: Option<SessionHook>,
493    post_hook: Option<SessionHook>,
494}
495
496impl<I: Iterator<Item = String>> SessionTracker<I> {
497    /// Wrap a [`LogStream`] to add per-session state tracking.
498    pub fn new(inner: LogStream<I>) -> Self {
499        SessionTracker {
500            inner,
501            sessions: HashMap::new(),
502            by_channel_name: HashMap::new(),
503            by_pending_target: HashMap::new(),
504            by_other_leg: HashMap::new(),
505            conferences: ConferenceRegistry::default(),
506            pre_hook: None,
507            post_hook: None,
508        }
509    }
510
511    /// Register a hook that runs BEFORE built-in field extraction.
512    ///
513    /// Use this to override how specific fields are extracted. Fields set
514    /// by the pre-hook may be preserved by built-in extraction if it uses
515    /// `is_none()` guards. Indexed fields set by the hook (`channel_name`,
516    /// `other_leg_uuid`) feed cross-session leg correlation like built-in
517    /// extraction does.
518    pub fn with_pre_hook<F>(mut self, hook: F) -> Self
519    where
520        F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
521    {
522        self.pre_hook = Some(Box::new(hook));
523        self
524    }
525
526    /// Register a hook that runs AFTER all built-in processing.
527    ///
528    /// Use this for custom field extraction and relationship detection.
529    /// The hook can read fields populated by built-in extraction and
530    /// fill gaps with application-specific patterns (e.g., `uuid_bridge`
531    /// API results, custom SIP headers). Indexed fields set by the hook
532    /// (`channel_name`, `other_leg_uuid`) feed cross-session leg
533    /// correlation like built-in extraction does.
534    ///
535    /// # Example
536    ///
537    /// ```
538    /// use freeswitch_log_parser::{LogStream, SessionTracker, MessageKind};
539    ///
540    /// let stream = LogStream::new(std::iter::empty::<String>());
541    /// let tracker = SessionTracker::new(stream)
542    ///     .with_post_hook(|entry, state| {
543    ///         if let MessageKind::Execute { application, arguments, .. } = &entry.message_kind {
544    ///             if application == "set" && arguments.starts_with("api_result=+OK ") {
545    ///                 // extract UUID and set state.other_leg_uuid
546    ///             }
547    ///         }
548    ///     });
549    /// ```
550    pub fn with_post_hook<F>(mut self, hook: F) -> Self
551    where
552        F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
553    {
554        self.post_hook = Some(Box::new(hook));
555        self
556    }
557
558    /// All currently tracked sessions, keyed by UUID.
559    pub fn sessions(&self) -> &HashMap<String, SessionState> {
560        &self.sessions
561    }
562
563    /// UUIDs currently in the conference instance named by
564    /// [`ConferenceMembership::instance`]. Empty once the last member leaves.
565    pub fn conference_members<'a>(&'a self, instance: &'a str) -> impl Iterator<Item = &'a str> {
566        self.conferences.members(instance)
567    }
568
569    /// Remove and return a session's accumulated state. Call this when a call ends
570    /// (e.g. `CS_DESTROY` or hangup) to free memory.
571    pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
572        let state = self.sessions.remove(uuid)?;
573        if let Some(chan) = &state.channel_name {
574            if let Some(set) = self.by_channel_name.get_mut(chan) {
575                set.remove(uuid);
576                if set.is_empty() {
577                    self.by_channel_name.remove(chan);
578                }
579            }
580        }
581        if let Some(target) = &state.pending_bridge_target {
582            self.by_pending_target.remove(target);
583        }
584        if let Some(other) = &state.other_leg_uuid {
585            self.by_other_leg.remove(other);
586        }
587        if let Some(conf) = &state.conference {
588            self.conferences.leave(&conf.name, uuid);
589        }
590        Some(state)
591    }
592
593    /// Delegates to [`LogStream::stats()`].
594    pub fn stats(&self) -> &ParseStats {
595        self.inner.stats()
596    }
597
598    /// Delegates to [`LogStream::drain_unclassified()`].
599    pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
600        self.inner.drain_unclassified()
601    }
602
603    fn apply_index_changes(&mut self, uuid: &str, changes: &IndexedFieldChanges) {
604        if let Some((old, new)) = &changes.channel_name {
605            if let Some(old_name) = old {
606                if let Some(set) = self.by_channel_name.get_mut(old_name) {
607                    set.remove(uuid);
608                    if set.is_empty() {
609                        self.by_channel_name.remove(old_name);
610                    }
611                }
612            }
613            if let Some(new_name) = new {
614                self.by_channel_name
615                    .entry(new_name.clone())
616                    .or_default()
617                    .insert(uuid.to_string());
618            }
619        }
620        if let Some((old, new)) = &changes.pending_bridge_target {
621            if let Some(old_target) = old {
622                self.by_pending_target.remove(old_target);
623            }
624            if let Some(new_target) = new {
625                self.by_pending_target
626                    .insert(new_target.clone(), uuid.to_string());
627            }
628        }
629        if let Some((old, new)) = &changes.other_leg_uuid {
630            match new {
631                Some(new_leg) => self.index_other_leg(uuid, old.clone(), new_leg),
632                None => {
633                    if let Some(old_leg) = old {
634                        self.by_other_leg.remove(old_leg);
635                    }
636                }
637            }
638        }
639        if let Some((old, new)) = &changes.conference {
640            let same_instance =
641                matches!((old, new), (Some(o), Some(n)) if o.instance == n.instance);
642            if !same_instance {
643                if let Some(old_conf) = old {
644                    self.conferences.leave(&old_conf.name, uuid);
645                }
646                if let Some(new_conf) = new {
647                    self.conferences
648                        .join(&new_conf.name, &new_conf.instance, uuid);
649                }
650            }
651        }
652    }
653
654    /// Record `uuid`'s `other_leg_uuid` transition in `by_other_leg`,
655    /// removing the superseded key so a stale entry cannot mislink a later
656    /// `New Channel` back-link. Every write to the index goes through here.
657    fn index_other_leg(&mut self, uuid: &str, old_leg: Option<String>, new_leg: &str) {
658        if let Some(old) = old_leg {
659            if old != new_leg {
660                self.by_other_leg.remove(&old);
661            }
662        }
663        self.by_other_leg
664            .insert(new_leg.to_string(), uuid.to_string());
665    }
666
667    /// Conference membership. Called after `update_from_entry` so the channel
668    /// variables this reads are already populated. Only `state.conference` is
669    /// written here; the registry is updated from the post-hook diff, so a
670    /// hook-set membership is registered the same way this one is.
671    fn update_conference(&mut self, uuid: &str, entry: &LogEntry) {
672        let target = match conference::detect(entry) {
673            Some(ConferenceEvent::Leave) => {
674                if let Some(state) = self.sessions.get_mut(uuid) {
675                    state.conference = None;
676                }
677                return;
678            }
679            Some(ConferenceEvent::Join(target)) => Some(target),
680            None => None,
681        };
682
683        let Some(state) = self.sessions.get(uuid) else {
684            return;
685        };
686        let Some(target) = target.or_else(|| conference::target_from_variables(&state.variables))
687        else {
688            if let Some(state) = self.sessions.get_mut(uuid) {
689                let SessionState {
690                    conference,
691                    variables,
692                    ..
693                } = state;
694                if let Some(membership) = conference {
695                    conference::refresh(membership, variables);
696                }
697            }
698            return;
699        };
700
701        // Staying in the same conference keeps the instance already recorded;
702        // otherwise adopt the live instance for that name, or open one keyed on
703        // this session because it is the first member.
704        let instance = match state.conference.as_ref() {
705            Some(current) if current.name == target.name => current.instance.clone(),
706            _ => self
707                .conferences
708                .instance_for(&target.name)
709                .map(str::to_string)
710                .unwrap_or_else(|| uuid.to_string()),
711        };
712
713        let Some(state) = self.sessions.get_mut(uuid) else {
714            return;
715        };
716        let SessionState {
717            conference,
718            variables,
719            ..
720        } = state;
721        let joining_elsewhere = conference.as_ref().is_none_or(|c| c.name != target.name);
722        if joining_elsewhere {
723            *conference = Some(ConferenceMembership {
724                name: target.name,
725                profile: target.profile.clone(),
726                instance,
727                member_id: None,
728                conference_uuid: None,
729            });
730        }
731        let Some(membership) = conference.as_mut() else {
732            return;
733        };
734        if target.profile.is_some() {
735            membership.profile = target.profile;
736        }
737        conference::refresh(membership, variables);
738    }
739
740    /// The A leg of the loopback whose B leg just appeared. Shares the
741    /// originate fallback's ambiguity guard: concurrent loopbacks to the same
742    /// destination produce identical names, and linking the wrong pair is worse
743    /// than linking none.
744    fn loopback_a_leg(&self, b_channel: &str, b_uuid: &str) -> Option<String> {
745        let a_channel = loopback::a_leg_name(b_channel)?;
746        let candidates: Vec<&String> = self
747            .by_channel_name
748            .get(&a_channel)?
749            .iter()
750            .filter(|u| *u != b_uuid)
751            .filter(|u| {
752                self.sessions
753                    .get(*u)
754                    .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
755                    .unwrap_or(false)
756            })
757            .collect();
758        match candidates.as_slice() {
759            [a_uuid] => Some((*a_uuid).clone()),
760            _ => None,
761        }
762    }
763
764    /// Cross-session leg linking. Called after `update_from_entry` so per-session
765    /// state (bridge target, channel name) is already populated.
766    fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
767        // 1. "Originate Resulted in Success ... Peer UUID: BLEG" — authoritative
768        if entry.message.contains("Originate Resulted in Success") {
769            let a_uuid = uuid.to_string();
770            if let Some(peer_uuid) = parse_originate_success(&entry.message) {
771                let a_old_pending = self
772                    .sessions
773                    .get(&a_uuid)
774                    .and_then(|s| s.pending_bridge_target.clone());
775
776                let mut a_old_leg = None;
777                if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
778                    a_old_leg = a_state.other_leg_uuid.replace(peer_uuid.clone());
779                    a_state.pending_bridge_target = None;
780                }
781                self.index_other_leg(&a_uuid, a_old_leg, &peer_uuid);
782                if let Some(old_target) = a_old_pending {
783                    self.by_pending_target.remove(&old_target);
784                }
785
786                let b_state = self.sessions.entry(peer_uuid.clone()).or_default();
787                let b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
788                self.index_other_leg(&peer_uuid, b_old_leg, &a_uuid);
789            } else if let Some(chan) = parse_originate_channel(&entry.message) {
790                // Fallback for FS builds without `Peer UUID:` suffix (e.g. 1.10.5-dev):
791                // link via unique non-terminated b-leg session whose channel_name
792                // matches. Candidates in terminal states are stragglers; if zero or
793                // multiple live candidates remain, skip (correctness over coverage).
794                let candidates: Vec<String> = self
795                    .by_channel_name
796                    .get(chan)
797                    .map(|set| {
798                        set.iter()
799                            .filter(|u| *u != &a_uuid)
800                            .filter(|u| {
801                                self.sessions
802                                    .get(*u)
803                                    .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
804                                    .unwrap_or(false)
805                            })
806                            .cloned()
807                            .collect()
808                    })
809                    .unwrap_or_default();
810
811                if let [b_uuid] = candidates.as_slice() {
812                    let b_uuid = b_uuid.clone();
813                    let a_old_pending = self
814                        .sessions
815                        .get(&a_uuid)
816                        .and_then(|s| s.pending_bridge_target.clone());
817
818                    let mut a_old_leg = None;
819                    if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
820                        a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
821                        a_state.pending_bridge_target = None;
822                    }
823                    let mut b_old_leg = None;
824                    if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
825                        b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
826                    }
827
828                    self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
829                    self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
830                    if let Some(old_target) = a_old_pending {
831                        self.by_pending_target.remove(&old_target);
832                    }
833                }
834            }
835            return;
836        }
837
838        // 2. New Channel on this UUID — check if any other session has a pending bridge
839        //    with origination_uuid matching this UUID, or target matching this channel name.
840        if let MessageKind::ChannelLifecycle { detail } = &entry.message_kind {
841            if let Some(channel_name) = parse_new_channel(detail) {
842                let b_uuid = uuid.to_string();
843
844                // O(1) index lookups instead of full scan
845                let a_uuid_found = self
846                    .by_other_leg
847                    .get(&b_uuid)
848                    .cloned()
849                    .or_else(|| self.by_pending_target.get(&channel_name).cloned())
850                    .or_else(|| self.loopback_a_leg(&channel_name, &b_uuid))
851                    .filter(|a| a != &b_uuid);
852
853                if let Some(a_uuid) = a_uuid_found {
854                    let a_old_pending = self
855                        .sessions
856                        .get(&a_uuid)
857                        .and_then(|s| s.pending_bridge_target.clone());
858
859                    let mut a_old_leg = None;
860                    if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
861                        a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
862                        a_state.pending_bridge_target = None;
863                    }
864                    let mut b_old_leg = None;
865                    if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
866                        b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
867                    }
868
869                    self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
870                    self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
871                    if let Some(old_target) = a_old_pending {
872                        self.by_pending_target.remove(&old_target);
873                    }
874                }
875            }
876        }
877    }
878}
879
880impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
881    type Item = EnrichedEntry;
882
883    fn next(&mut self) -> Option<EnrichedEntry> {
884        let entry = self.inner.next()?;
885
886        if entry.uuid.is_empty() {
887            return Some(EnrichedEntry {
888                entry,
889                session: None,
890            });
891        }
892
893        let uuid = entry.uuid.clone();
894        let state = self.sessions.entry(uuid.clone()).or_default();
895
896        // Snapshot indexed fields before the pre-hook and diff after the
897        // post-hook so hook-set fields maintain the cross-session indexes
898        // exactly like built-in extraction.
899        let old = IndexedFields::of(state);
900
901        if let Some(hook) = &self.pre_hook {
902            hook(&entry, state);
903        }
904
905        state.update_from_entry(&entry);
906
907        self.update_conference(&uuid, &entry);
908        self.link_legs(&uuid, &entry);
909
910        // `entry().or_default()` rather than an unwrapped lookup: the session was
911        // inserted above and nothing here removes it, but re-asserting that with a
912        // panic buys nothing when the map can simply hand back the same state.
913        if let Some(hook) = &self.post_hook {
914            let state = self.sessions.entry(uuid.clone()).or_default();
915            hook(&entry, state);
916        }
917
918        let state = self.sessions.entry(uuid.clone()).or_default();
919        let changes = IndexedFieldChanges::diff(old, state);
920        let snapshot = state.snapshot();
921        self.apply_index_changes(&uuid, &changes);
922
923        Some(EnrichedEntry {
924            entry,
925            session: Some(snapshot),
926        })
927    }
928}
929
930#[cfg(test)]
931mod tests {
932    use freeswitch_types::variables::SofiaVariable;
933    use freeswitch_types::ChannelVariable;
934
935    use super::*;
936
937    const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
938    const UUID2: &str = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
939    const UUID3: &str = "c3d4e5f6-a7b8-9012-cdef-234567890123";
940    const TS1: &str = "2025-01-15 10:30:45.123456";
941    const TS2: &str = "2025-01-15 10:30:46.234567";
942
943    fn full_line(uuid: &str, ts: &str, msg: &str) -> String {
944        format!("{uuid} {ts} 95.97% [DEBUG] sofia.c:100 {msg}")
945    }
946
947    fn collect_enriched(lines: Vec<String>) -> Vec<EnrichedEntry> {
948        let stream = LogStream::new(lines.into_iter());
949        SessionTracker::new(stream).collect()
950    }
951
952    #[test]
953    fn system_line_no_session() {
954        let lines = vec![format!(
955            "{TS1} 95.97% [INFO] mod_event_socket.c:1772 Event Socket command"
956        )];
957        let entries = collect_enriched(lines);
958        assert_eq!(entries.len(), 1);
959        assert!(entries[0].session.is_none());
960    }
961
962    #[test]
963    fn dialplan_context_propagation() {
964        let lines = vec![
965            full_line(UUID1, TS1, "CHANNEL_DATA:"),
966            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
967            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer"),
968            format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true"),
969            full_line(UUID1, TS2, "Some later event"),
970        ];
971        let entries = collect_enriched(lines);
972        let last = entries.last().unwrap();
973        let session = last.session.as_ref().unwrap();
974        assert_eq!(session.dialplan_context.as_deref(), Some("public"));
975        assert_eq!(session.dialplan_from.as_deref(), Some("public"));
976        assert_eq!(session.dialplan_to.as_deref(), Some("global"));
977    }
978
979    #[test]
980    fn processing_line_extracts_context() {
981        let lines = vec![full_line(
982            UUID1,
983            TS1,
984            "Processing 5551234567->5559876543 in context public",
985        )];
986        let entries = collect_enriched(lines);
987        let session = entries[0].session.as_ref().unwrap();
988        assert_eq!(session.dialplan_context.as_deref(), Some("public"));
989        assert_eq!(session.dialplan_from.as_deref(), Some("5551234567"));
990        assert_eq!(session.dialplan_to.as_deref(), Some("5559876543"));
991    }
992
993    #[test]
994    fn initial_context_preserved_across_transfers() {
995        let lines = vec![
996            full_line(
997                UUID1,
998                TS1,
999                "Processing 5551234567->5559876543 in context public",
1000            ),
1001            full_line(
1002                UUID1,
1003                TS2,
1004                "Processing 5551234567->start_recording in context recordings",
1005            ),
1006        ];
1007        let stream = LogStream::new(lines.into_iter());
1008        let mut tracker = SessionTracker::new(stream);
1009        let entries: Vec<_> = tracker.by_ref().collect();
1010
1011        let first = entries[0].session.as_ref().unwrap();
1012        assert_eq!(
1013            first.initial_context.as_deref(),
1014            Some("public"),
1015            "initial_context set on first Processing line"
1016        );
1017        assert_eq!(first.dialplan_context.as_deref(), Some("public"));
1018
1019        let state = tracker.sessions().get(UUID1).unwrap();
1020        assert_eq!(
1021            state.initial_context.as_deref(),
1022            Some("public"),
1023            "initial_context keeps the first context seen"
1024        );
1025        assert_eq!(
1026            state.dialplan_context.as_deref(),
1027            Some("recordings"),
1028            "dialplan_context tracks the current context"
1029        );
1030        assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1031    }
1032
1033    #[test]
1034    fn new_channel_sets_channel_name() {
1035        let lines = vec![full_line(
1036            UUID1,
1037            TS1,
1038            "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1039        )];
1040        let entries = collect_enriched(lines);
1041        let session = entries[0].session.as_ref().unwrap();
1042        assert_eq!(
1043            session.channel_name.as_deref(),
1044            Some("sofia/internal-v4/sos")
1045        );
1046    }
1047
1048    #[test]
1049    fn originate_success_links_both_legs() {
1050        // "Originate Resulted in Success" contains both the A-leg UUID (line prefix)
1051        // and B-leg UUID (Peer UUID field). Both legs should learn about each other.
1052        let lines = vec![
1053            full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1054            full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/esinet1-v6-tcp/sip:target.example.com] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901"),
1055        ];
1056        let stream = LogStream::new(lines.into_iter());
1057        let mut tracker = SessionTracker::new(stream);
1058        let _: Vec<_> = tracker.by_ref().collect();
1059
1060        let a_leg = tracker.sessions().get(UUID1).unwrap();
1061        assert_eq!(
1062            a_leg.other_leg_uuid.as_deref(),
1063            Some(UUID2),
1064            "A-leg other_leg_uuid set from Originate Resulted in Success"
1065        );
1066
1067        let b_leg = tracker.sessions().get(UUID2).unwrap();
1068        assert_eq!(
1069            b_leg.other_leg_uuid.as_deref(),
1070            Some(UUID1),
1071            "B-leg other_leg_uuid points back to A-leg"
1072        );
1073    }
1074
1075    #[test]
1076    fn originate_success_channel_fallback_links_legs() {
1077        // FS 1.10.5-dev and similar omit `Peer UUID:` from "Originate Resulted in Success".
1078        // The b-leg's New Channel populates channel_name 3.5 s before originate; the
1079        // fallback path matches by channel name when the Peer UUID is absent.
1080        let lines = vec![
1081            full_line(
1082                UUID2,
1083                TS1,
1084                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1085            ),
1086            full_line(
1087                UUID1,
1088                TS2,
1089                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1090            ),
1091        ];
1092        let stream = LogStream::new(lines.into_iter());
1093        let mut tracker = SessionTracker::new(stream);
1094        let _: Vec<_> = tracker.by_ref().collect();
1095
1096        let a_leg = tracker.sessions().get(UUID1).unwrap();
1097        assert_eq!(
1098            a_leg.other_leg_uuid.as_deref(),
1099            Some(UUID2),
1100            "A-leg linked to B-leg via channel-name fallback when Peer UUID absent"
1101        );
1102
1103        let b_leg = tracker.sessions().get(UUID2).unwrap();
1104        assert_eq!(
1105            b_leg.other_leg_uuid.as_deref(),
1106            Some(UUID1),
1107            "B-leg linked back to A-leg"
1108        );
1109    }
1110
1111    #[test]
1112    fn originate_success_peer_uuid_wins_over_channel_fallback() {
1113        // When Peer UUID is present, channel-name fallback must not fire — even if
1114        // another session shares the channel name. Peer UUID is authoritative.
1115        let lines = vec![
1116            full_line(
1117                UUID2,
1118                TS1,
1119                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1120            ),
1121            full_line(
1122                UUID3,
1123                TS1,
1124                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1125            ),
1126            full_line(
1127                UUID1,
1128                TS2,
1129                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901",
1130            ),
1131        ];
1132        let stream = LogStream::new(lines.into_iter());
1133        let mut tracker = SessionTracker::new(stream);
1134        let _: Vec<_> = tracker.by_ref().collect();
1135
1136        let a_leg = tracker.sessions().get(UUID1).unwrap();
1137        assert_eq!(
1138            a_leg.other_leg_uuid.as_deref(),
1139            Some(UUID2),
1140            "Peer UUID wins over channel-name match"
1141        );
1142
1143        let decoy = tracker.sessions().get(UUID3).unwrap();
1144        assert_eq!(
1145            decoy.other_leg_uuid, None,
1146            "Decoy session sharing channel name is not touched"
1147        );
1148    }
1149
1150    #[test]
1151    fn originate_success_channel_fallback_skips_when_ambiguous() {
1152        // Two b-leg candidates share the same channel name. The fallback must not
1153        // guess — correctness over coverage.
1154        let lines = vec![
1155            full_line(
1156                UUID2,
1157                TS1,
1158                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1159            ),
1160            full_line(
1161                UUID3,
1162                TS1,
1163                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1164            ),
1165            full_line(
1166                UUID1,
1167                TS2,
1168                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1169            ),
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 a_leg = tracker.sessions().get(UUID1).unwrap();
1176        assert_eq!(
1177            a_leg.other_leg_uuid, None,
1178            "Ambiguous channel name yields no link"
1179        );
1180        assert_eq!(tracker.sessions().get(UUID2).unwrap().other_leg_uuid, None);
1181        assert_eq!(tracker.sessions().get(UUID3).unwrap().other_leg_uuid, None);
1182    }
1183
1184    #[test]
1185    fn originate_success_channel_fallback_skips_terminated_candidates() {
1186        // Two b-leg sessions share the same channel_name, but one is in
1187        // CS_DESTROY (stale prior call on the same registered phone). The
1188        // liveness filter must drop the terminated candidate so the live one
1189        // becomes the unambiguous match.
1190        let lines = vec![
1191            full_line(
1192                UUID2,
1193                TS1,
1194                "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1195            ),
1196            full_line(
1197                UUID2,
1198                TS1,
1199                "(sofia/internal/6244@192.0.2.72:50744) State Change CS_EXECUTE -> CS_DESTROY",
1200            ),
1201            full_line(
1202                UUID3,
1203                TS1,
1204                "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1205            ),
1206            full_line(
1207                UUID1,
1208                TS2,
1209                "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1210            ),
1211        ];
1212        let stream = LogStream::new(lines.into_iter());
1213        let mut tracker = SessionTracker::new(stream);
1214        let _: Vec<_> = tracker.by_ref().collect();
1215
1216        let a_leg = tracker.sessions().get(UUID1).unwrap();
1217        assert_eq!(
1218            a_leg.other_leg_uuid.as_deref(),
1219            Some(UUID3),
1220            "Live b-leg wins over CS_DESTROY straggler"
1221        );
1222
1223        let live_b = tracker.sessions().get(UUID3).unwrap();
1224        assert_eq!(
1225            live_b.other_leg_uuid.as_deref(),
1226            Some(UUID1),
1227            "Live b-leg points back to a-leg"
1228        );
1229
1230        let stale_b = tracker.sessions().get(UUID2).unwrap();
1231        assert_eq!(
1232            stale_b.other_leg_uuid, None,
1233            "Terminated b-leg is not touched"
1234        );
1235    }
1236
1237    #[test]
1238    fn originate_success_channel_fallback_skips_when_no_match() {
1239        // a-leg fires Originate with a bracketed channel name no session has.
1240        // Must not panic, must not create a spurious link.
1241        let lines = vec![full_line(
1242            UUID1,
1243            TS2,
1244            "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1245        )];
1246        let stream = LogStream::new(lines.into_iter());
1247        let mut tracker = SessionTracker::new(stream);
1248        let _: Vec<_> = tracker.by_ref().collect();
1249
1250        let a_leg = tracker.sessions().get(UUID1).unwrap();
1251        assert_eq!(a_leg.other_leg_uuid, None);
1252        assert_eq!(a_leg.pending_bridge_target, None);
1253    }
1254
1255    #[test]
1256    fn bridge_origination_uuid_links_a_leg_immediately() {
1257        // bridge([origination_uuid=BLEG_UUID,...]) guarantees B-leg UUID from execute args alone.
1258        // A-leg knows B-leg immediately, B-leg learns A-leg when New Channel appears.
1259        let lines = vec![
1260            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)"),
1261            full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1262        ];
1263        let stream = LogStream::new(lines.into_iter());
1264        let mut tracker = SessionTracker::new(stream);
1265        let _: Vec<_> = tracker.by_ref().collect();
1266
1267        let a_leg = tracker.sessions().get(UUID1).unwrap();
1268        assert_eq!(
1269            a_leg.other_leg_uuid.as_deref(),
1270            Some(UUID2),
1271            "A-leg knows B-leg UUID from origination_uuid in bridge args"
1272        );
1273
1274        let b_leg = tracker.sessions().get(UUID2).unwrap();
1275        assert_eq!(
1276            b_leg.other_leg_uuid.as_deref(),
1277            Some(UUID1),
1278            "B-leg knows A-leg once New Channel correlates"
1279        );
1280    }
1281
1282    #[test]
1283    fn bridge_target_matches_new_channel() {
1284        // bridge() without origination_uuid — B-leg UUID is auto-generated by FS.
1285        // Match via bridge target channel matching next New Channel with same target.
1286        let lines = vec![
1287            full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1288            full_line(UUID1, TS1, "Parsing session specific variables"),
1289            full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1290        ];
1291        let stream = LogStream::new(lines.into_iter());
1292        let mut tracker = SessionTracker::new(stream);
1293        let _: Vec<_> = tracker.by_ref().collect();
1294
1295        let a_leg = tracker.sessions().get(UUID1).unwrap();
1296        assert_eq!(
1297            a_leg.other_leg_uuid.as_deref(),
1298            Some(UUID2),
1299            "A-leg linked to B-leg via bridge target matching New Channel"
1300        );
1301
1302        let b_leg = tracker.sessions().get(UUID2).unwrap();
1303        assert_eq!(
1304            b_leg.other_leg_uuid.as_deref(),
1305            Some(UUID1),
1306            "B-leg linked back to A-leg"
1307        );
1308    }
1309
1310    #[test]
1311    fn originate_success_corrects_wrong_target_match() {
1312        // Bridge target matching guessed UUID2 as B-leg, but originate success reveals
1313        // the actual B-leg is UUID3. The authoritative success message must override.
1314        let lines = vec![
1315            full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1316            full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1317            full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/gateway/carrier/+15559876543] Peer UUID: c3d4e5f6-a7b8-9012-cdef-234567890123"),
1318        ];
1319        let stream = LogStream::new(lines.into_iter());
1320        let mut tracker = SessionTracker::new(stream);
1321        let _: Vec<_> = tracker.by_ref().collect();
1322
1323        let a_leg = tracker.sessions().get(UUID1).unwrap();
1324        assert_eq!(
1325            a_leg.other_leg_uuid.as_deref(),
1326            Some(UUID3),
1327            "Originate success overrides earlier target-match guess"
1328        );
1329
1330        let real_b_leg = tracker.sessions().get(UUID3).unwrap();
1331        assert_eq!(
1332            real_b_leg.other_leg_uuid.as_deref(),
1333            Some(UUID1),
1334            "Real B-leg points back to A-leg"
1335        );
1336    }
1337
1338    #[test]
1339    fn channel_data_other_leg_uuid() {
1340        // Other-Leg-Unique-ID in CHANNEL_DATA (post-bridge info dump) sets other_leg_uuid
1341        let lines = vec![
1342            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1343            format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1344        ];
1345        let stream = LogStream::new(lines.into_iter());
1346        let mut tracker = SessionTracker::new(stream);
1347        let _: Vec<_> = tracker.by_ref().collect();
1348
1349        let state = tracker.sessions().get(UUID1).unwrap();
1350        assert_eq!(
1351            state.other_leg_uuid.as_deref(),
1352            Some(UUID2),
1353            "other_leg_uuid set from Other-Leg-Unique-ID CHANNEL_DATA field"
1354        );
1355    }
1356
1357    #[test]
1358    fn relink_removes_stale_by_other_leg_entry() {
1359        // B first points at C (Other-Leg-Unique-ID), then an authoritative
1360        // Peer UUID relinks B to A. The superseded C-keyed by_other_leg entry
1361        // must be removed — otherwise a later New Channel on C back-links to
1362        // B and clobbers the authoritative A<->B pair.
1363        let lines = vec![
1364            full_line(UUID2, TS1, "CHANNEL_DATA:"),
1365            format!("{UUID2} Other-Leg-Unique-ID: [{UUID3}]"),
1366            full_line(
1367                UUID1,
1368                TS2,
1369                &format!(
1370                    "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: {UUID2}"
1371                ),
1372            ),
1373            full_line(
1374                UUID3,
1375                TS2,
1376                &format!("New Channel sofia/external/dest@192.0.2.9 [{UUID3}]"),
1377            ),
1378        ];
1379        let stream = LogStream::new(lines.into_iter());
1380        let mut tracker = SessionTracker::new(stream);
1381        let _: Vec<_> = tracker.by_ref().collect();
1382
1383        let a_leg = tracker.sessions().get(UUID1).unwrap();
1384        assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1385
1386        let b_leg = tracker.sessions().get(UUID2).unwrap();
1387        assert_eq!(
1388            b_leg.other_leg_uuid.as_deref(),
1389            Some(UUID1),
1390            "authoritative Peer UUID link must survive the unrelated New Channel"
1391        );
1392
1393        let c_leg = tracker.sessions().get(UUID3).unwrap();
1394        assert_eq!(
1395            c_leg.other_leg_uuid, None,
1396            "New Channel on C must not back-link via the superseded index entry"
1397        );
1398    }
1399
1400    #[test]
1401    fn channel_data_populates_session() {
1402        let lines = vec![
1403            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1404            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1405            format!("{UUID1} Channel-State: [CS_EXECUTE]"),
1406            "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1407            "variable_direction: [inbound]".to_string(),
1408        ];
1409        let entries = collect_enriched(lines);
1410        assert_eq!(entries.len(), 1);
1411        let session = entries[0].session.as_ref().unwrap();
1412        assert_eq!(
1413            session.channel_name.as_deref(),
1414            Some("sofia/internal/+15550001234@192.0.2.1")
1415        );
1416        assert_eq!(session.channel_state.as_deref(), Some("CS_EXECUTE"));
1417    }
1418
1419    #[test]
1420    fn variables_learned_from_channel_data() {
1421        let lines = vec![
1422            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1423            "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1424            "variable_direction: [inbound]".to_string(),
1425        ];
1426        let stream = LogStream::new(lines.into_iter());
1427        let mut tracker = SessionTracker::new(stream);
1428        let _: Vec<_> = tracker.by_ref().collect();
1429        let state = tracker.sessions().get(UUID1).unwrap();
1430        assert_eq!(
1431            state.variables.get("sip_call_id").map(|s| s.as_str()),
1432            Some("test123@192.0.2.1")
1433        );
1434        assert_eq!(
1435            state.variables.get("direction").map(|s| s.as_str()),
1436            Some("inbound")
1437        );
1438    }
1439
1440    #[test]
1441    fn typed_variable_accessor() {
1442        let lines = vec![
1443            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1444            "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1445        ];
1446        let stream = LogStream::new(lines.into_iter());
1447        let mut tracker = SessionTracker::new(stream);
1448        let _: Vec<_> = tracker.by_ref().collect();
1449        let state = tracker.sessions().get(UUID1).unwrap();
1450        assert_eq!(
1451            state.variable(SofiaVariable::SipCallId),
1452            Some("test123@192.0.2.1")
1453        );
1454        assert_eq!(state.variable(ChannelVariable::Direction), None);
1455    }
1456
1457    #[test]
1458    fn multi_line_variable_survives_attached_rescan() {
1459        // The block carries the reassembled multi-line value; re-scanning the
1460        // raw attached opening fragment must not clobber it back to "v=0".
1461        let lines = vec![
1462            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1463            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1464            format!("{UUID1} variable_switch_r_sdp: [v=0"),
1465            "o=FreeSWITCH 1737000000 1737000001 IN IP4 192.0.2.10".to_string(),
1466            "s=FreeSWITCH".to_string(),
1467            "c=IN IP4 192.0.2.10".to_string(),
1468            "m=audio 30000 RTP/AVP 0 101".to_string(),
1469            "]".to_string(),
1470            format!("{UUID1} variable_direction: [inbound]"),
1471        ];
1472        let stream = LogStream::new(lines.into_iter());
1473        let mut tracker = SessionTracker::new(stream);
1474        let _: Vec<_> = tracker.by_ref().collect();
1475
1476        let state = tracker.sessions().get(UUID1).unwrap();
1477        let sdp = state
1478            .variables
1479            .get("switch_r_sdp")
1480            .expect("switch_r_sdp variable present");
1481        assert!(
1482            sdp.contains('\n'),
1483            "expected full reassembled value, got fragment: {sdp:?}"
1484        );
1485        assert!(sdp.starts_with("v=0\n"));
1486        assert!(sdp.contains("m=audio 30000 RTP/AVP 0 101"));
1487        assert_eq!(
1488            state.variables.get("direction").map(|s| s.as_str()),
1489            Some("inbound")
1490        );
1491        assert_eq!(
1492            state.channel_name.as_deref(),
1493            Some("sofia/internal/+15550001234@192.0.2.1")
1494        );
1495    }
1496
1497    #[test]
1498    fn attached_processing_line_updates_context() {
1499        // Format C continuation: a `Processing ...` line attached under a
1500        // primary entry must update dialplan context like the primary path.
1501        let lines = vec![
1502            full_line(UUID1, TS1, "Ring-Ready sofia/internal-v4/sos!"),
1503            format!(
1504                "{UUID1} Processing Extension 1263 <1263>->start_recording in context recordings"
1505            ),
1506        ];
1507        let stream = LogStream::new(lines.into_iter());
1508        let mut tracker = SessionTracker::new(stream);
1509        let _: Vec<_> = tracker.by_ref().collect();
1510
1511        let state = tracker.sessions().get(UUID1).unwrap();
1512        assert_eq!(state.dialplan_context.as_deref(), Some("recordings"));
1513        assert_eq!(
1514            state.dialplan_from.as_deref(),
1515            Some("Extension 1263 <1263>")
1516        );
1517        assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1518        assert_eq!(
1519            state.initial_destination.as_deref(),
1520            Some("start_recording")
1521        );
1522    }
1523
1524    #[test]
1525    fn variables_learned_from_set_execute() {
1526        let lines = vec![
1527            full_line(UUID1, TS1, "First"),
1528            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(call_direction=inbound)"),
1529            full_line(UUID1, TS2, "After set"),
1530        ];
1531        let stream = LogStream::new(lines.into_iter());
1532        let mut tracker = SessionTracker::new(stream);
1533        let entries: Vec<_> = tracker.by_ref().collect();
1534        assert_eq!(entries.len(), 3);
1535        let state = tracker.sessions().get(UUID1).unwrap();
1536        assert_eq!(
1537            state.variables.get("call_direction").map(|s| s.as_str()),
1538            Some("inbound")
1539        );
1540    }
1541
1542    #[test]
1543    fn variables_learned_from_export_execute() {
1544        let lines = vec![
1545            full_line(UUID1, TS1, "First"),
1546            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)"),
1547        ];
1548        let stream = LogStream::new(lines.into_iter());
1549        let mut tracker = SessionTracker::new(stream);
1550        let _: Vec<_> = tracker.by_ref().collect();
1551        let state = tracker.sessions().get(UUID1).unwrap();
1552        assert_eq!(
1553            state.variables.get("originate_timeout").map(|s| s.as_str()),
1554            Some("3600")
1555        );
1556    }
1557
1558    #[test]
1559    fn session_isolation_between_uuids() {
1560        let lines = vec![
1561            full_line(
1562                UUID1,
1563                TS1,
1564                "Processing 5551111111->5552222222 in context public",
1565            ),
1566            full_line(
1567                UUID2,
1568                TS2,
1569                "Processing 5553333333->5554444444 in context private",
1570            ),
1571        ];
1572        let stream = LogStream::new(lines.into_iter());
1573        let mut tracker = SessionTracker::new(stream);
1574        let _: Vec<_> = tracker.by_ref().collect();
1575        let s1 = tracker.sessions().get(UUID1).unwrap();
1576        let s2 = tracker.sessions().get(UUID2).unwrap();
1577        assert_eq!(s1.dialplan_context.as_deref(), Some("public"));
1578        assert_eq!(s2.dialplan_context.as_deref(), Some("private"));
1579        assert_eq!(s1.dialplan_from.as_deref(), Some("5551111111"));
1580        assert_eq!(s2.dialplan_from.as_deref(), Some("5553333333"));
1581    }
1582
1583    #[test]
1584    fn processing_line_with_regex_type_and_angle_bracket_caller() {
1585        let lines = vec![full_line(
1586            UUID1,
1587            TS1,
1588            "Processing Emergency S R <5550001234>->start_recording in context recordings",
1589        )];
1590        let entries = collect_enriched(lines);
1591        let session = entries[0].session.as_ref().unwrap();
1592        assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1593        assert_eq!(session.dialplan_context.as_deref(), Some("recordings"));
1594        assert_eq!(
1595            session.dialplan_from.as_deref(),
1596            Some("Emergency S R <5550001234>")
1597        );
1598        assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1599    }
1600
1601    #[test]
1602    fn processing_line_extension_format() {
1603        let lines = vec![full_line(
1604            UUID1,
1605            TS1,
1606            "Processing Extension 1263 <1263>->start_recording in context recordings",
1607        )];
1608        let entries = collect_enriched(lines);
1609        let session = entries[0].session.as_ref().unwrap();
1610        assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1611        assert_eq!(
1612            session.dialplan_from.as_deref(),
1613            Some("Extension 1263 <1263>")
1614        );
1615        assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1616    }
1617
1618    #[test]
1619    fn parse_processing_line_anchors_on_last_arrow() {
1620        let dest = |msg: &str| parse_processing_line(msg).map(|dp| dp.to);
1621        assert_eq!(
1622            dest("Processing Anonymous <anonymous>->5550001234 in context public").as_deref(),
1623            Some("5550001234"),
1624        );
1625        assert_eq!(
1626            dest("Processing 5550009999 <5550009999>->5550001234 in context public").as_deref(),
1627            Some("5550001234"),
1628        );
1629        assert_eq!(
1630            dest("Processing Jane Doe <5550009999>->5550001234 in context internal").as_deref(),
1631            Some("5550001234"),
1632        );
1633        // Hostile caller_id_name containing `->` must not be mistaken for the boundary.
1634        assert_eq!(
1635            dest("Processing Weird -> Name <5550009999>->5550001234 in context internal")
1636                .as_deref(),
1637            Some("5550001234"),
1638        );
1639        // Feature-context destination is non-numeric but still parsed.
1640        assert_eq!(
1641            dest("Processing Jane Doe <5550009999>->start_recording in context features")
1642                .as_deref(),
1643            Some("start_recording"),
1644        );
1645    }
1646
1647    #[test]
1648    fn initial_destination_first_wins() {
1649        let lines = vec![
1650            full_line(
1651                UUID1,
1652                TS1,
1653                "Processing Jane Doe <5550009999>->5550001234 in context public",
1654            ),
1655            full_line(
1656                UUID1,
1657                TS2,
1658                "Processing Jane Doe <5550009999>->5550001234 in context transit",
1659            ),
1660            full_line(
1661                UUID1,
1662                TS2,
1663                "Processing Jane Doe <5550009999>->start_recording in context features",
1664            ),
1665            full_line(
1666                UUID1,
1667                TS2,
1668                "Processing Jane Doe <5550009999>->check_end_call in context features",
1669            ),
1670        ];
1671        let stream = LogStream::new(lines.into_iter());
1672        let mut tracker = SessionTracker::new(stream);
1673        let _: Vec<_> = tracker.by_ref().collect();
1674
1675        let state = tracker.sessions().get(UUID1).unwrap();
1676        assert_eq!(
1677            state.initial_destination.as_deref(),
1678            Some("5550001234"),
1679            "initial_destination keeps the dialed number from the first Processing line"
1680        );
1681        assert_eq!(
1682            state.dialplan_to.as_deref(),
1683            Some("check_end_call"),
1684            "dialplan_to is last-wins and gets clobbered by feature-context routing"
1685        );
1686    }
1687
1688    #[test]
1689    fn state_change_updates_channel_state() {
1690        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1691        let entries = collect_enriched(lines);
1692        let session = entries[0].session.as_ref().unwrap();
1693        assert_eq!(session.channel_state.as_deref(), Some("CS_ROUTING"));
1694    }
1695
1696    #[test]
1697    fn callstate_change_updates_channel_state() {
1698        let lines = vec![full_line(
1699            UUID1,
1700            TS1,
1701            "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1702        )];
1703        let entries = collect_enriched(lines);
1704        let session = entries[0].session.as_ref().unwrap();
1705        assert_eq!(session.channel_state.as_deref(), Some("RINGING"));
1706    }
1707
1708    #[test]
1709    fn state_change_overrides_callstate() {
1710        let lines = vec![
1711            full_line(
1712                UUID1,
1713                TS1,
1714                "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1715            ),
1716            full_line(
1717                UUID1,
1718                TS2,
1719                "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1720            ),
1721        ];
1722        let entries = collect_enriched(lines);
1723        assert_eq!(
1724            entries[0]
1725                .session
1726                .as_ref()
1727                .unwrap()
1728                .channel_state
1729                .as_deref(),
1730            Some("RINGING")
1731        );
1732        assert_eq!(
1733            entries[1]
1734                .session
1735                .as_ref()
1736                .unwrap()
1737                .channel_state
1738                .as_deref(),
1739            Some("CS_EXCHANGE_MEDIA")
1740        );
1741    }
1742
1743    #[test]
1744    fn bleg_lifecycle_extracts_data_from_processing() {
1745        let lines = vec![
1746            full_line(
1747                UUID1,
1748                TS1,
1749                "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1750            ),
1751            full_line(
1752                UUID1,
1753                TS1,
1754                "(sofia/internal-v4/sos) State Change CS_NEW -> CS_INIT",
1755            ),
1756            full_line(
1757                UUID1,
1758                TS1,
1759                "(sofia/internal-v4/sos) State Change CS_INIT -> CS_ROUTING",
1760            ),
1761            full_line(
1762                UUID1,
1763                TS1,
1764                "(sofia/internal-v4/sos) State Change CS_ROUTING -> CS_CONSUME_MEDIA",
1765            ),
1766            full_line(
1767                UUID1,
1768                TS1,
1769                "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1770            ),
1771            full_line(
1772                UUID1,
1773                TS2,
1774                "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1775            ),
1776            full_line(
1777                UUID1,
1778                TS2,
1779                "Processing Emergency S R <5550001234>->start_recording in context recordings",
1780            ),
1781            full_line(
1782                UUID1,
1783                TS2,
1784                "(sofia/internal-v4/sos) State Change CS_EXCHANGE_MEDIA -> CS_HANGUP",
1785            ),
1786        ];
1787        let entries = collect_enriched(lines);
1788
1789        let after_ringing = entries[4].session.as_ref().unwrap();
1790        assert_eq!(after_ringing.channel_state.as_deref(), Some("RINGING"));
1791        assert!(after_ringing.initial_context.is_none());
1792
1793        let after_processing = entries[6].session.as_ref().unwrap();
1794        assert_eq!(
1795            after_processing.channel_state.as_deref(),
1796            Some("CS_EXCHANGE_MEDIA")
1797        );
1798        assert_eq!(
1799            after_processing.initial_context.as_deref(),
1800            Some("recordings")
1801        );
1802        assert_eq!(
1803            after_processing.dialplan_from.as_deref(),
1804            Some("Emergency S R <5550001234>")
1805        );
1806        assert_eq!(
1807            after_processing.dialplan_to.as_deref(),
1808            Some("start_recording")
1809        );
1810
1811        let after_hangup = entries[7].session.as_ref().unwrap();
1812        assert_eq!(after_hangup.channel_state.as_deref(), Some("CS_HANGUP"));
1813        assert_eq!(after_hangup.initial_context.as_deref(), Some("recordings"));
1814    }
1815
1816    #[test]
1817    fn channel_name_from_new_channel() {
1818        let lines = vec![full_line(
1819            UUID1,
1820            TS1,
1821            "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1822        )];
1823        let entries = collect_enriched(lines);
1824        let session = entries[0].session.as_ref().unwrap();
1825        assert_eq!(
1826            session.channel_name.as_deref(),
1827            Some("sofia/internal-v4/sos")
1828        );
1829    }
1830
1831    #[test]
1832    fn remove_session() {
1833        let lines = vec![full_line(
1834            UUID1,
1835            TS1,
1836            "Processing 5551111111->5552222222 in context public",
1837        )];
1838        let stream = LogStream::new(lines.into_iter());
1839        let mut tracker = SessionTracker::new(stream);
1840        let _: Vec<_> = tracker.by_ref().collect();
1841        assert!(tracker.sessions().contains_key(UUID1));
1842        let removed = tracker.remove_session(UUID1).unwrap();
1843        assert_eq!(removed.dialplan_context.as_deref(), Some("public"));
1844        assert!(!tracker.sessions().contains_key(UUID1));
1845    }
1846
1847    #[test]
1848    fn stats_delegation() {
1849        let lines = vec![
1850            full_line(UUID1, TS1, "First"),
1851            full_line(UUID1, TS2, "Second"),
1852        ];
1853        let stream = LogStream::new(lines.into_iter());
1854        let mut tracker = SessionTracker::new(stream);
1855        let _: Vec<_> = tracker.by_ref().collect();
1856        assert_eq!(tracker.stats().lines_processed, 2);
1857    }
1858
1859    #[test]
1860    fn snapshot_reflects_cumulative_state() {
1861        let lines = vec![
1862            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1863            format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1864            format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"),
1865            full_line(
1866                UUID1,
1867                TS2,
1868                "Processing 5551111111->5552222222 in context public",
1869            ),
1870        ];
1871        let entries = collect_enriched(lines);
1872        assert_eq!(entries.len(), 3);
1873        let first = entries[0].session.as_ref().unwrap();
1874        assert_eq!(
1875            first.channel_name.as_deref(),
1876            Some("sofia/internal/+15550001234@192.0.2.1"),
1877        );
1878        assert!(first.dialplan_context.is_none());
1879
1880        let last = entries[2].session.as_ref().unwrap();
1881        assert_eq!(
1882            last.channel_name.as_deref(),
1883            Some("sofia/internal/+15550001234@192.0.2.1"),
1884        );
1885        assert_eq!(last.dialplan_context.as_deref(), Some("public"));
1886    }
1887
1888    #[test]
1889    fn post_hook_sets_other_leg_uuid() {
1890        let lines = vec![
1891            full_line(UUID1, TS1, "First entry"),
1892            format!(
1893                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2} Job-UUID: ...)"
1894            ),
1895        ];
1896        let stream = LogStream::new(lines.into_iter());
1897        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1898            if let MessageKind::Execute {
1899                application,
1900                arguments,
1901                ..
1902            } = &entry.message_kind
1903            {
1904                if application == "set" {
1905                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1906                        let uuid = value.split_whitespace().next().unwrap_or("");
1907                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1908                            state.other_leg_uuid = Some(uuid.to_string());
1909                        }
1910                    }
1911                }
1912            }
1913        });
1914
1915        let entries: Vec<_> = tracker.by_ref().collect();
1916        assert_eq!(entries.len(), 2);
1917
1918        let session = entries[1].session.as_ref().unwrap();
1919        assert_eq!(
1920            session.other_leg_uuid.as_deref(),
1921            Some(UUID2),
1922            "post_hook should detect uuid_bridge API result"
1923        );
1924    }
1925
1926    #[test]
1927    fn post_hook_does_not_override_builtin() {
1928        let lines = vec![
1929            full_line(UUID1, TS1, "CHANNEL_DATA:"),
1930            format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1931            format!(
1932                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID3} Job-UUID: ...)"
1933            ),
1934        ];
1935        let stream = LogStream::new(lines.into_iter());
1936        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1937            if let MessageKind::Execute {
1938                application,
1939                arguments,
1940                ..
1941            } = &entry.message_kind
1942            {
1943                if application == "set" {
1944                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1945                        let uuid = value.split_whitespace().next().unwrap_or("");
1946                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1947                            state.other_leg_uuid = Some(uuid.to_string());
1948                        }
1949                    }
1950                }
1951            }
1952        });
1953
1954        let entries: Vec<_> = tracker.by_ref().collect();
1955        assert_eq!(entries.len(), 2);
1956
1957        let session = entries[1].session.as_ref().unwrap();
1958        assert_eq!(
1959            session.other_leg_uuid.as_deref(),
1960            Some(UUID2),
1961            "built-in Other-Leg-Unique-ID takes precedence over hook"
1962        );
1963    }
1964
1965    #[test]
1966    fn pre_hook_runs_before_builtin() {
1967        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1968        let stream = LogStream::new(lines.into_iter());
1969        let mut tracker = SessionTracker::new(stream).with_pre_hook(|_entry, state| {
1970            state.channel_state = Some("PRE_SET".to_string());
1971        });
1972        let entries: Vec<_> = tracker.by_ref().collect();
1973        assert_eq!(
1974            entries[0]
1975                .session
1976                .as_ref()
1977                .unwrap()
1978                .channel_state
1979                .as_deref(),
1980            Some("CS_ROUTING"),
1981            "built-in overwrites pre_hook value when no guard"
1982        );
1983    }
1984
1985    #[test]
1986    fn post_hook_runs_after_builtin() {
1987        let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1988        let stream = LogStream::new(lines.into_iter());
1989        let mut tracker = SessionTracker::new(stream).with_post_hook(|_entry, state| {
1990            if state.channel_state.as_deref() == Some("CS_ROUTING") {
1991                state
1992                    .variables
1993                    .insert("routing_seen".to_string(), "true".to_string());
1994            }
1995        });
1996        let _: Vec<_> = tracker.by_ref().collect();
1997        let state = tracker.sessions().get(UUID1).unwrap();
1998        assert_eq!(
1999            state.variables.get("routing_seen").map(|s| s.as_str()),
2000            Some("true"),
2001            "post_hook can read fields set by built-in"
2002        );
2003    }
2004
2005    #[test]
2006    fn post_hook_other_leg_uuid_maintains_index_for_backlink() {
2007        // A hook-set other_leg_uuid must reach by_other_leg so the B-leg's
2008        // later New Channel back-links to the A-leg like built-in sources do.
2009        let lines = vec![
2010            full_line(UUID1, TS1, "First entry"),
2011            format!(
2012                "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2})"
2013            ),
2014            full_line(
2015                UUID2,
2016                TS2,
2017                "New Channel sofia/internal/target@192.0.2.9 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
2018            ),
2019        ];
2020        let stream = LogStream::new(lines.into_iter());
2021        let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
2022            if let MessageKind::Execute {
2023                application,
2024                arguments,
2025                ..
2026            } = &entry.message_kind
2027            {
2028                if application == "set" {
2029                    if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
2030                        let uuid = value.split_whitespace().next().unwrap_or("");
2031                        if uuid.len() == 36 && state.other_leg_uuid.is_none() {
2032                            state.other_leg_uuid = Some(uuid.to_string());
2033                        }
2034                    }
2035                }
2036            }
2037        });
2038        let _: Vec<_> = tracker.by_ref().collect();
2039
2040        let a_leg = tracker.sessions().get(UUID1).unwrap();
2041        assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
2042
2043        let b_leg = tracker.sessions().get(UUID2).unwrap();
2044        assert_eq!(
2045            b_leg.other_leg_uuid.as_deref(),
2046            Some(UUID1),
2047            "B-leg back-links via by_other_leg index populated by the hook"
2048        );
2049    }
2050
2051    #[test]
2052    fn pre_hook_channel_name_maintains_index_for_originate_fallback() {
2053        // A hook-set channel_name must reach by_channel_name so the
2054        // originate-success channel-name fallback can find the B-leg.
2055        let lines = vec![
2056            full_line(UUID2, TS1, "custom-channel-announce sofia/custom/6244"),
2057            full_line(
2058                UUID1,
2059                TS2,
2060                "Originate Resulted in Success: [sofia/custom/6244]",
2061            ),
2062        ];
2063        let stream = LogStream::new(lines.into_iter());
2064        let mut tracker = SessionTracker::new(stream).with_pre_hook(|entry, state| {
2065            if let Some(chan) = entry.message.strip_prefix("custom-channel-announce ") {
2066                state.channel_name = Some(chan.to_string());
2067            }
2068        });
2069        let _: Vec<_> = tracker.by_ref().collect();
2070
2071        let a_leg = tracker.sessions().get(UUID1).unwrap();
2072        assert_eq!(
2073            a_leg.other_leg_uuid.as_deref(),
2074            Some(UUID2),
2075            "fallback finds hook-named B-leg via by_channel_name index"
2076        );
2077        let b_leg = tracker.sessions().get(UUID2).unwrap();
2078        assert_eq!(b_leg.other_leg_uuid.as_deref(), Some(UUID1));
2079    }
2080
2081    #[test]
2082    fn parse_hangup_extracts_cause() {
2083        assert_eq!(
2084            parse_hangup("Hangup sofia/internal/1234 [NORMAL_CLEARING]"),
2085            Some("NORMAL_CLEARING".to_string())
2086        );
2087        assert_eq!(
2088            parse_hangup("Hangup sofia/internal/1234 [USER_BUSY]"),
2089            Some("USER_BUSY".to_string())
2090        );
2091        assert_eq!(parse_hangup("Some other message"), None);
2092        assert_eq!(parse_hangup("New Channel sofia/internal/1234 [uuid]"), None);
2093    }
2094
2095    #[test]
2096    fn is_answered_detects_answer_event() {
2097        assert!(is_answered("sofia/internal/1234 has been answered"));
2098        assert!(!is_answered("sofia/internal/1234 is ringing"));
2099        assert!(!is_answered("New Channel sofia/internal/1234"));
2100    }
2101
2102    #[test]
2103    fn hangup_cause_from_lifecycle() {
2104        let lines = vec![full_line(
2105            UUID1,
2106            TS1,
2107            "Hangup sofia/internal/+15550001234@192.0.2.1 [NORMAL_CLEARING]",
2108        )];
2109        let entries = collect_enriched(lines);
2110        let session = entries[0].session.as_ref().unwrap();
2111        assert_eq!(
2112            session.hangup_cause.as_deref(),
2113            Some("NORMAL_CLEARING"),
2114            "hangup_cause extracted from ChannelLifecycle Hangup"
2115        );
2116    }
2117
2118    #[test]
2119    fn answered_at_from_lifecycle() {
2120        let lines = vec![full_line(
2121            UUID1,
2122            TS1,
2123            "sofia/internal/+15550001234@192.0.2.1 has been answered",
2124        )];
2125        let entries = collect_enriched(lines);
2126        let session = entries[0].session.as_ref().unwrap();
2127        assert_eq!(
2128            session.answered_at.as_deref(),
2129            Some(TS1),
2130            "answered_at captures timestamp when 'has been answered' seen"
2131        );
2132    }
2133
2134    #[test]
2135    fn answered_at_not_overwritten() {
2136        let lines = vec![
2137            full_line(
2138                UUID1,
2139                TS1,
2140                "sofia/internal/+15550001234@192.0.2.1 has been answered",
2141            ),
2142            full_line(
2143                UUID1,
2144                TS2,
2145                "sofia/internal/+15550001234@192.0.2.1 has been answered",
2146            ),
2147        ];
2148        let entries = collect_enriched(lines);
2149        let session = entries[1].session.as_ref().unwrap();
2150        assert_eq!(
2151            session.answered_at.as_deref(),
2152            Some(TS1),
2153            "answered_at preserves first answer timestamp"
2154        );
2155    }
2156
2157    #[test]
2158    fn caller_id_name_from_channel_data() {
2159        let lines = vec![
2160            full_line(UUID1, TS1, "CHANNEL_DATA:"),
2161            format!("{UUID1} Caller-Caller-ID-Name: [Test Caller Name]"),
2162        ];
2163        let entries = collect_enriched(lines);
2164        let session = entries[0].session.as_ref().unwrap();
2165        assert_eq!(
2166            session.caller_id_name.as_deref(),
2167            Some("Test Caller Name"),
2168            "caller_id_name extracted from CHANNEL_DATA"
2169        );
2170    }
2171
2172    fn track(lines: Vec<String>) -> SessionTracker<std::vec::IntoIter<String>> {
2173        let mut tracker = SessionTracker::new(LogStream::new(lines.into_iter()));
2174        for _ in tracker.by_ref() {}
2175        tracker
2176    }
2177
2178    #[test]
2179    fn conference_execute_shares_one_instance() {
2180        let tracker = track(vec![
2181            format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2182            format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2183            format!("{UUID3} EXECUTE [depth=0] sofia/internal/1001 conference(844)"),
2184        ]);
2185
2186        let first = tracker.sessions()[UUID1].conference.clone().unwrap();
2187        let second = tracker.sessions()[UUID2].conference.clone().unwrap();
2188        let other = tracker.sessions()[UUID3].conference.clone().unwrap();
2189
2190        assert_eq!(first.name, "835");
2191        assert_eq!(first.instance, UUID1, "first joiner names the instance");
2192        assert_eq!(second.instance, UUID1);
2193        assert_eq!(other.name, "844");
2194        assert_ne!(other.instance, first.instance);
2195
2196        let mut members: Vec<&str> = tracker.conference_members(&first.instance).collect();
2197        members.sort_unstable();
2198        assert_eq!(members, [UUID1, UUID2]);
2199    }
2200
2201    #[test]
2202    fn conference_transfer_line_joins() {
2203        let tracker = track(vec![full_line(
2204            UUID1,
2205            TS1,
2206            "Transfer loopback/tty-a to inline[conference:835@default]",
2207        )]);
2208        let membership = tracker.sessions()[UUID1].conference.clone().unwrap();
2209        assert_eq!(membership.name, "835");
2210        assert_eq!(
2211            membership.profile, None,
2212            "the transfer context is not a conference profile"
2213        );
2214    }
2215
2216    #[test]
2217    fn conference_variables_fill_member_id() {
2218        let tracker = track(vec![
2219            full_line(UUID1, TS1, "CHANNEL_DATA:"),
2220            format!("{UUID1} variable_conference_name: [835]"),
2221            format!("{UUID1} variable_conference_member_id: [3]"),
2222            format!("{UUID1} variable_conference_uuid: [{UUID2}]"),
2223            full_line(UUID1, TS2, "later"),
2224        ]);
2225        let membership = tracker.sessions()[UUID1].conference.clone().unwrap();
2226        assert_eq!(membership.name, "835");
2227        assert_eq!(membership.member_id, Some(3));
2228        assert_eq!(membership.conference_uuid.as_deref(), Some(UUID2));
2229    }
2230
2231    #[test]
2232    fn reused_name_after_the_last_leave_is_a_new_instance() {
2233        let tracker = track(vec![
2234            format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2235            full_line(
2236                UUID1,
2237                TS1,
2238                "Channel leaving conference, cause: NORMAL_CLEARING",
2239            ),
2240            format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2241        ]);
2242
2243        assert!(
2244            tracker.sessions()[UUID1].conference.is_none(),
2245            "leaving clears the membership"
2246        );
2247        let rejoined = tracker.sessions()[UUID2].conference.clone().unwrap();
2248        assert_eq!(rejoined.instance, UUID2);
2249    }
2250
2251    #[test]
2252    fn media_keeps_the_outcome_and_the_deduped_offer_set() {
2253        let tracker = track(vec![
2254            full_line(
2255                UUID1,
2256                TS1,
2257                "Audio Codec Compare [opus:102:16000:20:0:1]/[G722:9:16000:20:64000:1]",
2258            ),
2259            full_line(
2260                UUID1,
2261                TS1,
2262                "Audio Codec Compare [opus:102:16000:20:0:1]/[opus:116:16000:20:0:1]",
2263            ),
2264            full_line(
2265                UUID1,
2266                TS1,
2267                "Audio Codec Compare [opus:116:16000:20:0:1] ++++ is saved as a match",
2268            ),
2269            full_line(UUID1, TS2, "Video Codec Compare [H264:109]/[H263:34]"),
2270            full_line(
2271                UUID1,
2272                TS2,
2273                "Video Codec Compare [H264:109] +++ is saved as a match",
2274            ),
2275            full_line(
2276                UUID1,
2277                TS2,
2278                "Set Codec sofia/internal/1000 opus/16000 20 ms 320 samples 0 bits 1 channels",
2279            ),
2280            full_line(
2281                UUID1,
2282                TS2,
2283                "sofia/internal/1000 Original read codec set to opus:116",
2284            ),
2285        ]);
2286        let media = &tracker.sessions()[UUID1].media;
2287
2288        assert_eq!(
2289            media.audio.offered.len(),
2290            1,
2291            "the same remote offer compared twice is one entry: {:?}",
2292            media.audio.offered
2293        );
2294        assert_eq!(media.audio.negotiated.as_ref().unwrap().name, "opus");
2295        assert_eq!(media.audio.negotiated.as_ref().unwrap().payload_type, 116);
2296
2297        assert_eq!(media.video.negotiated.as_ref().unwrap().name, "H264");
2298        assert!(
2299            media.audio.negotiated != media.video.negotiated,
2300            "audio and video outcomes are tracked apart"
2301        );
2302
2303        assert_eq!(media.read_codec.as_ref().unwrap().payload_type, 116);
2304        assert_eq!(media.active_audio.as_ref().unwrap().clock_rate, Some(16000));
2305    }
2306
2307    #[test]
2308    fn loopback_b_leg_links_to_its_a_leg() {
2309        let tracker = track(vec![
2310            full_line(UUID1, TS1, "New Channel loopback/tty-a [ignored]"),
2311            full_line(UUID2, TS1, "New Channel loopback/tty-b [ignored]"),
2312        ]);
2313        assert_eq!(
2314            tracker.sessions()[UUID1].other_leg_uuid.as_deref(),
2315            Some(UUID2)
2316        );
2317        assert_eq!(
2318            tracker.sessions()[UUID2].other_leg_uuid.as_deref(),
2319            Some(UUID1)
2320        );
2321    }
2322
2323    #[test]
2324    fn concurrent_loopbacks_to_one_destination_do_not_link() {
2325        let tracker = track(vec![
2326            full_line(UUID1, TS1, "New Channel loopback/tty-a [ignored]"),
2327            full_line(UUID2, TS1, "New Channel loopback/tty-a [ignored]"),
2328            full_line(UUID3, TS1, "New Channel loopback/tty-b [ignored]"),
2329        ]);
2330        assert!(
2331            tracker.sessions()[UUID3].other_leg_uuid.is_none(),
2332            "two live A legs share the name; picking one would be a guess"
2333        );
2334    }
2335
2336    #[test]
2337    fn a_member_still_present_holds_the_instance_open() {
2338        let tracker = track(vec![
2339            format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2340            format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2341            full_line(
2342                UUID1,
2343                TS1,
2344                "Channel leaving conference, cause: NORMAL_CLEARING",
2345            ),
2346            format!("{UUID3} EXECUTE [depth=0] sofia/internal/1001 conference(835)"),
2347        ]);
2348        assert_eq!(
2349            tracker.sessions()[UUID3]
2350                .conference
2351                .as_ref()
2352                .unwrap()
2353                .instance,
2354            UUID1
2355        );
2356    }
2357}