Skip to main content

manasight_parser/parsers/
connection_error.rs

1//! Connection-error parsers: JSON-bearing and plain-text error-path markers.
2//!
3//! Parses seven error-path entry types that together form the Layer 1 red
4//! triggers for the desktop connection health monitor. The markers live
5//! under three different entry headers and use two different payload
6//! strategies depending on whether the source line carries a structured
7//! JSON payload or is plain text.
8//!
9//! # Markers handled
10//!
11//! ## JSON-marker variants (`[UnityCrossThreadLogger]` header)
12//!
13//! | Marker | `error_type` |
14//! |--------|--------------|
15//! | `TcpConnection.ProcessRead.Exception` | `tcp_process_read_exception` |
16//! | `Client.TcpConnection.ProcessFailure` | `tcp_process_failure_socket_error` |
17//! | `GREConnection.MatchDoorConnectionError` | `gre_match_door_connection_error` |
18//! | `TcpConnection.Close.Exception` | `tcp_close_exception` |
19//!
20//! ## Plain-text variants (`[ConnectionManager]` and `Matchmaking:` headers)
21//!
22//! | Line pattern | Header | `error_type` |
23//! |--------------|--------|--------------|
24//! | `Reconnect result : <value>` | `[ConnectionManager]` | `reconnect_result` |
25//! | `Reconnect succeeded after N attempts` / `Reconnect failed` / `Reconnect timed out` | `[ConnectionManager]` | `reconnect_outcome` |
26//! | `Matchmaking: GRE connection lost` | `Matchmaking:` | `gre_connection_lost` |
27//!
28//! # Bare-marker entries (JSON variants only)
29//!
30//! All four JSON-marker variants are observed in the disconnect corpus as
31//! paired lines — a bare marker (no JSON) followed by a JSON-carrying
32//! line. Bare-marker entries return `None`; the paired JSON line on a
33//! subsequent entry emits the event.
34//!
35//! # Payload shapes
36//!
37//! Two payload strategies coexist under the single `ConnectionError`
38//! event type. Desktop consumers switch on `error_type` and read either
39//! `payload` (JSON variants) or flat top-level named fields (plain-text
40//! variants).
41//!
42//! ## Strategy 1: JSON passthrough (under a `payload` key)
43//!
44//! ```json
45//! {
46//!   "error_type": "<discriminant>",
47//!   "payload": { /* full parsed JSON from the log line */ }
48//! }
49//! ```
50//!
51//! The parser is agnostic to inner error-code semantics (e.g., platform
52//! differences in `NativeErrorCode` — Windows `10054`, macOS `10060` /
53//! `10049`). Downstream consumers read fields from `payload` per ADR-011.
54//!
55//! ## Strategy 2: plain-text flattened fields
56//!
57//! Plain-text lines carry a small, fixed set of structured fields. Rather
58//! than wrapping them under a `payload` key, they are flattened alongside
59//! `error_type`:
60//!
61//! ```json
62//! {"error_type": "gre_connection_lost"}
63//! {"error_type": "reconnect_result", "result": "Connected" | "Error" | "None"}
64//! {"error_type": "reconnect_outcome", "outcome": "succeeded" | "failed" | "timed_out", "attempts": <i64 or null>}
65//! ```
66//!
67//! # Header dispatch
68//!
69//! [`try_parse`] dispatches on `entry.header` to one of three sub-parsers:
70//!
71//! - [`EntryHeader::UnityCrossThreadLogger`] → [`try_unity_error`] (JSON variants)
72//! - [`EntryHeader::ConnectionManager`] → [`try_connection_manager`] (plain-text)
73//! - [`EntryHeader::Matchmaking`] → [`try_matchmaking`] (plain-text)
74//! - All other headers → `None`
75//!
76//! Satisfies feature spec `connection-health-indicator.md` **AC-DET-5**
77//! (JSON-marker variants and plain-text variants).
78
79use crate::events::{ConnectionErrorEvent, EventMetadata, GameEvent};
80use crate::log::entry::{EntryHeader, LogEntry};
81use crate::parsers::api_common;
82
83/// Marker text for `TcpConnection.ProcessRead.Exception` entries.
84const PROCESS_READ_EXCEPTION_MARKER: &str = "TcpConnection.ProcessRead.Exception";
85
86/// Marker text for `Client.TcpConnection.ProcessFailure` entries.
87const PROCESS_FAILURE_MARKER: &str = "Client.TcpConnection.ProcessFailure";
88
89/// Marker text for `GREConnection.MatchDoorConnectionError` entries.
90const MATCH_DOOR_ERROR_MARKER: &str = "GREConnection.MatchDoorConnectionError";
91
92/// Marker text for `TcpConnection.Close.Exception` entries.
93const CLOSE_EXCEPTION_MARKER: &str = "TcpConnection.Close.Exception";
94
95/// Stable `error_type` discriminant: `TcpConnection.ProcessRead.Exception`.
96const ERROR_TYPE_PROCESS_READ: &str = "tcp_process_read_exception";
97
98/// Stable `error_type` discriminant: `Client.TcpConnection.ProcessFailure`.
99const ERROR_TYPE_PROCESS_FAILURE: &str = "tcp_process_failure_socket_error";
100
101/// Stable `error_type` discriminant: `GREConnection.MatchDoorConnectionError`.
102const ERROR_TYPE_MATCH_DOOR: &str = "gre_match_door_connection_error";
103
104/// Stable `error_type` discriminant: `TcpConnection.Close.Exception`.
105const ERROR_TYPE_CLOSE_EXCEPTION: &str = "tcp_close_exception";
106
107/// Attempts to parse a [`LogEntry`] as a connection-error event.
108///
109/// Dispatches on `entry.header`:
110///
111/// - [`EntryHeader::UnityCrossThreadLogger`] — inspect the body for one of
112///   the four JSON-bearing error markers. Bare-marker bodies (no JSON
113///   payload) return `None`.
114/// - [`EntryHeader::ConnectionManager`] — parse the body for
115///   `Reconnect result : <value>` or `Reconnect <outcome>` plain-text
116///   markers.
117/// - [`EntryHeader::Matchmaking`] — parse the body for
118///   `Matchmaking: GRE connection lost`.
119/// - Any other header — return `None`.
120///
121/// The `timestamp` is `None` when the log entry header did not contain a
122/// parseable timestamp. It is passed through to [`EventMetadata`] so
123/// downstream consumers can distinguish real vs missing timestamps.
124pub fn try_parse(
125    entry: &LogEntry,
126    timestamp: Option<chrono::DateTime<chrono::Utc>>,
127) -> Option<GameEvent> {
128    let payload = match entry.header {
129        EntryHeader::UnityCrossThreadLogger => try_unity_error(&entry.body)?,
130        EntryHeader::ConnectionManager => try_connection_manager(&entry.body)?,
131        EntryHeader::Matchmaking => try_matchmaking(&entry.body)?,
132        _ => return None,
133    };
134
135    let metadata = EventMetadata::new(timestamp, entry.body.as_bytes().to_vec());
136    Some(GameEvent::ConnectionError(ConnectionErrorEvent::new(
137        metadata, payload,
138    )))
139}
140
141/// Matches a `[UnityCrossThreadLogger]` body against the four JSON-marker
142/// variants and returns the discriminated payload.
143///
144/// Returns `None` for bodies that don't contain any known marker, for
145/// bare-marker bodies without a JSON payload, and for malformed JSON
146/// payloads.
147fn try_unity_error(body: &str) -> Option<serde_json::Value> {
148    if body.contains(PROCESS_READ_EXCEPTION_MARKER) {
149        return try_exception_marker(body, ERROR_TYPE_PROCESS_READ);
150    }
151    if body.contains(PROCESS_FAILURE_MARKER) {
152        return try_exception_marker(body, ERROR_TYPE_PROCESS_FAILURE);
153    }
154    if body.contains(MATCH_DOOR_ERROR_MARKER) {
155        return try_exception_marker(body, ERROR_TYPE_MATCH_DOOR);
156    }
157    if body.contains(CLOSE_EXCEPTION_MARKER) {
158        return try_exception_marker(body, ERROR_TYPE_CLOSE_EXCEPTION);
159    }
160    None
161}
162
163/// Extracts and parses the JSON payload from the given body and wraps it
164/// in the discriminated `{error_type, payload}` envelope.
165///
166/// Returns `None` when the body has no JSON payload (bare-marker entries),
167/// when the JSON fails to parse, or when the body is otherwise malformed.
168/// A warning is logged on parse failure; bare-marker entries are silent
169/// because they are the expected leading half of a paired emission.
170fn try_exception_marker(body: &str, error_type: &str) -> Option<serde_json::Value> {
171    let json_str = api_common::extract_json_from_body(body)?;
172    let parsed: serde_json::Value = match serde_json::from_str(json_str) {
173        Ok(v) => v,
174        Err(e) => {
175            ::log::warn!("{error_type}: malformed JSON payload: {e}");
176            return None;
177        }
178    };
179    Some(serde_json::json!({
180        "error_type": error_type,
181        "payload": parsed,
182    }))
183}
184
185/// Parses a `[ConnectionManager]` entry body into a flattened plain-text
186/// payload.
187///
188/// The body begins with the literal `[ConnectionManager] ` prefix (the
189/// [`LineBuffer`][crate::log::entry::LineBuffer] keeps the header line in
190/// the entry body). Recognized suffixes:
191///
192/// - `Reconnect result : <value>` — only the enumerated values
193///   `Connected`, `Error`, and `None` are accepted. Any other value
194///   returns `None`.
195/// - `Reconnect succeeded after <N> attempts` — emits
196///   `reconnect_outcome` with `outcome = "succeeded"`. The attempts count
197///   is parsed as `i64`; if unparseable, `attempts` is `null` rather than
198///   causing the whole entry to be rejected.
199/// - `Reconnect failed` — emits `reconnect_outcome` with
200///   `outcome = "failed"` and `attempts = null`.
201/// - `Reconnect timed out` — emits `reconnect_outcome` with
202///   `outcome = "timed_out"` and `attempts = null`.
203///
204/// Returns `None` for any body that lacks the `[ConnectionManager] `
205/// prefix or that does not match one of the recognized suffixes.
206fn try_connection_manager(body: &str) -> Option<serde_json::Value> {
207    let content = body.strip_prefix("[ConnectionManager] ")?;
208
209    if let Some(rest) = content.strip_prefix("Reconnect result : ") {
210        let result = rest.trim();
211        return match result {
212            "Connected" | "Error" | "None" => Some(serde_json::json!({
213                "error_type": "reconnect_result",
214                "result": result,
215            })),
216            _ => None,
217        };
218    }
219
220    if let Some(rest) = content.strip_prefix("Reconnect succeeded after ") {
221        let attempts = rest
222            .split_whitespace()
223            .next()
224            .and_then(|s| s.parse::<i64>().ok());
225        return Some(serde_json::json!({
226            "error_type": "reconnect_outcome",
227            "outcome": "succeeded",
228            "attempts": attempts,
229        }));
230    }
231
232    if content.starts_with("Reconnect failed") {
233        return Some(serde_json::json!({
234            "error_type": "reconnect_outcome",
235            "outcome": "failed",
236            "attempts": serde_json::Value::Null,
237        }));
238    }
239
240    if content.starts_with("Reconnect timed out") {
241        return Some(serde_json::json!({
242            "error_type": "reconnect_outcome",
243            "outcome": "timed_out",
244            "attempts": serde_json::Value::Null,
245        }));
246    }
247
248    None
249}
250
251/// Parses a `Matchmaking:` entry body into a flattened plain-text payload.
252///
253/// Currently only one marker is recognized:
254///
255/// - `Matchmaking: GRE connection lost` → `gre_connection_lost`.
256///
257/// The body is matched with `starts_with` so downstream extensions
258/// (trailing descriptors such as `, attempting reconnect`) remain part of
259/// the same marker. Any other `Matchmaking:` suffix returns `None`.
260fn try_matchmaking(body: &str) -> Option<serde_json::Value> {
261    if body.starts_with("Matchmaking: GRE connection lost") {
262        return Some(serde_json::json!({"error_type": "gre_connection_lost"}));
263    }
264    None
265}
266
267// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[cfg(test)]
272#[allow(deprecated)]
273mod tests {
274    use super::*;
275    use crate::parsers::test_helpers::{
276        connection_error_payload, connection_manager_entry, matchmaking_entry, test_timestamp,
277        unity_entry,
278    };
279
280    /// Build a `[UnityCrossThreadLogger]<marker> <json>` body.
281    fn unity_body(marker: &str, json: &str) -> String {
282        format!("[UnityCrossThreadLogger]{marker} {json}")
283    }
284
285    /// Assert that parsing the entry yielded `Some(GameEvent::ConnectionError)`
286    /// with the given `error_type`, and return the inner `payload` field.
287    fn assert_connection_error<'a>(
288        event: &'a GameEvent,
289        expected_error_type: &str,
290    ) -> &'a serde_json::Value {
291        assert!(
292            matches!(event, GameEvent::ConnectionError(_)),
293            "expected ConnectionError, got {event:?}"
294        );
295        let outer = connection_error_payload(event);
296        assert_eq!(
297            outer["error_type"], expected_error_type,
298            "error_type mismatch"
299        );
300        &outer["payload"]
301    }
302
303    // -- TcpConnection.ProcessRead.Exception -------------------------------
304
305    mod process_read_exception {
306        use super::*;
307
308        #[test]
309        fn test_windows_native_error_code_10054() {
310            let body = unity_body(
311                PROCESS_READ_EXCEPTION_MARKER,
312                r#"{
313                    "function":"ReadAsync",
314                    "description":"An established connection was aborted by the software in your host machine",
315                    "exception":{
316                        "Message":"Unable to read data from the transport connection",
317                        "ClassName":"System.IO.IOException",
318                        "InnerException":{
319                            "ClassName":"System.Net.Sockets.SocketException",
320                            "NativeErrorCode":10054,
321                            "SocketErrorCode":"ConnectionAborted",
322                            "Message":"An established connection was aborted"
323                        }
324                    }
325                }"#,
326            );
327            let entry = unity_entry(&body);
328            let result = try_parse(&entry, Some(test_timestamp()));
329
330            assert!(result.is_some());
331            let event = result.as_ref().unwrap_or_else(|| unreachable!());
332            let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
333            assert_eq!(payload["function"], "ReadAsync");
334            assert_eq!(
335                payload["exception"]["InnerException"]["NativeErrorCode"],
336                10054
337            );
338            assert_eq!(
339                payload["exception"]["InnerException"]["SocketErrorCode"],
340                "ConnectionAborted"
341            );
342        }
343
344        #[test]
345        fn test_macos_native_error_code_10060() {
346            let body = unity_body(
347                PROCESS_READ_EXCEPTION_MARKER,
348                r#"{
349                    "function":"ReadAsync",
350                    "description":"Connection timed out",
351                    "exception":{
352                        "ClassName":"System.IO.IOException",
353                        "InnerException":{
354                            "ClassName":"System.Net.Sockets.SocketException",
355                            "NativeErrorCode":10060,
356                            "SocketErrorCode":"TimedOut",
357                            "Message":"Operation timed out"
358                        }
359                    }
360                }"#,
361            );
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            let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
368            assert_eq!(
369                payload["exception"]["InnerException"]["NativeErrorCode"],
370                10060
371            );
372            assert_eq!(
373                payload["exception"]["InnerException"]["SocketErrorCode"],
374                "TimedOut"
375            );
376        }
377
378        #[test]
379        fn test_macos_native_error_code_10049() {
380            let body = unity_body(
381                PROCESS_READ_EXCEPTION_MARKER,
382                r#"{
383                    "function":"ReadAsync",
384                    "description":"Address not valid",
385                    "exception":{
386                        "InnerException":{
387                            "NativeErrorCode":10049,
388                            "SocketErrorCode":"AddressNotAvailable"
389                        }
390                    }
391                }"#,
392            );
393            let entry = unity_entry(&body);
394            let result = try_parse(&entry, Some(test_timestamp()));
395
396            assert!(result.is_some());
397            let event = result.as_ref().unwrap_or_else(|| unreachable!());
398            let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
399            assert_eq!(
400                payload["exception"]["InnerException"]["NativeErrorCode"],
401                10049
402            );
403        }
404
405        #[test]
406        fn test_bare_marker_returns_none() {
407            let body = format!("[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER}");
408            let entry = unity_entry(&body);
409            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
410        }
411
412        #[test]
413        fn test_bare_marker_with_trailing_whitespace_returns_none() {
414            let body = format!("[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER}   ");
415            let entry = unity_entry(&body);
416            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
417        }
418
419        #[test]
420        fn test_numeric_native_error_code_stays_numeric() {
421            let body = unity_body(
422                PROCESS_READ_EXCEPTION_MARKER,
423                r#"{"exception":{"InnerException":{"NativeErrorCode":10054}}}"#,
424            );
425            let entry = unity_entry(&body);
426            let result = try_parse(&entry, Some(test_timestamp()));
427
428            assert!(result.is_some());
429            let event = result.as_ref().unwrap_or_else(|| unreachable!());
430            let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_READ);
431            assert!(
432                payload["exception"]["InnerException"]["NativeErrorCode"].is_number(),
433                "NativeErrorCode must remain numeric"
434            );
435        }
436    }
437
438    // -- Client.TcpConnection.ProcessFailure -------------------------------
439
440    mod process_failure {
441        use super::*;
442
443        #[test]
444        fn test_socket_error_firewall_block() {
445            let body = unity_body(
446                PROCESS_FAILURE_MARKER,
447                r#"{"SocketError":"AccessDenied","function":"ConnectAsync"}"#,
448            );
449            let entry = unity_entry(&body);
450            let result = try_parse(&entry, Some(test_timestamp()));
451
452            assert!(result.is_some());
453            let event = result.as_ref().unwrap_or_else(|| unreachable!());
454            let payload = assert_connection_error(event, ERROR_TYPE_PROCESS_FAILURE);
455            assert_eq!(payload["SocketError"], "AccessDenied");
456            assert_eq!(payload["function"], "ConnectAsync");
457        }
458
459        #[test]
460        fn test_bare_marker_returns_none() {
461            let body = format!("[UnityCrossThreadLogger]{PROCESS_FAILURE_MARKER}");
462            let entry = unity_entry(&body);
463            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
464        }
465    }
466
467    // -- GREConnection.MatchDoorConnectionError ----------------------------
468
469    mod match_door_error {
470        use super::*;
471
472        #[test]
473        fn test_close_type_and_tcp_conn() {
474            let body = unity_body(
475                MATCH_DOOR_ERROR_MARKER,
476                r#"{
477                    "closeType":1,
478                    "reason":"Connection lost",
479                    "tcpConn":{
480                        "host":"mtgarena-match.example.com",
481                        "port":443,
482                        "inactivityTimeoutMs":30000
483                    }
484                }"#,
485            );
486            let entry = unity_entry(&body);
487            let result = try_parse(&entry, Some(test_timestamp()));
488
489            assert!(result.is_some());
490            let event = result.as_ref().unwrap_or_else(|| unreachable!());
491            let payload = assert_connection_error(event, ERROR_TYPE_MATCH_DOOR);
492            assert_eq!(payload["closeType"], 1);
493            assert_eq!(payload["reason"], "Connection lost");
494            assert_eq!(payload["tcpConn"]["host"], "mtgarena-match.example.com");
495            assert_eq!(payload["tcpConn"]["port"], 443);
496        }
497
498        #[test]
499        fn test_bare_marker_returns_none() {
500            let body = format!("[UnityCrossThreadLogger]{MATCH_DOOR_ERROR_MARKER}");
501            let entry = unity_entry(&body);
502            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
503        }
504    }
505
506    // -- TcpConnection.Close.Exception (macOS-only in corpus) --------------
507
508    mod close_exception {
509        use super::*;
510
511        #[test]
512        fn test_single_exception_top_level_key() {
513            let body = unity_body(
514                CLOSE_EXCEPTION_MARKER,
515                r#"{
516                    "exception":{
517                        "NativeErrorCode":10049,
518                        "ClassName":"System.Net.Sockets.SocketException",
519                        "Message":"The requested address is not valid in this context",
520                        "InnerException":null
521                    }
522                }"#,
523            );
524            let entry = unity_entry(&body);
525            let result = try_parse(&entry, Some(test_timestamp()));
526
527            assert!(result.is_some());
528            let event = result.as_ref().unwrap_or_else(|| unreachable!());
529            let payload = assert_connection_error(event, ERROR_TYPE_CLOSE_EXCEPTION);
530            // Single top-level `exception` key — the SocketException is
531            // the direct value, NOT wrapped in an outer IOException.
532            assert!(payload["exception"].is_object());
533            assert_eq!(payload["exception"]["NativeErrorCode"], 10049);
534            assert_eq!(
535                payload["exception"]["ClassName"],
536                "System.Net.Sockets.SocketException"
537            );
538            assert!(payload["exception"]["InnerException"].is_null());
539        }
540
541        #[test]
542        fn test_bare_marker_returns_none() {
543            let body = format!("[UnityCrossThreadLogger]{CLOSE_EXCEPTION_MARKER}");
544            let entry = unity_entry(&body);
545            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
546        }
547    }
548
549    // -- Non-matching bodies -----------------------------------------------
550
551    mod non_matching {
552        use super::*;
553
554        #[test]
555        fn test_plain_gre_message_returns_none() {
556            let body =
557                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent\n{\"data\":1}";
558            let entry = unity_entry(body);
559            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
560        }
561
562        #[test]
563        fn test_tcp_connection_close_returns_none() {
564            // A-2 claims `Client.TcpConnection.Close`; ensure A-3 does NOT
565            // also match that body (would be a double-claim regression).
566            let body =
567                "[UnityCrossThreadLogger]Client.TcpConnection.Close {\"status\":7,\"reason\":\"x\"}";
568            let entry = unity_entry(body);
569            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
570        }
571
572        #[test]
573        fn test_websocket_closed_returns_none() {
574            let body =
575                "[UnityCrossThreadLogger]GREConnection.HandleWebSocketClosed {\"closeType\":1}";
576            let entry = unity_entry(body);
577            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
578        }
579
580        #[test]
581        fn test_empty_unity_body_returns_none() {
582            let body = "[UnityCrossThreadLogger]";
583            let entry = unity_entry(body);
584            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
585        }
586
587        #[test]
588        fn test_malformed_json_returns_none() {
589            let body = format!(
590                "[UnityCrossThreadLogger]{PROCESS_READ_EXCEPTION_MARKER} {{\"function\":\"ReadAsync\""
591            );
592            let entry = unity_entry(&body);
593            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
594        }
595    }
596
597    // -- Non-UnityCrossThreadLogger headers --------------------------------
598
599    mod non_unity_headers {
600        use super::*;
601
602        #[test]
603        fn test_matchmaking_header_returns_none() {
604            let entry = LogEntry {
605                header: EntryHeader::Matchmaking,
606                body: format!(
607                    "Matchmaking:{PROCESS_READ_EXCEPTION_MARKER} {{\"function\":\"ReadAsync\"}}"
608                ),
609            };
610            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
611        }
612
613        #[test]
614        fn test_metadata_header_returns_none() {
615            let entry = LogEntry {
616                header: EntryHeader::Metadata,
617                body: format!(
618                    "{PROCESS_READ_EXCEPTION_MARKER} {{\"exception\":{{\"InnerException\":{{\"NativeErrorCode\":10054}}}}}}"
619                ),
620            };
621            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
622        }
623
624        #[test]
625        fn test_unrecognized_connection_manager_body_returns_none() {
626            // A-4 claims ConnectionManager entries, but only the enumerated
627            // Reconnect markers. Unrelated bodies must still return None.
628            let entry =
629                connection_manager_entry("[ConnectionManager] Some unrelated diagnostic line");
630            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
631        }
632
633        #[test]
634        fn test_unrecognized_matchmaking_body_returns_none() {
635            // A-4 claims Matchmaking entries, but only the GRE-connection-lost
636            // marker. Unrelated bodies must still return None.
637            let entry = matchmaking_entry("Matchmaking: queue entered");
638            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
639        }
640    }
641
642    // -- [ConnectionManager] Reconnect result ------------------------------
643
644    mod reconnect_result {
645        use super::*;
646
647        fn parse(body: &str) -> Option<GameEvent> {
648            let entry = connection_manager_entry(body);
649            try_parse(&entry, Some(test_timestamp()))
650        }
651
652        fn assert_result(body: &str, expected: &str) {
653            let result = parse(body);
654            assert!(result.is_some(), "expected Some for {body:?}, got None");
655            let event = result.as_ref().unwrap_or_else(|| unreachable!());
656            let payload = connection_error_payload(event);
657            assert_eq!(payload["error_type"], "reconnect_result");
658            assert_eq!(payload["result"], expected);
659        }
660
661        #[test]
662        fn test_reconnect_result_connected() {
663            assert_result(
664                "[ConnectionManager] Reconnect result : Connected",
665                "Connected",
666            );
667        }
668
669        #[test]
670        fn test_reconnect_result_error() {
671            assert_result("[ConnectionManager] Reconnect result : Error", "Error");
672        }
673
674        #[test]
675        fn test_reconnect_result_none() {
676            assert_result("[ConnectionManager] Reconnect result : None", "None");
677        }
678
679        #[test]
680        fn test_reconnect_result_invalid_value_returns_none() {
681            // Only the enumerated Connected/Error/None values are accepted.
682            assert!(parse("[ConnectionManager] Reconnect result : Unknown").is_none());
683        }
684
685        #[test]
686        fn test_reconnect_result_empty_value_returns_none() {
687            assert!(parse("[ConnectionManager] Reconnect result : ").is_none());
688        }
689    }
690
691    // -- [ConnectionManager] Reconnect outcome -----------------------------
692
693    mod reconnect_outcome {
694        use super::*;
695
696        fn parse(body: &str) -> Option<GameEvent> {
697            let entry = connection_manager_entry(body);
698            try_parse(&entry, Some(test_timestamp()))
699        }
700
701        #[test]
702        fn test_reconnect_succeeded_after_1_attempts() {
703            let result = parse("[ConnectionManager] Reconnect succeeded after 1 attempts");
704            assert!(result.is_some());
705            let event = result.as_ref().unwrap_or_else(|| unreachable!());
706            let payload = connection_error_payload(event);
707            assert_eq!(payload["error_type"], "reconnect_outcome");
708            assert_eq!(payload["outcome"], "succeeded");
709            assert_eq!(payload["attempts"], 1);
710        }
711
712        #[test]
713        fn test_reconnect_succeeded_with_trailing_descriptor() {
714            // `Reconnect succeeded after 1 attempts (0.8s)` — attempts is
715            // parsed from the first whitespace-delimited token.
716            let result = parse("[ConnectionManager] Reconnect succeeded after 3 attempts (1.5s)");
717            assert!(result.is_some());
718            let event = result.as_ref().unwrap_or_else(|| unreachable!());
719            let payload = connection_error_payload(event);
720            assert_eq!(payload["outcome"], "succeeded");
721            assert_eq!(payload["attempts"], 3);
722        }
723
724        #[test]
725        fn test_reconnect_failed() {
726            let result = parse("[ConnectionManager] Reconnect failed");
727            assert!(result.is_some());
728            let event = result.as_ref().unwrap_or_else(|| unreachable!());
729            let payload = connection_error_payload(event);
730            assert_eq!(payload["error_type"], "reconnect_outcome");
731            assert_eq!(payload["outcome"], "failed");
732            assert!(payload["attempts"].is_null());
733        }
734
735        #[test]
736        fn test_reconnect_timed_out() {
737            let result = parse("[ConnectionManager] Reconnect timed out");
738            assert!(result.is_some());
739            let event = result.as_ref().unwrap_or_else(|| unreachable!());
740            let payload = connection_error_payload(event);
741            assert_eq!(payload["error_type"], "reconnect_outcome");
742            assert_eq!(payload["outcome"], "timed_out");
743            assert!(payload["attempts"].is_null());
744        }
745
746        #[test]
747        fn test_reconnect_succeeded_unparseable_attempts_is_null() {
748            // Unparseable attempts should fall back to null, NOT return
749            // None — the outcome itself is still useful downstream.
750            let result = parse("[ConnectionManager] Reconnect succeeded after banana attempts");
751            assert!(result.is_some());
752            let event = result.as_ref().unwrap_or_else(|| unreachable!());
753            let payload = connection_error_payload(event);
754            assert_eq!(payload["error_type"], "reconnect_outcome");
755            assert_eq!(payload["outcome"], "succeeded");
756            assert!(
757                payload["attempts"].is_null(),
758                "unparseable attempts must be null, got {:?}",
759                payload["attempts"]
760            );
761        }
762    }
763
764    // -- Matchmaking: GRE connection lost ----------------------------------
765
766    mod gre_connection_lost {
767        use super::*;
768
769        #[test]
770        fn test_gre_connection_lost_bare() {
771            let entry = matchmaking_entry("Matchmaking: GRE connection lost");
772            let result = try_parse(&entry, Some(test_timestamp()));
773            assert!(result.is_some());
774            let event = result.as_ref().unwrap_or_else(|| unreachable!());
775            let payload = connection_error_payload(event);
776            assert_eq!(payload["error_type"], "gre_connection_lost");
777            // Plain-text flattened strategy: no `payload` wrapper key.
778            assert!(payload.get("payload").is_none());
779        }
780
781        #[test]
782        fn test_gre_connection_lost_with_trailing_descriptor() {
783            // Trailing descriptors are permitted — the marker is matched
784            // via starts_with.
785            let entry = matchmaking_entry("Matchmaking: GRE connection lost, attempting reconnect");
786            let result = try_parse(&entry, Some(test_timestamp()));
787            assert!(result.is_some());
788            let event = result.as_ref().unwrap_or_else(|| unreachable!());
789            let payload = connection_error_payload(event);
790            assert_eq!(payload["error_type"], "gre_connection_lost");
791        }
792
793        #[test]
794        fn test_non_matching_matchmaking_suffix_returns_none() {
795            // `Matchmaking: GRE connected` (wrong suffix, not "lost") must
796            // not match the gre_connection_lost marker.
797            let entry = matchmaking_entry("Matchmaking: GRE connected");
798            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
799        }
800    }
801
802    // -- Plain-text dispatch edge cases ------------------------------------
803
804    mod plain_text_dispatch {
805        use super::*;
806
807        #[test]
808        fn test_connection_manager_without_prefix_returns_none() {
809            // The body is required to begin with `[ConnectionManager] `.
810            let entry = LogEntry {
811                header: EntryHeader::ConnectionManager,
812                body: "Reconnect result : Connected".to_owned(),
813            };
814            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
815        }
816
817        #[test]
818        fn test_matchmaking_empty_body_returns_none() {
819            let entry = matchmaking_entry("");
820            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
821        }
822
823        #[test]
824        fn test_unity_header_with_reconnect_body_returns_none() {
825            // Confirms dispatch is header-gated: a ConnectionManager-shaped
826            // body under the wrong header (UnityCrossThreadLogger) must return None.
827            let entry = LogEntry {
828                header: EntryHeader::UnityCrossThreadLogger,
829                body: "[ConnectionManager] Reconnect result : Connected".to_owned(),
830            };
831            assert!(try_parse(&entry, Some(test_timestamp())).is_none());
832        }
833    }
834
835    // -- Metadata preservation ---------------------------------------------
836
837    mod metadata {
838        use super::*;
839
840        #[test]
841        fn test_preserves_raw_bytes() {
842            let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
843            let entry = unity_entry(&body);
844            let result = try_parse(&entry, Some(test_timestamp()));
845
846            assert!(result.is_some());
847            let event = result.as_ref().unwrap_or_else(|| unreachable!());
848            assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
849        }
850
851        #[test]
852        fn test_preserves_timestamp() {
853            let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
854            let entry = unity_entry(&body);
855            let ts = Some(test_timestamp());
856            let result = try_parse(&entry, ts);
857
858            assert!(result.is_some());
859            let event = result.as_ref().unwrap_or_else(|| unreachable!());
860            assert_eq!(event.metadata().timestamp(), ts);
861        }
862
863        #[test]
864        fn test_passes_through_none_timestamp() {
865            let body = unity_body(PROCESS_READ_EXCEPTION_MARKER, r#"{"function":"ReadAsync"}"#);
866            let entry = unity_entry(&body);
867            let result = try_parse(&entry, None);
868
869            assert!(result.is_some());
870            let event = result.as_ref().unwrap_or_else(|| unreachable!());
871            assert!(event.metadata().timestamp().is_none());
872        }
873    }
874}