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