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. Collection — card collection from `StartHook`
247/// 11. Inventory — inventory from `StartHook`
248/// 12. Match connection state — `STATE CHANGED` transitions
249/// 13. Connection close — `Client.TcpConnection.Close` / `GREConnection.HandleWebSocketClosed`
250///
251/// The GRE parser may return multiple events from a single entry
252/// (batched `GameStateMessage` values). All other parsers return at
253/// most one event.
254///
255/// `timestamp` is `None` when the log entry header did not contain a
256/// parseable timestamp; parsers pass it through to `EventMetadata`.
257fn dispatch_to_parsers(entry: &LogEntry, timestamp: Option<DateTime<Utc>>) -> Vec<GameEvent> {
258    // Metadata entries are routed directly to the metadata parser.
259    if let Some(event) = parsers::metadata::try_parse(entry, timestamp) {
260        return vec![event];
261    }
262
263    // GRE parser returns Vec<GameEvent> (may contain multiple batched GSMs).
264    let gre_events = parsers::gre::try_parse(entry, timestamp);
265    if !gre_events.is_empty() {
266        return gre_events;
267    }
268
269    // All other parsers return Option<GameEvent> (at most one event per entry).
270    let event = None
271        .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
272        .or_else(|| parsers::match_state::try_parse(entry, timestamp))
273        .or_else(|| parsers::session::try_parse(entry, timestamp))
274        .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
275        .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
276        .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
277        .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
278        .or_else(|| parsers::rank::try_parse(entry, timestamp))
279        .or_else(|| parsers::collection::try_parse(entry, timestamp))
280        .or_else(|| parsers::inventory::try_parse(entry, timestamp))
281        .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
282        .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
283        .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
284
285    event.into_iter().collect()
286}
287
288// ---------------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------------
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::log::entry::EntryHeader;
296    use chrono::Timelike;
297
298    /// Helper: build a `LogEntry` with `UnityCrossThreadLogger` header.
299    fn unity_entry(body: &str) -> LogEntry {
300        LogEntry {
301            header: EntryHeader::UnityCrossThreadLogger,
302            body: body.to_owned(),
303        }
304    }
305
306    /// Helper: build a `LogEntry` with `ClientGre` header.
307    fn gre_entry(body: &str) -> LogEntry {
308        LogEntry {
309            header: EntryHeader::ClientGre,
310            body: body.to_owned(),
311        }
312    }
313
314    // -- extract_timestamp ---------------------------------------------------
315
316    mod extract_timestamp_tests {
317        use super::*;
318
319        #[test]
320        fn test_extract_timestamp_us_format_with_pm() {
321            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
322            let ts = extract_timestamp(body);
323            assert!(ts.is_some());
324            if let Some(ts) = ts {
325                assert_eq!(
326                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
327                    "2026-02-25 12:00:00"
328                );
329            }
330        }
331
332        #[test]
333        fn test_extract_timestamp_us_format_with_am() {
334            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
335            let ts = extract_timestamp(body);
336            assert!(ts.is_some());
337            if let Some(ts) = ts {
338                assert_eq!(
339                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
340                    "2026-02-22 11:59:51"
341                );
342            }
343        }
344
345        #[test]
346        fn test_extract_timestamp_with_trailing_colon() {
347            let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
348            let ts = extract_timestamp(body);
349            assert!(ts.is_some());
350            if let Some(ts) = ts {
351                assert_eq!(ts.hour(), 23); // Should correctly identify PM
352            }
353        }
354
355        #[test]
356        fn test_extract_timestamp_24h_format() {
357            let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
358            let ts = extract_timestamp(body);
359            assert!(ts.is_some());
360            if let Some(ts) = ts {
361                assert_eq!(
362                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
363                    "2026-02-25 14:30:00"
364                );
365            }
366        }
367
368        #[test]
369        fn test_extract_timestamp_client_gre_header() {
370            let body = "[Client GRE]2/25/2026 12:00:00 PM GreToClientEvent";
371            let ts = extract_timestamp(body);
372            assert!(ts.is_some());
373        }
374
375        #[test]
376        fn test_extract_timestamp_no_bracket_returns_none() {
377            let body = "no bracket here";
378            let ts = extract_timestamp(body);
379            assert!(ts.is_none());
380        }
381
382        #[test]
383        fn test_extract_timestamp_empty_after_bracket_returns_none() {
384            let body = "[UnityCrossThreadLogger]";
385            let ts = extract_timestamp(body);
386            assert!(ts.is_none());
387        }
388
389        #[test]
390        fn test_extract_timestamp_no_timestamp_content_returns_none() {
391            let body = "[UnityCrossThreadLogger]Updated account. DisplayName:Player";
392            let ts = extract_timestamp(body);
393            assert!(ts.is_none());
394        }
395
396        #[test]
397        fn test_extract_timestamp_timestamp_on_own_line() {
398            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
399            let ts = extract_timestamp(body);
400            assert!(ts.is_some());
401            if let Some(ts) = ts {
402                assert_eq!(
403                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
404                    "2026-02-22 11:59:51"
405                );
406            }
407        }
408
409        #[test]
410        fn test_extract_timestamp_with_leading_space() {
411            let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
412            let ts = extract_timestamp(body);
413            assert!(ts.is_some());
414        }
415    }
416
417    // -- Router: known entry routing -----------------------------------------
418
419    mod known_routing {
420        use super::*;
421
422        #[test]
423        fn test_route_gre_game_state_message() {
424            let router = Router::new();
425            let payload = serde_json::json!({
426                "greToClientEvent": {
427                    "greToClientMessages": [{
428                        "type": "GREMessageType_GameStateMessage",
429                        "gameStateMessage": {
430                            "gameInfo": { "stage": "GameStage_Play" },
431                            "gameObjects": [],
432                            "zones": []
433                        }
434                    }]
435                }
436            });
437            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
438            let entry = unity_entry(&body);
439
440            let results = router.route(&entry);
441            assert_eq!(results.len(), 1);
442            assert!(matches!(&results[0], GameEvent::GameState(_)));
443            assert_eq!(router.stats().routed_count(), 1);
444            assert_eq!(router.stats().unknown_count(), 0);
445        }
446
447        #[test]
448        fn test_route_client_action() {
449            let router = Router::new();
450            let payload = serde_json::json!({
451                "clientToMatchServiceMessageType":
452                    "ClientToMatchServiceMessageType_ClientToGREMessage",
453                "payload": {
454                    "type": "ClientMessageType_MulliganResp",
455                    "gameStateId": 5,
456                    "respId": 1,
457                    "mulliganResp": { "decision": "MulliganOption_Mulligan" }
458                },
459                "requestId": 12345,
460                "timestamp": "637123456789"
461            });
462            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
463            let entry = unity_entry(&body);
464
465            let results = router.route(&entry);
466            assert_eq!(results.len(), 1);
467            assert!(matches!(&results[0], GameEvent::ClientAction(_)));
468        }
469
470        #[test]
471        fn test_route_match_state() {
472            let router = Router::new();
473            let payload = serde_json::json!({
474                "matchGameRoomStateChangedEvent": {
475                    "gameRoomInfo": {
476                        "stateType": "MatchGameRoomStateType_Playing",
477                        "gameRoomConfig": {
478                            "matchId": "match-123",
479                            "reservedPlayers": []
480                        }
481                    }
482                }
483            });
484            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
485            let entry = unity_entry(&body);
486
487            let results = router.route(&entry);
488            assert_eq!(results.len(), 1);
489            assert!(matches!(&results[0], GameEvent::MatchState(_)));
490        }
491
492        #[test]
493        fn test_route_session_account_update() {
494            let router = Router::new();
495            let body = "[UnityCrossThreadLogger]Updated account. \
496                         DisplayName:TestPlayer, \
497                         AccountID:abc123, \
498                         Token:sometoken";
499            let entry = unity_entry(body);
500
501            let results = router.route(&entry);
502            assert_eq!(results.len(), 1);
503            assert!(matches!(&results[0], GameEvent::Session(_)));
504        }
505
506        #[test]
507        fn test_route_rank_event() {
508            let router = Router::new();
509            let payload = serde_json::json!({
510                "constructedClass": "Gold",
511                "constructedLevel": 2,
512                "limitedClass": "Silver",
513                "limitedLevel": 1
514            });
515            let body = format!(
516                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
517                 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
518            );
519            let entry = unity_entry(&body);
520
521            let results = router.route(&entry);
522            assert_eq!(results.len(), 1);
523            assert!(matches!(&results[0], GameEvent::Rank(_)));
524        }
525
526        #[test]
527        fn test_route_event_lifecycle() {
528            let router = Router::new();
529            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
530                         ==> EventJoin {\"id\":\"abc-123\",\
531                         \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
532            let entry = unity_entry(body);
533
534            let results = router.route(&entry);
535            assert_eq!(results.len(), 1);
536            assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
537        }
538
539        #[test]
540        fn test_route_draft_complete() {
541            let router = Router::new();
542            let payload = serde_json::json!({
543                "CourseId": "draft-123",
544                "InternalEventName": "PremierDraft_MKM",
545                "CardPool": [12345, 67890]
546            });
547            let body = format!(
548                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
549                 <== DraftCompleteDraft(abc-123)\n{payload}",
550            );
551            let entry = unity_entry(&body);
552
553            let results = router.route(&entry);
554            assert_eq!(results.len(), 1);
555            assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
556        }
557
558        #[test]
559        fn test_route_draft_bot_pack_presentation() {
560            let router = Router::new();
561            let payload = serde_json::json!({
562                "DraftStatus": "PickNext",
563                "PackNumber": 0,
564                "PickNumber": 0,
565                "DraftPack": ["12345", "67890", "11111"],
566                "EventName": "QuickDraft_MKM_20260201"
567            });
568            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}",);
569            let entry = unity_entry(&body);
570
571            let results = router.route(&entry);
572            assert_eq!(results.len(), 1);
573            assert!(matches!(&results[0], GameEvent::DraftBot(_)));
574        }
575
576        #[test]
577        fn test_route_draft_human_notify() {
578            let router = Router::new();
579            let payload = serde_json::json!({
580                "draftId": "abc-123-def",
581                "SelfPack": 0,
582                "SelfPick": 0,
583                "PackCards": "12345,67890,11111"
584            });
585            let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
586            let entry = unity_entry(&body);
587
588            let results = router.route(&entry);
589            assert_eq!(results.len(), 1);
590            assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
591        }
592
593        #[test]
594        fn test_route_collection_event() {
595            let router = Router::new();
596            let payload = serde_json::json!({
597                "PlayerCards": { "98535": 4, "12345": 2 },
598                "InventoryInfo": { "Gems": 100 }
599            });
600            let body = format!(
601                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
602                 <== StartHook(abc-123)\n{payload}",
603            );
604            let entry = unity_entry(&body);
605
606            let results = router.route(&entry);
607            assert_eq!(results.len(), 1);
608            // StartHook with PlayerCards goes to Collection (tried before Inventory).
609            assert!(matches!(&results[0], GameEvent::Collection(_)));
610        }
611
612        #[test]
613        fn test_route_inventory_event() {
614            let router = Router::new();
615            let payload = serde_json::json!({
616                "InventoryInfo": { "Gems": 100, "Gold": 5000 }
617            });
618            let body = format!(
619                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
620                 <== StartHook(abc-123)\n{payload}",
621            );
622            let entry = unity_entry(&body);
623
624            let results = router.route(&entry);
625            assert_eq!(results.len(), 1);
626            // StartHook with InventoryInfo but no PlayerCards goes to Inventory.
627            assert!(matches!(&results[0], GameEvent::Inventory(_)));
628        }
629    }
630
631    // -- Router: unknown entry handling --------------------------------------
632
633    mod unknown_entries {
634        use super::*;
635
636        #[test]
637        fn test_route_unknown_entry_returns_empty() {
638            let router = Router::new();
639            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
640                         some unrecognized content here";
641            let entry = unity_entry(body);
642
643            let results = router.route(&entry);
644            assert!(results.is_empty());
645        }
646
647        #[test]
648        fn test_route_unknown_entry_increments_counter() {
649            let router = Router::new();
650            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
651                         unrecognized content";
652            let entry = unity_entry(body);
653
654            router.route(&entry);
655            assert_eq!(router.stats().unknown_count(), 1);
656            assert_eq!(router.stats().routed_count(), 0);
657        }
658
659        #[test]
660        fn test_route_multiple_unknown_entries_accumulates() {
661            let router = Router::new();
662
663            for i in 0..5 {
664                let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
665                let entry = unity_entry(&body);
666                router.route(&entry);
667            }
668
669            assert_eq!(router.stats().unknown_count(), 5);
670            assert_eq!(router.stats().routed_count(), 0);
671        }
672
673        #[test]
674        fn test_route_empty_body_after_header_returns_empty() {
675            let router = Router::new();
676            let body = "[UnityCrossThreadLogger]";
677            let entry = unity_entry(body);
678
679            let results = router.route(&entry);
680            // No timestamp -> passes None, but no parser matches.
681            assert!(results.is_empty());
682            assert_eq!(router.stats().timestamp_failure_count(), 1);
683            assert_eq!(router.stats().unknown_count(), 1);
684        }
685
686        #[test]
687        fn test_route_no_timestamp_increments_timestamp_failure() {
688            let router = Router::new();
689            let body = "[UnityCrossThreadLogger]just some text without a timestamp";
690            let entry = unity_entry(body);
691
692            let results = router.route(&entry);
693            // No parseable timestamp and no parser claims this entry.
694            assert!(results.is_empty());
695            assert_eq!(router.stats().timestamp_failure_count(), 1);
696            assert_eq!(router.stats().unknown_count(), 1);
697        }
698
699        #[test]
700        fn test_route_no_timestamp_session_still_routes() {
701            let router = Router::new();
702            // Real-world session entries without timestamps should still route.
703            let body = "[UnityCrossThreadLogger]Updated account. \
704                         DisplayName:Player, \
705                         AccountID:abc123, \
706                         Token:token";
707            let entry = unity_entry(body);
708
709            let results = router.route(&entry);
710            assert_eq!(results.len(), 1);
711            // Session routed even without a timestamp in header.
712            assert!(matches!(&results[0], GameEvent::Session(_)));
713            assert_eq!(router.stats().timestamp_failure_count(), 1);
714            assert_eq!(router.stats().routed_count(), 1);
715        }
716
717        #[test]
718        fn test_route_no_timestamp_passes_none_to_metadata() {
719            let router = Router::new();
720            // Session entries without timestamps should have None timestamp
721            // in metadata rather than a synthetic Utc::now().
722            let body = "[UnityCrossThreadLogger]Updated account. \
723                         DisplayName:Player, \
724                         AccountID:abc123, \
725                         Token:token";
726            let entry = unity_entry(body);
727
728            let results = router.route(&entry);
729            assert_eq!(results.len(), 1);
730            assert!(
731                results[0].metadata().timestamp().is_none(),
732                "entries without parseable timestamps should have None timestamp"
733            );
734        }
735
736        #[test]
737        fn test_route_with_timestamp_passes_some_to_metadata() {
738            let router = Router::new();
739            let payload = serde_json::json!({
740                "greToClientEvent": {
741                    "greToClientMessages": [{
742                        "type": "GREMessageType_GameStateMessage",
743                        "gameStateMessage": {
744                            "gameInfo": { "stage": "GameStage_Play" },
745                            "gameObjects": [],
746                            "zones": []
747                        }
748                    }]
749                }
750            });
751            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
752            let entry = unity_entry(&body);
753
754            let results = router.route(&entry);
755            assert_eq!(results.len(), 1);
756            assert!(
757                results[0].metadata().timestamp().is_some(),
758                "entries with parseable timestamps should have Some timestamp"
759            );
760        }
761    }
762
763    // -- Router: statistics --------------------------------------------------
764
765    mod stats {
766        use super::*;
767
768        #[test]
769        fn test_stats_initial_values_are_zero() {
770            let router = Router::new();
771            assert_eq!(router.stats().routed_count(), 0);
772            assert_eq!(router.stats().unknown_count(), 0);
773            assert_eq!(router.stats().timestamp_failure_count(), 0);
774        }
775
776        #[test]
777        fn test_stats_reset_clears_all_counters() {
778            let router = Router::new();
779
780            // Route a few entries to increment counters.
781            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
782            let entry = unity_entry(body);
783            router.route(&entry);
784            router.route(&entry);
785
786            assert_eq!(router.stats().unknown_count(), 2);
787
788            router.stats().reset();
789
790            assert_eq!(router.stats().routed_count(), 0);
791            assert_eq!(router.stats().unknown_count(), 0);
792            assert_eq!(router.stats().timestamp_failure_count(), 0);
793        }
794
795        #[test]
796        fn test_stats_mixed_routing() {
797            let router = Router::new();
798
799            // Route one known entry (session -- no timestamp in header).
800            let known_body = "[UnityCrossThreadLogger]Updated account. \
801                              DisplayName:Player, \
802                              AccountID:abc123, \
803                              Token:token";
804            router.route(&unity_entry(known_body));
805
806            // Route one unknown entry (with valid timestamp).
807            let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
808            router.route(&unity_entry(unknown_body));
809
810            // Route one entry with no timestamp and no parser match.
811            let bad_ts_body = "[UnityCrossThreadLogger]";
812            router.route(&unity_entry(bad_ts_body));
813
814            assert_eq!(router.stats().routed_count(), 1);
815            // Two unknown: one with valid timestamp, one with timestamp failure.
816            assert_eq!(router.stats().unknown_count(), 2);
817            // Two timestamp failures: the session entry and the empty entry.
818            assert_eq!(router.stats().timestamp_failure_count(), 2);
819        }
820    }
821
822    // -- Router: default impl -----------------------------------------------
823
824    mod default_impl {
825        use super::*;
826
827        #[test]
828        fn test_router_default_creates_functional_router() {
829            let router = Router::default();
830            assert_eq!(router.stats().routed_count(), 0);
831            assert_eq!(router.stats().unknown_count(), 0);
832        }
833    }
834
835    // -- Router: Client GRE header entries -----------------------------------
836
837    mod client_gre_entries {
838        use super::*;
839
840        #[test]
841        fn test_route_client_gre_entry() {
842            let router = Router::new();
843            let payload = serde_json::json!({
844                "greToClientEvent": {
845                    "greToClientMessages": [{
846                        "type": "GREMessageType_GameStateMessage",
847                        "gameStateMessage": {
848                            "gameInfo": { "stage": "GameStage_Play" },
849                            "gameObjects": [],
850                            "zones": []
851                        }
852                    }]
853                }
854            });
855            let body = format!("[Client GRE]2/25/2026 12:00:00 PM\n{payload}");
856            let entry = gre_entry(&body);
857
858            let results = router.route(&entry);
859            assert_eq!(results.len(), 1);
860            assert!(matches!(&results[0], GameEvent::GameState(_)));
861        }
862    }
863
864    // -- Router: Metadata header entries --------------------------------------
865
866    mod metadata_entries {
867        use super::*;
868
869        /// Helper: build a `LogEntry` with `Metadata` header.
870        fn metadata_entry(body: &str) -> LogEntry {
871            LogEntry {
872                header: EntryHeader::Metadata,
873                body: body.to_owned(),
874            }
875        }
876
877        #[test]
878        fn test_route_detailed_logs_enabled() {
879            let router = Router::new();
880            let entry = metadata_entry("DETAILED LOGS: ENABLED");
881
882            let results = router.route(&entry);
883            assert_eq!(results.len(), 1);
884            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
885            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
886                assert_eq!(e.enabled(), Some(true));
887            }
888            assert_eq!(router.stats().routed_count(), 1);
889        }
890
891        #[test]
892        fn test_route_detailed_logs_disabled() {
893            let router = Router::new();
894            let entry = metadata_entry("DETAILED LOGS: DISABLED");
895
896            let results = router.route(&entry);
897            assert_eq!(results.len(), 1);
898            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
899            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
900                assert_eq!(e.enabled(), Some(false));
901            }
902        }
903
904        #[test]
905        fn test_route_metadata_no_timestamp_failure() {
906            let router = Router::new();
907            let entry = metadata_entry("DETAILED LOGS: ENABLED");
908
909            router.route(&entry);
910            // Metadata entries have no bracket prefix for timestamp extraction,
911            // so they increment the timestamp failure counter.
912            assert_eq!(router.stats().timestamp_failure_count(), 1);
913            // But they should still be routed successfully.
914            assert_eq!(router.stats().routed_count(), 1);
915        }
916
917        #[test]
918        fn test_route_unrecognized_metadata_returns_empty() {
919            let router = Router::new();
920            let entry = metadata_entry("SOME OTHER METADATA");
921
922            let results = router.route(&entry);
923            assert!(results.is_empty());
924            assert_eq!(router.stats().unknown_count(), 1);
925        }
926    }
927}