Skip to main content

manasight_parser/
router.rs

1//! Raw log entry to parser dispatch routing.
2//!
3//! Examines the header prefix and payload of each raw log entry to
4//! determine which category-specific parser should handle it. Unrecognized
5//! entries are counted and logged at debug level.
6//!
7//! # Dispatch strategy
8//!
9//! Each [`LogEntry`] is offered to category parsers in a fixed priority
10//! order (most frequent first). The first parser that returns one or
11//! more events claims the entry. GRE entries may produce multiple
12//! events from batched `GameStateMessage` values. If no parser matches,
13//! the entry is counted as unrecognized and discarded.
14//!
15//! # Timestamp extraction
16//!
17//! The router extracts the timestamp from the entry header line
18//! (e.g., `[UnityCrossThreadLogger]2/25/2026 12:00:00 PM ...`) and
19//! parses it using [`parse_log_timestamp`]. If the timestamp cannot be
20//! parsed, `None` is passed to parsers so downstream consumers can
21//! distinguish real timestamps from missing ones.
22//!
23//! [`LogEntry`]: crate::log::entry::LogEntry
24//! [`parse_log_timestamp`]: crate::log::timestamp::parse_log_timestamp
25
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use chrono::{DateTime, Utc};
29
30use crate::events::GameEvent;
31use crate::log::entry::LogEntry;
32use crate::log::timestamp::parse_log_timestamp;
33use crate::parsers;
34use crate::util::truncate_for_log;
35
36// ---------------------------------------------------------------------------
37// RouterStats
38// ---------------------------------------------------------------------------
39
40/// Counters for router health monitoring.
41///
42/// Tracks the number of entries routed successfully and the number of
43/// unrecognized entries. The unknown-entry count is exposed for upload
44/// health status — a spike after an MTGA update signals that new event
45/// types may need parser support.
46#[derive(Debug, Default)]
47pub struct RouterStats {
48    /// Number of entries successfully routed to a parser.
49    routed: AtomicU64,
50    /// Number of entries not claimed by any parser.
51    unknown: AtomicU64,
52    /// Number of entries where the timestamp could not be parsed.
53    timestamp_failures: AtomicU64,
54}
55
56impl RouterStats {
57    /// Creates a new `RouterStats` with all counters at zero.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Returns the number of entries successfully routed to a parser.
63    pub fn routed_count(&self) -> u64 {
64        self.routed.load(Ordering::Relaxed)
65    }
66
67    /// Returns the number of unrecognized entries (not claimed by any parser).
68    pub fn unknown_count(&self) -> u64 {
69        self.unknown.load(Ordering::Relaxed)
70    }
71
72    /// Returns the number of entries where the timestamp could not be parsed.
73    pub fn timestamp_failure_count(&self) -> u64 {
74        self.timestamp_failures.load(Ordering::Relaxed)
75    }
76
77    /// Resets all counters to zero.
78    pub fn reset(&self) {
79        self.routed.store(0, Ordering::Relaxed);
80        self.unknown.store(0, Ordering::Relaxed);
81        self.timestamp_failures.store(0, Ordering::Relaxed);
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Router
87// ---------------------------------------------------------------------------
88
89/// Dispatch router that matches raw log entries to category-specific parsers.
90///
91/// Holds a [`RouterStats`] that tracks routing outcomes for health monitoring.
92/// The router is designed to be long-lived — create one at startup and reuse
93/// it for every entry.
94///
95/// # Example
96///
97/// ```
98/// use manasight_parser::router::Router;
99/// use manasight_parser::log::entry::{LogEntry, EntryHeader};
100///
101/// let router = Router::new();
102///
103/// let entry = LogEntry {
104///     header: EntryHeader::UnityCrossThreadLogger,
105///     body: "[UnityCrossThreadLogger]some unrecognized line".to_owned(),
106/// };
107///
108/// let events = router.route(&entry);
109/// assert!(events.is_empty());
110/// assert_eq!(router.stats().unknown_count(), 1);
111/// ```
112pub struct Router {
113    /// Routing statistics for health monitoring.
114    stats: RouterStats,
115}
116
117impl Router {
118    /// Creates a new router with zeroed statistics.
119    pub fn new() -> Self {
120        Self {
121            stats: RouterStats::new(),
122        }
123    }
124
125    /// Returns a reference to the router's statistics.
126    pub fn stats(&self) -> &RouterStats {
127        &self.stats
128    }
129
130    /// Routes a [`LogEntry`] to the appropriate parser.
131    ///
132    /// Extracts the timestamp from the entry header line, then offers the
133    /// entry to each category parser in priority order. Returns a
134    /// `Vec<GameEvent>` with one or more events if a parser claims the
135    /// entry, or an empty `Vec` if unrecognized.
136    ///
137    /// GRE entries may contain multiple batched `GameStateMessage` values
138    /// in a single log entry, producing multiple events from one entry.
139    ///
140    /// When the timestamp cannot be parsed, `None` is passed to parsers
141    /// so downstream consumers can distinguish real timestamps from
142    /// missing ones. The timestamp failure is counted in [`RouterStats`]
143    /// and logged at debug level.
144    pub fn route(&self, entry: &LogEntry) -> Vec<GameEvent> {
145        let timestamp = extract_timestamp(&entry.body);
146
147        if timestamp.is_none() {
148            self.stats
149                .timestamp_failures
150                .fetch_add(1, Ordering::Relaxed);
151            ::log::debug!(
152                "No timestamp in entry header: {:?}",
153                truncate_for_log(&entry.body, 120),
154            );
155        }
156
157        let events = dispatch_to_parsers(entry, timestamp);
158
159        if events.is_empty() {
160            self.stats.unknown.fetch_add(1, Ordering::Relaxed);
161            ::log::debug!(
162                "Unrecognized entry (header={}, body={:?})",
163                entry.header,
164                truncate_for_log(&entry.body, 120),
165            );
166        } else {
167            self.stats.routed.fetch_add(1, Ordering::Relaxed);
168        }
169
170        events
171    }
172}
173
174impl Default for Router {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Internal helpers
182// ---------------------------------------------------------------------------
183
184/// Extracts and parses the timestamp from the first line of an entry body.
185///
186/// The expected format is:
187/// ```text
188/// [UnityCrossThreadLogger]2/25/2026 12:00:00 PM some content
189/// [Client GRE]2/25/2026 12:00:00 PM GreToClientEvent
190/// [UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to ...
191/// ```
192///
193/// Strips the bracket-enclosed header prefix and extracts the date/time
194/// portion that follows. The timestamp string may be followed by
195/// additional content on the same line (event name, method name, etc.)
196/// or by a newline if the timestamp is on its own line.
197///
198/// Trims trailing punctuation (like colons in `... PM: MatchGameRoom...`) from
199/// the extracted tokens before parsing to ensure robust matching.
200fn extract_timestamp(body: &str) -> Option<DateTime<Utc>> {
201    let first_line = body.lines().next()?;
202
203    // Strip the header prefix: find the closing `]` bracket.
204    let after_bracket = first_line.find(']').map(|pos| &first_line[pos + 1..])?;
205    let trimmed = after_bracket.trim();
206
207    if trimmed.is_empty() {
208        return None;
209    }
210
211    // The timestamp may be followed by additional text (event name, etc.).
212    // Try progressively shorter prefixes to find a valid timestamp.
213    // Start with the full string and remove trailing words one at a time.
214    let words: Vec<&str> = trimmed.split_whitespace().collect();
215
216    // Timestamps typically use 2-3 tokens (date + time, or date + time + AM/PM).
217    // Try from longest plausible prefix down to 2 tokens.
218    let max_words = words.len().min(4);
219    for end in (2..=max_words).rev() {
220        let candidate = words[..end].join(" ");
221        // Ensure trailing punctuation (like colons after AM/PM) doesn't break parsing.
222        let cleaned = candidate.trim_end_matches(|c: char| c.is_ascii_punctuation());
223        if let Ok(ts) = parse_log_timestamp(cleaned) {
224            return Some(ts);
225        }
226    }
227
228    None
229}
230
231/// Dispatches a log entry to category parsers in priority order.
232///
233/// Parsers are tried in order of expected frequency during typical
234/// gameplay to minimize unnecessary parse attempts:
235///
236/// 0. Metadata — `DETAILED LOGS` status (header-type short-circuit)
237/// 1. GRE messages (game state + game result) — most frequent in-game
238/// 2. Client actions — frequent player decisions
239/// 3. Match state — match boundaries
240/// 4. Session — login/logout
241/// 5. Draft bot — bot draft picks
242/// 6. Draft human — human draft picks
243/// 7. Draft complete — draft completion
244/// 8. Event lifecycle — event joins/claims
245/// 9. Rank — rank snapshots
246/// 10. Inventory — inventory from `StartHook`
247/// 11. Match connection state — `STATE CHANGED` transitions
248/// 12. Connection close — `Client.TcpConnection.Close` / `GREConnection.HandleWebSocketClosed`
249///
250/// The GRE parser may return multiple events from a single entry
251/// (batched `GameStateMessage` values). All other parsers return at
252/// most one event.
253///
254/// `timestamp` is `None` when the log entry header did not contain a
255/// parseable timestamp; parsers pass it through to `EventMetadata`.
256fn dispatch_to_parsers(entry: &LogEntry, timestamp: Option<DateTime<Utc>>) -> Vec<GameEvent> {
257    // Metadata entries are routed directly to the metadata parser.
258    if let Some(event) = parsers::metadata::try_parse(entry, timestamp) {
259        return vec![event];
260    }
261
262    // GRE parser returns Vec<GameEvent> (may contain multiple batched GSMs).
263    let gre_events = parsers::gre::try_parse(entry, timestamp);
264    if !gre_events.is_empty() {
265        return gre_events;
266    }
267
268    // All other parsers return Option<GameEvent> (at most one event per entry).
269    let event = None
270        .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
271        .or_else(|| parsers::match_state::try_parse(entry, timestamp))
272        .or_else(|| parsers::session::try_parse(entry, timestamp))
273        .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
274        .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
275        .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
276        .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
277        .or_else(|| parsers::rank::try_parse(entry, timestamp))
278        .or_else(|| parsers::inventory::try_parse(entry, timestamp))
279        .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
280        .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
281        .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
282
283    event.into_iter().collect()
284}
285
286// ---------------------------------------------------------------------------
287// Tests
288// ---------------------------------------------------------------------------
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::log::entry::EntryHeader;
294    use chrono::Timelike;
295
296    /// Helper: build a `LogEntry` with `UnityCrossThreadLogger` header.
297    fn unity_entry(body: &str) -> LogEntry {
298        LogEntry {
299            header: EntryHeader::UnityCrossThreadLogger,
300            body: body.to_owned(),
301        }
302    }
303
304    /// Helper: build a `LogEntry` with `ClientGre` header.
305    fn gre_entry(body: &str) -> LogEntry {
306        LogEntry {
307            header: EntryHeader::ClientGre,
308            body: body.to_owned(),
309        }
310    }
311
312    // -- extract_timestamp ---------------------------------------------------
313
314    mod extract_timestamp_tests {
315        use super::*;
316
317        #[test]
318        fn test_extract_timestamp_us_format_with_pm() {
319            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
320            let ts = extract_timestamp(body);
321            assert!(ts.is_some());
322            if let Some(ts) = ts {
323                assert_eq!(
324                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
325                    "2026-02-25 12:00:00"
326                );
327            }
328        }
329
330        #[test]
331        fn test_extract_timestamp_us_format_with_am() {
332            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
333            let ts = extract_timestamp(body);
334            assert!(ts.is_some());
335            if let Some(ts) = ts {
336                assert_eq!(
337                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
338                    "2026-02-22 11:59:51"
339                );
340            }
341        }
342
343        #[test]
344        fn test_extract_timestamp_with_trailing_colon() {
345            let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
346            let ts = extract_timestamp(body);
347            assert!(ts.is_some());
348            if let Some(ts) = ts {
349                assert_eq!(ts.hour(), 23); // Should correctly identify PM
350            }
351        }
352
353        #[test]
354        fn test_extract_timestamp_24h_format() {
355            let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
356            let ts = extract_timestamp(body);
357            assert!(ts.is_some());
358            if let Some(ts) = ts {
359                assert_eq!(
360                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
361                    "2026-02-25 14:30:00"
362                );
363            }
364        }
365
366        #[test]
367        fn test_extract_timestamp_client_gre_header() {
368            let body = "[Client GRE]2/25/2026 12:00:00 PM GreToClientEvent";
369            let ts = extract_timestamp(body);
370            assert!(ts.is_some());
371        }
372
373        #[test]
374        fn test_extract_timestamp_no_bracket_returns_none() {
375            let body = "no bracket here";
376            let ts = extract_timestamp(body);
377            assert!(ts.is_none());
378        }
379
380        #[test]
381        fn test_extract_timestamp_empty_after_bracket_returns_none() {
382            let body = "[UnityCrossThreadLogger]";
383            let ts = extract_timestamp(body);
384            assert!(ts.is_none());
385        }
386
387        #[test]
388        fn test_extract_timestamp_no_timestamp_content_returns_none() {
389            let body = "[UnityCrossThreadLogger]Updated account. DisplayName:Player";
390            let ts = extract_timestamp(body);
391            assert!(ts.is_none());
392        }
393
394        #[test]
395        fn test_extract_timestamp_timestamp_on_own_line() {
396            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
397            let ts = extract_timestamp(body);
398            assert!(ts.is_some());
399            if let Some(ts) = ts {
400                assert_eq!(
401                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
402                    "2026-02-22 11:59:51"
403                );
404            }
405        }
406
407        #[test]
408        fn test_extract_timestamp_with_leading_space() {
409            let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
410            let ts = extract_timestamp(body);
411            assert!(ts.is_some());
412        }
413    }
414
415    // -- Router: known entry routing -----------------------------------------
416
417    mod known_routing {
418        use super::*;
419
420        #[test]
421        fn test_route_gre_game_state_message() {
422            let router = Router::new();
423            let payload = serde_json::json!({
424                "greToClientEvent": {
425                    "greToClientMessages": [{
426                        "type": "GREMessageType_GameStateMessage",
427                        "gameStateMessage": {
428                            "gameInfo": { "stage": "GameStage_Play" },
429                            "gameObjects": [],
430                            "zones": []
431                        }
432                    }]
433                }
434            });
435            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
436            let entry = unity_entry(&body);
437
438            let results = router.route(&entry);
439            assert_eq!(results.len(), 1);
440            assert!(matches!(&results[0], GameEvent::GameState(_)));
441            assert_eq!(router.stats().routed_count(), 1);
442            assert_eq!(router.stats().unknown_count(), 0);
443        }
444
445        #[test]
446        fn test_route_client_action() {
447            let router = Router::new();
448            let payload = serde_json::json!({
449                "clientToMatchServiceMessageType":
450                    "ClientToMatchServiceMessageType_ClientToGREMessage",
451                "payload": {
452                    "type": "ClientMessageType_MulliganResp",
453                    "gameStateId": 5,
454                    "respId": 1,
455                    "mulliganResp": { "decision": "MulliganOption_Mulligan" }
456                },
457                "requestId": 12345,
458                "timestamp": "637123456789"
459            });
460            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
461            let entry = unity_entry(&body);
462
463            let results = router.route(&entry);
464            assert_eq!(results.len(), 1);
465            assert!(matches!(&results[0], GameEvent::ClientAction(_)));
466        }
467
468        #[test]
469        fn test_route_match_state() {
470            let router = Router::new();
471            let payload = serde_json::json!({
472                "matchGameRoomStateChangedEvent": {
473                    "gameRoomInfo": {
474                        "stateType": "MatchGameRoomStateType_Playing",
475                        "gameRoomConfig": {
476                            "matchId": "match-123",
477                            "reservedPlayers": []
478                        }
479                    }
480                }
481            });
482            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
483            let entry = unity_entry(&body);
484
485            let results = router.route(&entry);
486            assert_eq!(results.len(), 1);
487            assert!(matches!(&results[0], GameEvent::MatchState(_)));
488        }
489
490        #[test]
491        fn test_route_session_account_update() {
492            let router = Router::new();
493            let body = "[UnityCrossThreadLogger]Updated account. \
494                         DisplayName:TestPlayer, \
495                         AccountID:abc123, \
496                         Token:sometoken";
497            let entry = unity_entry(body);
498
499            let results = router.route(&entry);
500            assert_eq!(results.len(), 1);
501            assert!(matches!(&results[0], GameEvent::Session(_)));
502        }
503
504        #[test]
505        fn test_route_rank_event() {
506            let router = Router::new();
507            let payload = serde_json::json!({
508                "constructedClass": "Gold",
509                "constructedLevel": 2,
510                "limitedClass": "Silver",
511                "limitedLevel": 1
512            });
513            let body = format!(
514                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
515                 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
516            );
517            let entry = unity_entry(&body);
518
519            let results = router.route(&entry);
520            assert_eq!(results.len(), 1);
521            assert!(matches!(&results[0], GameEvent::Rank(_)));
522        }
523
524        #[test]
525        fn test_route_event_lifecycle() {
526            let router = Router::new();
527            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
528                         ==> EventJoin {\"id\":\"abc-123\",\
529                         \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
530            let entry = unity_entry(body);
531
532            let results = router.route(&entry);
533            assert_eq!(results.len(), 1);
534            assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
535        }
536
537        #[test]
538        fn test_route_draft_complete() {
539            let router = Router::new();
540            let payload = serde_json::json!({
541                "CourseId": "draft-123",
542                "InternalEventName": "PremierDraft_MKM",
543                "CardPool": [12345, 67890]
544            });
545            let body = format!(
546                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
547                 <== DraftCompleteDraft(abc-123)\n{payload}",
548            );
549            let entry = unity_entry(&body);
550
551            let results = router.route(&entry);
552            assert_eq!(results.len(), 1);
553            assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
554        }
555
556        #[test]
557        fn test_route_draft_bot_pack_presentation() {
558            let router = Router::new();
559            let payload = serde_json::json!({
560                "DraftStatus": "PickNext",
561                "PackNumber": 0,
562                "PickNumber": 0,
563                "DraftPack": ["12345", "67890", "11111"],
564                "EventName": "QuickDraft_MKM_20260201"
565            });
566            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}",);
567            let entry = unity_entry(&body);
568
569            let results = router.route(&entry);
570            assert_eq!(results.len(), 1);
571            assert!(matches!(&results[0], GameEvent::DraftBot(_)));
572        }
573
574        #[test]
575        fn test_route_draft_human_notify() {
576            let router = Router::new();
577            let payload = serde_json::json!({
578                "draftId": "abc-123-def",
579                "SelfPack": 0,
580                "SelfPick": 0,
581                "PackCards": "12345,67890,11111"
582            });
583            let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
584            let entry = unity_entry(&body);
585
586            let results = router.route(&entry);
587            assert_eq!(results.len(), 1);
588            assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
589        }
590
591        #[test]
592        fn test_route_start_hook_with_additional_fields_routes_to_inventory() {
593            let router = Router::new();
594            let payload = serde_json::json!({
595                "InventoryInfo": { "Gems": 100 },
596                "DeckSummariesV2": []
597            });
598            let body = format!(
599                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
600                 <== StartHook(abc-123)\n{payload}",
601            );
602            let entry = unity_entry(&body);
603
604            let results = router.route(&entry);
605            assert_eq!(results.len(), 1);
606            assert!(matches!(&results[0], GameEvent::Inventory(_)));
607        }
608
609        #[test]
610        fn test_route_inventory_event() {
611            let router = Router::new();
612            let payload = serde_json::json!({
613                "InventoryInfo": { "Gems": 100, "Gold": 5000 }
614            });
615            let body = format!(
616                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
617                 <== StartHook(abc-123)\n{payload}",
618            );
619            let entry = unity_entry(&body);
620
621            let results = router.route(&entry);
622            assert_eq!(results.len(), 1);
623            assert!(matches!(&results[0], GameEvent::Inventory(_)));
624        }
625    }
626
627    // -- Router: unknown entry handling --------------------------------------
628
629    mod unknown_entries {
630        use super::*;
631
632        #[test]
633        fn test_route_unknown_entry_returns_empty() {
634            let router = Router::new();
635            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
636                         some unrecognized content here";
637            let entry = unity_entry(body);
638
639            let results = router.route(&entry);
640            assert!(results.is_empty());
641        }
642
643        #[test]
644        fn test_route_unknown_entry_increments_counter() {
645            let router = Router::new();
646            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
647                         unrecognized content";
648            let entry = unity_entry(body);
649
650            router.route(&entry);
651            assert_eq!(router.stats().unknown_count(), 1);
652            assert_eq!(router.stats().routed_count(), 0);
653        }
654
655        #[test]
656        fn test_route_multiple_unknown_entries_accumulates() {
657            let router = Router::new();
658
659            for i in 0..5 {
660                let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
661                let entry = unity_entry(&body);
662                router.route(&entry);
663            }
664
665            assert_eq!(router.stats().unknown_count(), 5);
666            assert_eq!(router.stats().routed_count(), 0);
667        }
668
669        #[test]
670        fn test_route_empty_body_after_header_returns_empty() {
671            let router = Router::new();
672            let body = "[UnityCrossThreadLogger]";
673            let entry = unity_entry(body);
674
675            let results = router.route(&entry);
676            // No timestamp -> passes None, but no parser matches.
677            assert!(results.is_empty());
678            assert_eq!(router.stats().timestamp_failure_count(), 1);
679            assert_eq!(router.stats().unknown_count(), 1);
680        }
681
682        #[test]
683        fn test_route_no_timestamp_increments_timestamp_failure() {
684            let router = Router::new();
685            let body = "[UnityCrossThreadLogger]just some text without a timestamp";
686            let entry = unity_entry(body);
687
688            let results = router.route(&entry);
689            // No parseable timestamp and no parser claims this entry.
690            assert!(results.is_empty());
691            assert_eq!(router.stats().timestamp_failure_count(), 1);
692            assert_eq!(router.stats().unknown_count(), 1);
693        }
694
695        #[test]
696        fn test_route_no_timestamp_session_still_routes() {
697            let router = Router::new();
698            // Real-world session entries without timestamps should still route.
699            let body = "[UnityCrossThreadLogger]Updated account. \
700                         DisplayName:Player, \
701                         AccountID:abc123, \
702                         Token:token";
703            let entry = unity_entry(body);
704
705            let results = router.route(&entry);
706            assert_eq!(results.len(), 1);
707            // Session routed even without a timestamp in header.
708            assert!(matches!(&results[0], GameEvent::Session(_)));
709            assert_eq!(router.stats().timestamp_failure_count(), 1);
710            assert_eq!(router.stats().routed_count(), 1);
711        }
712
713        #[test]
714        fn test_route_no_timestamp_passes_none_to_metadata() {
715            let router = Router::new();
716            // Session entries without timestamps should have None timestamp
717            // in metadata rather than a synthetic Utc::now().
718            let body = "[UnityCrossThreadLogger]Updated account. \
719                         DisplayName:Player, \
720                         AccountID:abc123, \
721                         Token:token";
722            let entry = unity_entry(body);
723
724            let results = router.route(&entry);
725            assert_eq!(results.len(), 1);
726            assert!(
727                results[0].metadata().timestamp().is_none(),
728                "entries without parseable timestamps should have None timestamp"
729            );
730        }
731
732        #[test]
733        fn test_route_with_timestamp_passes_some_to_metadata() {
734            let router = Router::new();
735            let payload = serde_json::json!({
736                "greToClientEvent": {
737                    "greToClientMessages": [{
738                        "type": "GREMessageType_GameStateMessage",
739                        "gameStateMessage": {
740                            "gameInfo": { "stage": "GameStage_Play" },
741                            "gameObjects": [],
742                            "zones": []
743                        }
744                    }]
745                }
746            });
747            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
748            let entry = unity_entry(&body);
749
750            let results = router.route(&entry);
751            assert_eq!(results.len(), 1);
752            assert!(
753                results[0].metadata().timestamp().is_some(),
754                "entries with parseable timestamps should have Some timestamp"
755            );
756        }
757    }
758
759    // -- Router: statistics --------------------------------------------------
760
761    mod stats {
762        use super::*;
763
764        #[test]
765        fn test_stats_initial_values_are_zero() {
766            let router = Router::new();
767            assert_eq!(router.stats().routed_count(), 0);
768            assert_eq!(router.stats().unknown_count(), 0);
769            assert_eq!(router.stats().timestamp_failure_count(), 0);
770        }
771
772        #[test]
773        fn test_stats_reset_clears_all_counters() {
774            let router = Router::new();
775
776            // Route a few entries to increment counters.
777            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
778            let entry = unity_entry(body);
779            router.route(&entry);
780            router.route(&entry);
781
782            assert_eq!(router.stats().unknown_count(), 2);
783
784            router.stats().reset();
785
786            assert_eq!(router.stats().routed_count(), 0);
787            assert_eq!(router.stats().unknown_count(), 0);
788            assert_eq!(router.stats().timestamp_failure_count(), 0);
789        }
790
791        #[test]
792        fn test_stats_mixed_routing() {
793            let router = Router::new();
794
795            // Route one known entry (session -- no timestamp in header).
796            let known_body = "[UnityCrossThreadLogger]Updated account. \
797                              DisplayName:Player, \
798                              AccountID:abc123, \
799                              Token:token";
800            router.route(&unity_entry(known_body));
801
802            // Route one unknown entry (with valid timestamp).
803            let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
804            router.route(&unity_entry(unknown_body));
805
806            // Route one entry with no timestamp and no parser match.
807            let bad_ts_body = "[UnityCrossThreadLogger]";
808            router.route(&unity_entry(bad_ts_body));
809
810            assert_eq!(router.stats().routed_count(), 1);
811            // Two unknown: one with valid timestamp, one with timestamp failure.
812            assert_eq!(router.stats().unknown_count(), 2);
813            // Two timestamp failures: the session entry and the empty entry.
814            assert_eq!(router.stats().timestamp_failure_count(), 2);
815        }
816    }
817
818    // -- Router: default impl -----------------------------------------------
819
820    mod default_impl {
821        use super::*;
822
823        #[test]
824        fn test_router_default_creates_functional_router() {
825            let router = Router::default();
826            assert_eq!(router.stats().routed_count(), 0);
827            assert_eq!(router.stats().unknown_count(), 0);
828        }
829    }
830
831    // -- Router: Client GRE header entries -----------------------------------
832
833    mod client_gre_entries {
834        use super::*;
835
836        #[test]
837        fn test_route_client_gre_entry() {
838            let router = Router::new();
839            let payload = serde_json::json!({
840                "greToClientEvent": {
841                    "greToClientMessages": [{
842                        "type": "GREMessageType_GameStateMessage",
843                        "gameStateMessage": {
844                            "gameInfo": { "stage": "GameStage_Play" },
845                            "gameObjects": [],
846                            "zones": []
847                        }
848                    }]
849                }
850            });
851            let body = format!("[Client GRE]2/25/2026 12:00:00 PM\n{payload}");
852            let entry = gre_entry(&body);
853
854            let results = router.route(&entry);
855            assert_eq!(results.len(), 1);
856            assert!(matches!(&results[0], GameEvent::GameState(_)));
857        }
858    }
859
860    // -- Router: Metadata header entries --------------------------------------
861
862    mod metadata_entries {
863        use super::*;
864
865        /// Helper: build a `LogEntry` with `Metadata` header.
866        fn metadata_entry(body: &str) -> LogEntry {
867            LogEntry {
868                header: EntryHeader::Metadata,
869                body: body.to_owned(),
870            }
871        }
872
873        #[test]
874        fn test_route_detailed_logs_enabled() {
875            let router = Router::new();
876            let entry = metadata_entry("DETAILED LOGS: ENABLED");
877
878            let results = router.route(&entry);
879            assert_eq!(results.len(), 1);
880            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
881            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
882                assert_eq!(e.enabled(), Some(true));
883            }
884            assert_eq!(router.stats().routed_count(), 1);
885        }
886
887        #[test]
888        fn test_route_detailed_logs_disabled() {
889            let router = Router::new();
890            let entry = metadata_entry("DETAILED LOGS: DISABLED");
891
892            let results = router.route(&entry);
893            assert_eq!(results.len(), 1);
894            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
895            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
896                assert_eq!(e.enabled(), Some(false));
897            }
898        }
899
900        #[test]
901        fn test_route_metadata_no_timestamp_failure() {
902            let router = Router::new();
903            let entry = metadata_entry("DETAILED LOGS: ENABLED");
904
905            router.route(&entry);
906            // Metadata entries have no bracket prefix for timestamp extraction,
907            // so they increment the timestamp failure counter.
908            assert_eq!(router.stats().timestamp_failure_count(), 1);
909            // But they should still be routed successfully.
910            assert_eq!(router.stats().routed_count(), 1);
911        }
912
913        #[test]
914        fn test_route_unrecognized_metadata_returns_empty() {
915            let router = Router::new();
916            let entry = metadata_entry("SOME OTHER METADATA");
917
918            let results = router.route(&entry);
919            assert!(results.is_empty());
920            assert_eq!(router.stats().unknown_count(), 1);
921        }
922    }
923}