Skip to main content

freeswitch_log_parser/
session.rs

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