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/// [UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to ...
190/// ```
191///
192/// Strips the bracket-enclosed header prefix and extracts the date/time
193/// portion that follows. The timestamp string may be followed by
194/// additional content on the same line (event name, method name, etc.)
195/// or by a newline if the timestamp is on its own line.
196///
197/// Trims trailing punctuation (like colons in `... PM: MatchGameRoom...`) from
198/// the extracted tokens before parsing to ensure robust matching.
199fn extract_timestamp(body: &str) -> Option<DateTime<Utc>> {
200    let first_line = body.lines().next()?;
201
202    // Strip the header prefix: find the closing `]` bracket.
203    let after_bracket = first_line.find(']').map(|pos| &first_line[pos + 1..])?;
204    let trimmed = after_bracket.trim();
205
206    if trimmed.is_empty() {
207        return None;
208    }
209
210    // The timestamp may be followed by additional text (event name, etc.).
211    // Try progressively shorter prefixes to find a valid timestamp.
212    // Start with the full string and remove trailing words one at a time.
213    let words: Vec<&str> = trimmed.split_whitespace().collect();
214
215    // Timestamps typically use 2-3 tokens (date + time, or date + time + AM/PM).
216    // Try from longest plausible prefix down to 2 tokens.
217    let max_words = words.len().min(4);
218    for end in (2..=max_words).rev() {
219        let candidate = words[..end].join(" ");
220        // Ensure trailing punctuation (like colons after AM/PM) doesn't break parsing.
221        let cleaned = candidate.trim_end_matches(|c: char| c.is_ascii_punctuation());
222        if let Ok(ts) = parse_log_timestamp(cleaned) {
223            return Some(ts);
224        }
225    }
226
227    None
228}
229
230/// Dispatches a log entry to category parsers in priority order.
231///
232/// Parsers are tried in order of expected frequency during typical
233/// gameplay to minimize unnecessary parse attempts:
234///
235/// 0. Metadata — `DETAILED LOGS` status (header-type short-circuit)
236/// 1. GRE messages (game state + game result) — most frequent in-game
237/// 2. Client actions — frequent player decisions
238/// 3. Match state — match boundaries
239/// 4. Session — login/logout
240/// 5. Draft bot — bot draft picks
241/// 6. Draft human — human draft picks
242/// 7. Draft complete — draft completion
243/// 8. Event lifecycle — event joins/claims
244/// 9. Rank — rank snapshots
245/// 10. Deck collection — deck snapshots from `StartHook`
246/// 11. Inventory — inventory from `StartHook`
247/// 12. Deck submission — `EventSetDeck` family (V2/V3/future Vn) requests
248/// 13. Match connection state — `STATE CHANGED` transitions
249/// 14. 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    // Truncation marker entries — structurally a metadata-style entry (no
264    // JSON body, header-only data) so dispatched alongside metadata before
265    // the GRE path. The parser gates on `EntryHeader::TruncationMarker`, so
266    // non-truncation entries pass straight through.
267    if let Some(event) = parsers::truncation::try_parse(entry, timestamp) {
268        return vec![event];
269    }
270
271    // GRE parser returns Vec<GameEvent> (may contain multiple batched GSMs).
272    let gre_events = parsers::gre::try_parse(entry, timestamp);
273    if !gre_events.is_empty() {
274        return gre_events;
275    }
276
277    // All other parsers return Option<GameEvent> (at most one event per entry).
278    let event = None
279        .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
280        .or_else(|| parsers::match_state::try_parse(entry, timestamp))
281        .or_else(|| parsers::session::try_parse(entry, timestamp))
282        .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
283        .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
284        .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
285        .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
286        .or_else(|| parsers::rank::try_parse(entry, timestamp))
287        .or_else(|| parsers::deck_collection::try_parse(entry, timestamp))
288        .or_else(|| parsers::inventory::try_parse(entry, timestamp))
289        .or_else(|| parsers::deck_submission::try_parse(entry, timestamp))
290        .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
291        .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
292        .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
293
294    event.into_iter().collect()
295}
296
297// ---------------------------------------------------------------------------
298// Tests
299// ---------------------------------------------------------------------------
300
301#[cfg(test)]
302#[allow(deprecated)]
303mod tests {
304    use super::*;
305    use crate::log::entry::EntryHeader;
306    use chrono::Timelike;
307
308    /// Helper: build a `LogEntry` with `UnityCrossThreadLogger` header.
309    fn unity_entry(body: &str) -> LogEntry {
310        LogEntry {
311            header: EntryHeader::UnityCrossThreadLogger,
312            body: body.to_owned(),
313        }
314    }
315
316    // -- extract_timestamp ---------------------------------------------------
317
318    mod extract_timestamp_tests {
319        use super::*;
320
321        #[test]
322        fn test_extract_timestamp_us_format_with_pm() {
323            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
324            let ts = extract_timestamp(body);
325            assert!(ts.is_some());
326            if let Some(ts) = ts {
327                assert_eq!(
328                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
329                    "2026-02-25 12:00:00"
330                );
331            }
332        }
333
334        #[test]
335        fn test_extract_timestamp_us_format_with_am() {
336            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
337            let ts = extract_timestamp(body);
338            assert!(ts.is_some());
339            if let Some(ts) = ts {
340                assert_eq!(
341                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
342                    "2026-02-22 11:59:51"
343                );
344            }
345        }
346
347        #[test]
348        fn test_extract_timestamp_with_trailing_colon() {
349            let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
350            let ts = extract_timestamp(body);
351            assert!(ts.is_some());
352            if let Some(ts) = ts {
353                assert_eq!(ts.hour(), 23); // Should correctly identify PM
354            }
355        }
356
357        #[test]
358        fn test_extract_timestamp_24h_format() {
359            let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
360            let ts = extract_timestamp(body);
361            assert!(ts.is_some());
362            if let Some(ts) = ts {
363                assert_eq!(
364                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
365                    "2026-02-25 14:30:00"
366                );
367            }
368        }
369
370        #[test]
371        fn test_extract_timestamp_no_bracket_returns_none() {
372            let body = "no bracket here";
373            let ts = extract_timestamp(body);
374            assert!(ts.is_none());
375        }
376
377        #[test]
378        fn test_extract_timestamp_empty_after_bracket_returns_none() {
379            let body = "[UnityCrossThreadLogger]";
380            let ts = extract_timestamp(body);
381            assert!(ts.is_none());
382        }
383
384        #[test]
385        fn test_extract_timestamp_no_timestamp_content_returns_none() {
386            let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
387            let ts = extract_timestamp(body);
388            assert!(ts.is_none());
389        }
390
391        #[test]
392        fn test_extract_timestamp_timestamp_on_own_line() {
393            let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
394            let ts = extract_timestamp(body);
395            assert!(ts.is_some());
396            if let Some(ts) = ts {
397                assert_eq!(
398                    ts.format("%Y-%m-%d %H:%M:%S").to_string(),
399                    "2026-02-22 11:59:51"
400                );
401            }
402        }
403
404        #[test]
405        fn test_extract_timestamp_with_leading_space() {
406            let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
407            let ts = extract_timestamp(body);
408            assert!(ts.is_some());
409        }
410    }
411
412    // -- Router: known entry routing -----------------------------------------
413
414    mod known_routing {
415        use super::*;
416
417        #[test]
418        fn test_route_gre_game_state_message() {
419            let router = Router::new();
420            let payload = serde_json::json!({
421                "greToClientEvent": {
422                    "greToClientMessages": [{
423                        "type": "GREMessageType_GameStateMessage",
424                        "gameStateMessage": {
425                            "gameInfo": { "stage": "GameStage_Play" },
426                            "gameObjects": [],
427                            "zones": []
428                        }
429                    }]
430                }
431            });
432            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
433            let entry = unity_entry(&body);
434
435            let results = router.route(&entry);
436            assert_eq!(results.len(), 1);
437            assert!(matches!(&results[0], GameEvent::GameState(_)));
438            assert_eq!(router.stats().routed_count(), 1);
439            assert_eq!(router.stats().unknown_count(), 0);
440        }
441
442        #[test]
443        fn test_route_client_action() {
444            let router = Router::new();
445            let payload = serde_json::json!({
446                "clientToMatchServiceMessageType":
447                    "ClientToMatchServiceMessageType_ClientToGREMessage",
448                "payload": {
449                    "type": "ClientMessageType_MulliganResp",
450                    "gameStateId": 5,
451                    "respId": 1,
452                    "mulliganResp": { "decision": "MulliganOption_Mulligan" }
453                },
454                "requestId": 12345,
455                "timestamp": "637123456789"
456            });
457            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
458            let entry = unity_entry(&body);
459
460            let results = router.route(&entry);
461            assert_eq!(results.len(), 1);
462            assert!(matches!(&results[0], GameEvent::ClientAction(_)));
463        }
464
465        #[test]
466        fn test_route_match_state() {
467            let router = Router::new();
468            let payload = serde_json::json!({
469                "matchGameRoomStateChangedEvent": {
470                    "gameRoomInfo": {
471                        "stateType": "MatchGameRoomStateType_Playing",
472                        "gameRoomConfig": {
473                            "matchId": "match-123",
474                            "reservedPlayers": []
475                        }
476                    }
477                }
478            });
479            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
480            let entry = unity_entry(&body);
481
482            let results = router.route(&entry);
483            assert_eq!(results.len(), 1);
484            assert!(matches!(&results[0], GameEvent::MatchState(_)));
485        }
486
487        #[test]
488        fn test_route_session_authenticate_response() {
489            let router = Router::new();
490            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
491                         {\"screenName\":\"TestPlayer\"}";
492            let entry = unity_entry(body);
493
494            let results = router.route(&entry);
495            assert_eq!(results.len(), 1);
496            assert!(matches!(&results[0], GameEvent::Session(_)));
497        }
498
499        #[test]
500        fn test_route_rank_event() {
501            let router = Router::new();
502            let payload = serde_json::json!({
503                "constructedClass": "Gold",
504                "constructedLevel": 2,
505                "limitedClass": "Silver",
506                "limitedLevel": 1
507            });
508            let body = format!(
509                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
510                 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
511            );
512            let entry = unity_entry(&body);
513
514            let results = router.route(&entry);
515            assert_eq!(results.len(), 1);
516            assert!(matches!(&results[0], GameEvent::Rank(_)));
517        }
518
519        #[test]
520        fn test_route_event_lifecycle() {
521            let router = Router::new();
522            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
523                         ==> EventJoin {\"id\":\"abc-123\",\
524                         \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
525            let entry = unity_entry(body);
526
527            let results = router.route(&entry);
528            assert_eq!(results.len(), 1);
529            assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
530        }
531
532        #[test]
533        fn test_route_draft_complete() {
534            let router = Router::new();
535            let payload = serde_json::json!({
536                "CourseId": "draft-123",
537                "InternalEventName": "PremierDraft_MKM",
538                "CardPool": [12345, 67890]
539            });
540            let body = format!(
541                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
542                 <== DraftCompleteDraft(abc-123)\n{payload}",
543            );
544            let entry = unity_entry(&body);
545
546            let results = router.route(&entry);
547            assert_eq!(results.len(), 1);
548            assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
549        }
550
551        #[test]
552        fn test_route_draft_bot_pack_presentation() {
553            let router = Router::new();
554            let payload = serde_json::json!({
555                "CurrentModule": "BotDraft",
556                "Payload":"{\"DraftStatus\":\"PickNext\",\"PackNumber\":0,\"PickNumber\":0,\"DraftPack\":[\"12345\",\"67890\",\"11111\"]}"
557            });
558            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n<== BotDraftDraftStatus(uuid)\n{payload}",);
559            let entry = unity_entry(&body);
560
561            let results = router.route(&entry);
562            assert_eq!(results.len(), 1);
563            assert!(matches!(&results[0], GameEvent::DraftBot(_)));
564        }
565
566        #[test]
567        fn test_route_draft_human_notify() {
568            let router = Router::new();
569            let payload = serde_json::json!({
570                "draftId": "abc-123-def",
571                "SelfPack": 0,
572                "SelfPick": 0,
573                "PackCards": "12345,67890,11111"
574            });
575            let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
576            let entry = unity_entry(&body);
577
578            let results = router.route(&entry);
579            assert_eq!(results.len(), 1);
580            assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
581        }
582
583        #[test]
584        fn test_route_start_hook_with_additional_fields_routes_to_inventory() {
585            let router = Router::new();
586            let payload = serde_json::json!({
587                "InventoryInfo": { "Gems": 100 },
588                "DeckSummariesV2": []
589            });
590            let body = format!(
591                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
592                 <== StartHook(abc-123)\n{payload}",
593            );
594            let entry = unity_entry(&body);
595
596            let results = router.route(&entry);
597            assert_eq!(results.len(), 1);
598            assert!(matches!(&results[0], GameEvent::Inventory(_)));
599        }
600
601        #[test]
602        fn test_route_inventory_event() {
603            let router = Router::new();
604            let payload = serde_json::json!({
605                "InventoryInfo": { "Gems": 100, "Gold": 5000 }
606            });
607            let body = format!(
608                "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
609                 <== StartHook(abc-123)\n{payload}",
610            );
611            let entry = unity_entry(&body);
612
613            let results = router.route(&entry);
614            assert_eq!(results.len(), 1);
615            assert!(matches!(&results[0], GameEvent::Inventory(_)));
616        }
617    }
618
619    // -- Router: unknown entry handling --------------------------------------
620
621    mod unknown_entries {
622        use super::*;
623
624        #[test]
625        fn test_route_unknown_entry_returns_empty() {
626            let router = Router::new();
627            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
628                         some unrecognized content here";
629            let entry = unity_entry(body);
630
631            let results = router.route(&entry);
632            assert!(results.is_empty());
633        }
634
635        #[test]
636        fn test_route_unknown_entry_increments_counter() {
637            let router = Router::new();
638            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
639                         unrecognized content";
640            let entry = unity_entry(body);
641
642            router.route(&entry);
643            assert_eq!(router.stats().unknown_count(), 1);
644            assert_eq!(router.stats().routed_count(), 0);
645        }
646
647        #[test]
648        fn test_route_multiple_unknown_entries_accumulates() {
649            let router = Router::new();
650
651            for i in 0..5 {
652                let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
653                let entry = unity_entry(&body);
654                router.route(&entry);
655            }
656
657            assert_eq!(router.stats().unknown_count(), 5);
658            assert_eq!(router.stats().routed_count(), 0);
659        }
660
661        #[test]
662        fn test_route_empty_body_after_header_returns_empty() {
663            let router = Router::new();
664            let body = "[UnityCrossThreadLogger]";
665            let entry = unity_entry(body);
666
667            let results = router.route(&entry);
668            // No timestamp -> passes None, but no parser matches.
669            assert!(results.is_empty());
670            assert_eq!(router.stats().timestamp_failure_count(), 1);
671            assert_eq!(router.stats().unknown_count(), 1);
672        }
673
674        #[test]
675        fn test_route_no_timestamp_increments_timestamp_failure() {
676            let router = Router::new();
677            let body = "[UnityCrossThreadLogger]just some text without a timestamp";
678            let entry = unity_entry(body);
679
680            let results = router.route(&entry);
681            // No parseable timestamp and no parser claims this entry.
682            assert!(results.is_empty());
683            assert_eq!(router.stats().timestamp_failure_count(), 1);
684            assert_eq!(router.stats().unknown_count(), 1);
685        }
686
687        #[test]
688        fn test_route_no_timestamp_session_still_routes() {
689            let router = Router::new();
690            // Real-world session entries without timestamps should still route.
691            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
692                         {\"screenName\":\"Player\"}";
693            let entry = unity_entry(body);
694
695            let results = router.route(&entry);
696            assert_eq!(results.len(), 1);
697            // Session routed even without a timestamp in header.
698            assert!(matches!(&results[0], GameEvent::Session(_)));
699            assert_eq!(router.stats().timestamp_failure_count(), 1);
700            assert_eq!(router.stats().routed_count(), 1);
701        }
702
703        #[test]
704        fn test_route_no_timestamp_passes_none_to_metadata() {
705            let router = Router::new();
706            // Session entries without timestamps should have None timestamp
707            // in metadata rather than a synthetic Utc::now().
708            let body = "[UnityCrossThreadLogger]authenticateResponse\n\
709                         {\"screenName\":\"Player\"}";
710            let entry = unity_entry(body);
711
712            let results = router.route(&entry);
713            assert_eq!(results.len(), 1);
714            assert!(
715                results[0].metadata().timestamp().is_none(),
716                "entries without parseable timestamps should have None timestamp"
717            );
718        }
719
720        #[test]
721        fn test_route_with_timestamp_passes_some_to_metadata() {
722            let router = Router::new();
723            let payload = serde_json::json!({
724                "greToClientEvent": {
725                    "greToClientMessages": [{
726                        "type": "GREMessageType_GameStateMessage",
727                        "gameStateMessage": {
728                            "gameInfo": { "stage": "GameStage_Play" },
729                            "gameObjects": [],
730                            "zones": []
731                        }
732                    }]
733                }
734            });
735            let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
736            let entry = unity_entry(&body);
737
738            let results = router.route(&entry);
739            assert_eq!(results.len(), 1);
740            assert!(
741                results[0].metadata().timestamp().is_some(),
742                "entries with parseable timestamps should have Some timestamp"
743            );
744        }
745    }
746
747    // -- Router: statistics --------------------------------------------------
748
749    mod stats {
750        use super::*;
751
752        #[test]
753        fn test_stats_initial_values_are_zero() {
754            let router = Router::new();
755            assert_eq!(router.stats().routed_count(), 0);
756            assert_eq!(router.stats().unknown_count(), 0);
757            assert_eq!(router.stats().timestamp_failure_count(), 0);
758        }
759
760        #[test]
761        fn test_stats_reset_clears_all_counters() {
762            let router = Router::new();
763
764            // Route a few entries to increment counters.
765            let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
766            let entry = unity_entry(body);
767            router.route(&entry);
768            router.route(&entry);
769
770            assert_eq!(router.stats().unknown_count(), 2);
771
772            router.stats().reset();
773
774            assert_eq!(router.stats().routed_count(), 0);
775            assert_eq!(router.stats().unknown_count(), 0);
776            assert_eq!(router.stats().timestamp_failure_count(), 0);
777        }
778
779        #[test]
780        fn test_stats_mixed_routing() {
781            let router = Router::new();
782
783            // Route one known entry (session -- no timestamp in header).
784            let known_body = "[UnityCrossThreadLogger]authenticateResponse\n\
785                              {\"screenName\":\"Player\"}";
786            router.route(&unity_entry(known_body));
787
788            // Route one unknown entry (with valid timestamp).
789            let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
790            router.route(&unity_entry(unknown_body));
791
792            // Route one entry with no timestamp and no parser match.
793            let bad_ts_body = "[UnityCrossThreadLogger]";
794            router.route(&unity_entry(bad_ts_body));
795
796            assert_eq!(router.stats().routed_count(), 1);
797            // Two unknown: one with valid timestamp, one with timestamp failure.
798            assert_eq!(router.stats().unknown_count(), 2);
799            // Two timestamp failures: the session entry and the empty entry.
800            assert_eq!(router.stats().timestamp_failure_count(), 2);
801        }
802    }
803
804    // -- Router: default impl -----------------------------------------------
805
806    mod default_impl {
807        use super::*;
808
809        #[test]
810        fn test_router_default_creates_functional_router() {
811            let router = Router::default();
812            assert_eq!(router.stats().routed_count(), 0);
813            assert_eq!(router.stats().unknown_count(), 0);
814        }
815    }
816
817    // -- Router: Metadata header entries --------------------------------------
818
819    mod metadata_entries {
820        use super::*;
821
822        /// Helper: build a `LogEntry` with `Metadata` header.
823        fn metadata_entry(body: &str) -> LogEntry {
824            LogEntry {
825                header: EntryHeader::Metadata,
826                body: body.to_owned(),
827            }
828        }
829
830        #[test]
831        fn test_route_detailed_logs_enabled() {
832            let router = Router::new();
833            let entry = metadata_entry("DETAILED LOGS: ENABLED");
834
835            let results = router.route(&entry);
836            assert_eq!(results.len(), 1);
837            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
838            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
839                assert_eq!(e.enabled(), Some(true));
840            }
841            assert_eq!(router.stats().routed_count(), 1);
842        }
843
844        #[test]
845        fn test_route_detailed_logs_disabled() {
846            let router = Router::new();
847            let entry = metadata_entry("DETAILED LOGS: DISABLED");
848
849            let results = router.route(&entry);
850            assert_eq!(results.len(), 1);
851            assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
852            if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
853                assert_eq!(e.enabled(), Some(false));
854            }
855        }
856
857        #[test]
858        fn test_route_metadata_no_timestamp_failure() {
859            let router = Router::new();
860            let entry = metadata_entry("DETAILED LOGS: ENABLED");
861
862            router.route(&entry);
863            // Metadata entries have no bracket prefix for timestamp extraction,
864            // so they increment the timestamp failure counter.
865            assert_eq!(router.stats().timestamp_failure_count(), 1);
866            // But they should still be routed successfully.
867            assert_eq!(router.stats().routed_count(), 1);
868        }
869
870        #[test]
871        fn test_route_unrecognized_metadata_returns_empty() {
872            let router = Router::new();
873            let entry = metadata_entry("SOME OTHER METADATA");
874
875            let results = router.route(&entry);
876            assert!(results.is_empty());
877            assert_eq!(router.stats().unknown_count(), 1);
878        }
879    }
880
881    // -- Router: GSM truncation marker entries (#200) ------------------------
882
883    mod truncation_marker_entries {
884        use super::*;
885
886        fn truncation_entry(body: &str) -> LogEntry {
887            LogEntry {
888                header: EntryHeader::TruncationMarker,
889                body: body.to_owned(),
890            }
891        }
892
893        fn marker_body(object_count: u32, annotation_count: u32) -> String {
894            format!(
895                "[Message summarized because one or more GameStateMessages \
896                 exceeded the 50 GameObject or 50 Annotation limit.]\n\
897                 ::: GameStateMessage\n\
898                 :: GameObject Count = {object_count}\n\
899                 :: Annotation Count = {annotation_count}\n\
900                 ::: ActionsAvailableReq"
901            )
902        }
903
904        #[test]
905        fn test_route_truncation_marker_emits_truncation_event() {
906            let router = Router::new();
907            let entry = truncation_entry(&marker_body(63, 4));
908
909            let results = router.route(&entry);
910            assert_eq!(results.len(), 1);
911            assert!(matches!(&results[0], GameEvent::Truncation(_)));
912            assert_eq!(router.stats().routed_count(), 1);
913            assert_eq!(router.stats().unknown_count(), 0);
914        }
915
916        #[test]
917        fn test_route_truncation_marker_preserves_counts() {
918            let router = Router::new();
919            let entry = truncation_entry(&marker_body(63, 4));
920
921            let results = router.route(&entry);
922            assert_eq!(results.len(), 1);
923            let GameEvent::Truncation(ref event) = results[0] else {
924                unreachable!("expected Truncation event");
925            };
926            assert_eq!(event.object_count(), Some(63));
927            assert_eq!(event.annotation_count(), Some(4));
928        }
929
930        #[test]
931        fn test_route_truncation_marker_without_counts_is_unrecognized() {
932            // Marker header but no count lines — the parser bails and the
933            // router falls through to its unknown-counter path.
934            let router = Router::new();
935            let body = "[Message summarized because one or more GameStateMessages \
936                        exceeded the 50 GameObject or 50 Annotation limit.]";
937            let entry = truncation_entry(body);
938
939            let results = router.route(&entry);
940            assert!(results.is_empty());
941            assert_eq!(router.stats().unknown_count(), 1);
942        }
943    }
944}