Skip to main content

manasight_parser/parsers/
session.rs

1//! Session event parser: login and logout.
2//!
3//! Recognizes two log signatures that establish and terminate player
4//! identity within a session:
5//!
6//! | Signature | Meaning |
7//! |-----------|---------|
8//! | `authenticateResponse` | Login confirmation (screen name in JSON) |
9//! | `FrontDoorConnection.Close` | Logout / disconnect |
10//!
11//! These are the first meaningful events in any log and are used to tag
12//! all subsequent events with the active player identity.
13
14use crate::events::{EventMetadata, GameEvent, SessionEvent};
15use crate::log::entry::LogEntry;
16use crate::parsers::api_common;
17
18/// Marker for authentication response entries.
19const AUTHENTICATE_RESPONSE_MARKER: &str = "authenticateResponse";
20
21/// Marker for front door connection close (logout/disconnect).
22const FRONT_DOOR_CLOSE_MARKER: &str = "FrontDoorConnection.Close";
23
24/// Attempts to parse a [`LogEntry`] as a session event.
25///
26/// Returns `Some(GameEvent::Session(_))` if the entry matches one of the
27/// two recognized session signatures, or `None` if the entry is not a
28/// session event.
29///
30/// The `timestamp` is `None` when the log entry header did not contain a
31/// parseable timestamp. It is passed through to [`EventMetadata`] so
32/// downstream consumers can distinguish real vs missing timestamps.
33pub fn try_parse(
34    entry: &LogEntry,
35    timestamp: Option<chrono::DateTime<chrono::Utc>>,
36) -> Option<GameEvent> {
37    let body = &entry.body;
38
39    // Strip the header prefix (e.g., "[UnityCrossThreadLogger]") to get
40    // the content portion of the first line.
41    let content = strip_header_prefix(body);
42
43    if let Some(payload) = try_parse_authenticate_response(body) {
44        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
45        return Some(GameEvent::Session(SessionEvent::new(metadata, payload)));
46    }
47
48    if try_match_front_door_close(content) {
49        let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
50        let payload = serde_json::json!({
51            "type": "session_disconnect",
52        });
53        return Some(GameEvent::Session(SessionEvent::new(metadata, payload)));
54    }
55
56    None
57}
58
59/// Strips the `[UnityCrossThreadLogger]` bracket prefix from the first line
60/// of the body, returning the remaining content.
61///
62/// If the body does not start with a recognized bracket prefix, returns
63/// the full body unchanged.
64fn strip_header_prefix(body: &str) -> &str {
65    // The first line contains the header. Find the closing bracket.
66    let first_line = body.lines().next().unwrap_or(body);
67    if let Some(pos) = first_line.find(']') {
68        first_line[pos + 1..].trim_start()
69    } else {
70        first_line
71    }
72}
73
74/// Attempts to parse an `authenticateResponse` entry.
75///
76/// The authenticate response can appear in two forms:
77///
78/// 1. As a label on the first line, with a JSON body on subsequent lines
79///    containing a `screenName` field.
80/// 2. As a key within a JSON payload on the first or subsequent lines.
81///
82/// In either case, the function extracts the `screenName` from the JSON
83/// and returns a payload with `type: "session_authenticate"`.
84fn try_parse_authenticate_response(full_body: &str) -> Option<serde_json::Value> {
85    // Check full_body (which includes all lines) for the marker.
86    if !full_body.contains(AUTHENTICATE_RESPONSE_MARKER) {
87        return None;
88    }
89
90    // Try to extract JSON from the body (lines after the header line).
91    let json_body = api_common::extract_json_from_body(full_body);
92
93    if let Some(json_str) = json_body {
94        match serde_json::from_str::<serde_json::Value>(json_str) {
95            Ok(parsed) => {
96                // Look for screenName at the top level or nested in the response.
97                let screen_name = find_screen_name(&parsed);
98                return Some(serde_json::json!({
99                    "type": "session_authenticate",
100                    "screen_name": screen_name.unwrap_or_default(),
101                    "raw_response": parsed,
102                }));
103            }
104            Err(e) => {
105                ::log::warn!(
106                    "authenticateResponse: malformed JSON body, falling back to empty screen_name: {e}"
107                );
108            }
109        }
110    }
111
112    // If no JSON body found or JSON was malformed, emit a simpler payload.
113    Some(serde_json::json!({
114        "type": "session_authenticate",
115        "screen_name": "",
116    }))
117}
118
119/// Returns `true` if the content matches a `FrontDoorConnection.Close` entry.
120fn try_match_front_door_close(content: &str) -> bool {
121    content.contains(FRONT_DOOR_CLOSE_MARKER)
122}
123
124/// Recursively searches a JSON value for a `screenName` field.
125///
126/// Checks the top level and one level of nesting (common in MTGA
127/// authenticate responses).
128fn find_screen_name(value: &serde_json::Value) -> Option<String> {
129    // Check top-level.
130    if let Some(name) = value.get("screenName").and_then(|v| v.as_str()) {
131        return Some(name.to_owned());
132    }
133
134    // Check one level of nesting (e.g., `{"authenticateResponse": {"screenName": ...}}`).
135    if let Some(obj) = value.as_object() {
136        for (_key, nested) in obj {
137            if let Some(name) = nested.get("screenName").and_then(|v| v.as_str()) {
138                return Some(name.to_owned());
139            }
140        }
141    }
142
143    None
144}
145
146// ---------------------------------------------------------------------------
147// Tests
148// ---------------------------------------------------------------------------
149
150#[cfg(test)]
151#[allow(deprecated)]
152mod tests {
153    use super::*;
154    use crate::parsers::test_helpers::{session_payload, test_timestamp, unity_entry, EntryHeader};
155
156    // -- Authenticate response parsing ----------------------------------------
157
158    mod authenticate_response {
159        use super::*;
160
161        #[test]
162        fn test_try_parse_authenticate_response_with_json_body() {
163            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
164                         {\n\
165                           \"screenName\": \"TestPlayer#12345\"\n\
166                         }";
167            let entry = unity_entry(body);
168            let result = try_parse(&entry, Some(test_timestamp()));
169
170            assert!(result.is_some());
171            let event = result.as_ref().unwrap_or_else(|| unreachable!());
172            let payload = session_payload(event);
173
174            assert_eq!(payload["type"], "session_authenticate");
175            assert_eq!(payload["screen_name"], "TestPlayer#12345");
176        }
177
178        #[test]
179        fn test_try_parse_authenticate_response_nested_screen_name() {
180            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
181                         {\n\
182                           \"authenticateResponse\": {\n\
183                             \"screenName\": \"Nested#99999\"\n\
184                           }\n\
185                         }";
186            let entry = unity_entry(body);
187            let result = try_parse(&entry, Some(test_timestamp()));
188
189            assert!(result.is_some());
190            let event = result.as_ref().unwrap_or_else(|| unreachable!());
191            let payload = session_payload(event);
192
193            assert_eq!(payload["screen_name"], "Nested#99999");
194        }
195
196        #[test]
197        fn test_try_parse_authenticate_response_no_json() {
198            let body = "[UnityCrossThreadLogger]authenticateResponse";
199            let entry = unity_entry(body);
200            let result = try_parse(&entry, Some(test_timestamp()));
201
202            assert!(result.is_some());
203            let event = result.as_ref().unwrap_or_else(|| unreachable!());
204            let payload = session_payload(event);
205
206            assert_eq!(payload["type"], "session_authenticate");
207            assert_eq!(payload["screen_name"], "");
208        }
209
210        #[test]
211        fn test_try_parse_authenticate_response_no_screen_name_in_json() {
212            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
213                         {\"otherField\": \"value\"}";
214            let entry = unity_entry(body);
215            let result = try_parse(&entry, Some(test_timestamp()));
216
217            assert!(result.is_some());
218            let event = result.as_ref().unwrap_or_else(|| unreachable!());
219            let payload = session_payload(event);
220
221            assert_eq!(payload["type"], "session_authenticate");
222            assert_eq!(payload["screen_name"], "");
223        }
224
225        #[test]
226        fn test_try_parse_authenticate_response_preserves_raw_response() {
227            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
228                         {\"screenName\": \"Player#1\", \"token\": \"abc\"}";
229            let entry = unity_entry(body);
230            let result = try_parse(&entry, Some(test_timestamp()));
231
232            assert!(result.is_some());
233            let event = result.as_ref().unwrap_or_else(|| unreachable!());
234            let payload = session_payload(event);
235
236            assert!(payload.get("raw_response").is_some());
237            assert_eq!(payload["raw_response"]["token"], "abc");
238        }
239
240        #[test]
241        fn test_try_parse_authenticate_response_with_timestamp() {
242            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
243                         authenticateResponse\n\
244                         {\"screenName\": \"TsPlayer#555\"}";
245            let entry = unity_entry(body);
246            let result = try_parse(&entry, Some(test_timestamp()));
247
248            assert!(result.is_some());
249            let event = result.as_ref().unwrap_or_else(|| unreachable!());
250            let payload = session_payload(event);
251
252            assert_eq!(payload["screen_name"], "TsPlayer#555");
253        }
254    }
255
256    // -- FrontDoorConnection.Close parsing ------------------------------------
257
258    mod front_door_close {
259        use super::*;
260
261        #[test]
262        fn test_try_parse_front_door_close_basic() {
263            let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
264            let entry = unity_entry(body);
265            let result = try_parse(&entry, Some(test_timestamp()));
266
267            assert!(result.is_some());
268            let event = result.as_ref().unwrap_or_else(|| unreachable!());
269            let payload = session_payload(event);
270
271            assert_eq!(payload["type"], "session_disconnect");
272        }
273
274        #[test]
275        fn test_try_parse_front_door_close_with_details() {
276            let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close \
277                         reason: server shutdown";
278            let entry = unity_entry(body);
279            let result = try_parse(&entry, Some(test_timestamp()));
280
281            assert!(result.is_some());
282            let event = result.as_ref().unwrap_or_else(|| unreachable!());
283            let payload = session_payload(event);
284
285            assert_eq!(payload["type"], "session_disconnect");
286        }
287
288        #[test]
289        fn test_try_parse_front_door_close_with_timestamp() {
290            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
291                         FrontDoorConnection.Close";
292            let entry = unity_entry(body);
293            let result = try_parse(&entry, Some(test_timestamp()));
294
295            assert!(result.is_some());
296            let event = result.as_ref().unwrap_or_else(|| unreachable!());
297            let payload = session_payload(event);
298
299            assert_eq!(payload["type"], "session_disconnect");
300        }
301
302        #[test]
303        fn test_try_parse_front_door_close_preserves_metadata() {
304            let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
305            let entry = unity_entry(body);
306            let ts = Some(test_timestamp());
307            let result = try_parse(&entry, ts);
308
309            assert!(result.is_some());
310            let event = result.as_ref().unwrap_or_else(|| unreachable!());
311            assert_eq!(event.metadata().timestamp(), ts);
312            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
313        }
314    }
315
316    // -- Non-session entries (should return None) -----------------------------
317
318    mod non_session {
319        use super::*;
320
321        #[test]
322        fn test_try_parse_unrelated_entry_returns_none() {
323            let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
324            let entry = unity_entry(body);
325            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
326        }
327
328        #[test]
329        fn test_try_parse_empty_body_returns_none() {
330            let body = "[UnityCrossThreadLogger]";
331            let entry = unity_entry(body);
332            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
333        }
334
335        #[test]
336        fn test_try_parse_connection_manager_entry_returns_none() {
337            let entry = LogEntry {
338                header: EntryHeader::ConnectionManager,
339                body: "[ConnectionManager]some connection message".to_owned(),
340            };
341            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
342        }
343
344        #[test]
345        fn test_try_parse_similar_but_different_marker_returns_none() {
346            let body = "[UnityCrossThreadLogger]FrontDoorConnection.Open";
347            let entry = unity_entry(body);
348            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
349        }
350    }
351
352    // -- Performance class ---------------------------------------------------
353
354    mod performance_class {
355        use super::*;
356        use crate::events::PerformanceClass;
357
358        #[test]
359        fn test_session_event_is_durable_per_event() {
360            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
361                         {\"screenName\":\"ClassTest\"}";
362            let entry = unity_entry(body);
363            let result = try_parse(&entry, Some(test_timestamp()));
364
365            assert!(result.is_some());
366            let event = result.as_ref().unwrap_or_else(|| unreachable!());
367            assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
368        }
369    }
370
371    // -- Internal helpers ----------------------------------------------------
372
373    mod helpers {
374        use super::*;
375
376        #[test]
377        fn test_strip_header_prefix_unity() {
378            let result = strip_header_prefix("[UnityCrossThreadLogger]some content");
379            assert_eq!(result, "some content");
380        }
381
382        #[test]
383        fn test_strip_header_prefix_with_space() {
384            let result = strip_header_prefix("[UnityCrossThreadLogger] spaced content");
385            assert_eq!(result, "spaced content");
386        }
387
388        #[test]
389        fn test_strip_header_prefix_connection_manager() {
390            let result = strip_header_prefix("[ConnectionManager]connection content");
391            assert_eq!(result, "connection content");
392        }
393
394        #[test]
395        fn test_strip_header_prefix_no_bracket() {
396            let result = strip_header_prefix("no bracket here");
397            assert_eq!(result, "no bracket here");
398        }
399
400        #[test]
401        fn test_find_screen_name_top_level() {
402            let value = serde_json::json!({"screenName": "Player#123"});
403            assert_eq!(find_screen_name(&value), Some("Player#123".to_owned()));
404        }
405
406        #[test]
407        fn test_find_screen_name_nested() {
408            let value = serde_json::json!({
409                "authenticateResponse": {"screenName": "Nested#456"}
410            });
411            assert_eq!(find_screen_name(&value), Some("Nested#456".to_owned()));
412        }
413
414        #[test]
415        fn test_find_screen_name_not_present() {
416            let value = serde_json::json!({"other": "data"});
417            assert!(find_screen_name(&value).is_none());
418        }
419    }
420}