Skip to main content

manasight_parser/log/
entry.rs

1//! Log entry prefix identification and multi-line JSON accumulation.
2//!
3//! Detects log entry boundaries using the `[UnityCrossThreadLogger]`,
4//! `[Client GRE]`, `[ConnectionManager]`, and `Matchmaking:` header patterns,
5//! then accumulates subsequent lines until the next header boundary to form
6//! complete raw entries.
7//!
8//! # Data flow
9//!
10//! ```text
11//! File Tailer ──(raw lines)──▸ LineBuffer ──(complete entries)──▸ Router
12//! ```
13//!
14//! The [`LineBuffer`] receives individual lines from the file tailer. When a
15//! new log entry header is detected, it flushes the previously accumulated
16//! lines as a complete [`LogEntry`] and begins accumulating the new entry.
17
18use regex::Regex;
19
20use crate::util::truncate_for_log;
21
22/// The known log entry header prefixes in MTG Arena's `Player.log`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum EntryHeader {
26    /// `[UnityCrossThreadLogger]` — the most common header, used for
27    /// game state, client actions, match lifecycle, and most other events.
28    UnityCrossThreadLogger,
29    /// `[Client GRE]` — used for Game Rules Engine messages.
30    ClientGre,
31    /// `[ConnectionManager]` — emitted for Arena's connection-lifecycle
32    /// diagnostics (e.g., `Reconnect result : ...`, `Reconnect succeeded`,
33    /// `Reconnect failed`). These lines are plain-text, single-line entries
34    /// in practice.
35    ConnectionManager,
36    /// `Matchmaking:` — a bare (non-bracketed) prefix Arena emits for
37    /// matchmaking-side connection markers such as
38    /// `Matchmaking: GRE connection lost`. These lines are plain-text,
39    /// single-line entries in practice.
40    Matchmaking,
41    /// Metadata lines that appear outside bracket-delimited entries.
42    ///
43    /// Currently covers `DETAILED LOGS: ENABLED` and `DETAILED LOGS: DISABLED`,
44    /// which Arena writes near the top of every session (typically line 24).
45    Metadata,
46}
47
48impl EntryHeader {
49    /// Returns the header string as it appears in the log.
50    ///
51    /// Bracket-delimited headers return the full `[...]` prefix.
52    /// `Metadata` returns `"METADATA"` as a synthetic label (metadata
53    /// lines have no bracket prefix in the actual log).
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::UnityCrossThreadLogger => "[UnityCrossThreadLogger]",
57            Self::ClientGre => "[Client GRE]",
58            Self::ConnectionManager => "[ConnectionManager]",
59            Self::Matchmaking => "Matchmaking:",
60            Self::Metadata => "METADATA",
61        }
62    }
63}
64
65impl std::fmt::Display for EntryHeader {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71/// A complete log entry extracted from the line buffer.
72///
73/// Contains the detected header prefix and the full raw text of the entry
74/// (header line plus any continuation lines for multi-line payloads).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct LogEntry {
77    /// Which header prefix introduced this entry.
78    pub header: EntryHeader,
79    /// The full raw text of the entry, including the header line and all
80    /// continuation lines. Lines are joined with `'\n'`.
81    pub body: String,
82}
83
84/// Accumulates raw lines and produces complete [`LogEntry`] values when a
85/// new header boundary is detected.
86///
87/// # Usage
88///
89/// Feed lines one at a time via [`push_line`](Self::push_line). Each call
90/// returns `Some(LogEntry)` when a new header flushes the previous entry.
91/// After the input stream ends (EOF or file rotation), call
92/// [`flush`](Self::flush) to retrieve any remaining buffered entry.
93///
94/// # Example
95///
96/// ```
97/// use manasight_parser::log::entry::LineBuffer;
98///
99/// let mut buf = LineBuffer::new();
100///
101/// // First header — nothing to flush yet.
102/// assert!(buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1").is_none());
103///
104/// // Continuation line — still accumulating.
105/// assert!(buf.push_line(r#"{"key": "value"}"#).is_none());
106///
107/// // Second header — flushes the first entry.
108/// if let Some(entry) = buf.push_line("[Client GRE] 1/1/2025 Event2") {
109///     assert_eq!(entry.body, "[UnityCrossThreadLogger] 1/1/2025 Event1\n{\"key\": \"value\"}");
110/// }
111///
112/// // Flush the remaining entry.
113/// if let Some(last) = buf.flush() {
114///     assert_eq!(last.body, "[Client GRE] 1/1/2025 Event2");
115/// }
116/// ```
117pub struct LineBuffer {
118    /// Compiled regex for detecting log entry header boundaries.
119    header_re: Regex,
120    /// Header of the entry currently being accumulated, if any.
121    current_header: Option<EntryHeader>,
122    /// Lines accumulated for the current entry.
123    lines: Vec<String>,
124}
125
126impl LineBuffer {
127    /// Creates a new, empty line buffer with the compiled header regex.
128    pub fn new() -> Self {
129        // The regex crate documents that `Regex::new` only fails on invalid
130        // patterns. This pattern is a compile-time constant and is valid, so
131        // the `Err` branch is unreachable in practice.
132        let header_re =
133            match Regex::new(r"^\[(UnityCrossThreadLogger|Client GRE|ConnectionManager)\]") {
134                Ok(re) => re,
135                Err(e) => unreachable!("invalid header regex: {e}"),
136            };
137        Self {
138            header_re,
139            current_header: None,
140            lines: Vec::new(),
141        }
142    }
143
144    /// Feeds a single line into the buffer.
145    ///
146    /// Returns `Some(LogEntry)` when `line` starts a new log entry header,
147    /// flushing the previously accumulated entry. Returns `None` when the
148    /// line is a continuation of the current entry, or when no entry was
149    /// in progress (buffer was empty).
150    ///
151    /// Metadata lines (`DETAILED LOGS: ENABLED` / `DISABLED`) are treated
152    /// as self-contained entries: the current in-progress entry (if any) is
153    /// flushed, the metadata entry is returned, and no new accumulation
154    /// begins. If a metadata line is the first line in the stream (nothing
155    /// to flush), it is returned directly.
156    ///
157    /// Lines that arrive before any header has been seen are discarded with
158    /// a warning log — this handles partial entries at the start of a file
159    /// or after rotation.
160    pub fn push_line(&mut self, line: &str) -> Option<LogEntry> {
161        // Check for metadata lines first — these are self-contained.
162        if Self::is_metadata_line(line) {
163            let flushed = self.take_entry();
164            let metadata_entry = LogEntry {
165                header: EntryHeader::Metadata,
166                body: line.to_owned(),
167            };
168            // If there was a buffered entry, return it now.
169            // The metadata entry needs to be emitted too, so we buffer it
170            // as a complete single-line entry that will flush on the next
171            // header or explicit flush.
172            if flushed.is_some() {
173                self.current_header = Some(EntryHeader::Metadata);
174                self.lines.push(line.to_owned());
175                return flushed;
176            }
177            // No prior entry — return the metadata entry directly.
178            return Some(metadata_entry);
179        }
180
181        if let Some(header) = self.detect_header(line) {
182            let flushed = self.take_entry();
183            self.current_header = Some(header);
184            self.lines.push(line.to_owned());
185            flushed
186        } else if self.current_header.is_some() {
187            // Continuation line for the current entry.
188            self.lines.push(line.to_owned());
189            None
190        } else {
191            // Line arrived before any header — discard with a warning.
192            ::log::warn!(
193                "Discarding headerless line at start of input: {:?}",
194                truncate_for_log(line, 120),
195            );
196            None
197        }
198    }
199
200    /// Flushes any remaining buffered entry.
201    ///
202    /// Call this when the input stream ends (EOF or file rotation) to
203    /// retrieve the last accumulated entry.
204    pub fn flush(&mut self) -> Option<LogEntry> {
205        self.take_entry()
206    }
207
208    /// Resets the buffer, discarding any in-progress entry.
209    ///
210    /// Useful on file rotation when the previous partial entry should be
211    /// abandoned.
212    pub fn reset(&mut self) {
213        self.current_header = None;
214        self.lines.clear();
215    }
216
217    /// Returns `true` if no entry is currently being accumulated.
218    pub fn is_empty(&self) -> bool {
219        self.current_header.is_none()
220    }
221
222    /// Returns `true` if the line is a metadata line that should be
223    /// treated as a self-contained entry.
224    ///
225    /// Currently matches `DETAILED LOGS: ENABLED` and
226    /// `DETAILED LOGS: DISABLED`.
227    fn is_metadata_line(line: &str) -> bool {
228        let trimmed = line.trim();
229        trimmed == "DETAILED LOGS: ENABLED" || trimmed == "DETAILED LOGS: DISABLED"
230    }
231
232    /// Detects whether `line` starts with a known header prefix.
233    ///
234    /// Bracketed headers (`[UnityCrossThreadLogger]`, `[Client GRE]`,
235    /// `[ConnectionManager]`) are matched via the compiled regex. The
236    /// bare `Matchmaking: ` prefix is matched via a separate
237    /// `starts_with` check because it has no brackets.
238    fn detect_header(&self, line: &str) -> Option<EntryHeader> {
239        if let Some(caps) = self.header_re.captures(line) {
240            let prefix = caps.get(1)?.as_str();
241            return match prefix {
242                "UnityCrossThreadLogger" => Some(EntryHeader::UnityCrossThreadLogger),
243                "Client GRE" => Some(EntryHeader::ClientGre),
244                "ConnectionManager" => Some(EntryHeader::ConnectionManager),
245                _ => None,
246            };
247        }
248        if line.starts_with("Matchmaking: ") {
249            return Some(EntryHeader::Matchmaking);
250        }
251        None
252    }
253
254    /// Takes the current entry out of the buffer, leaving it empty.
255    fn take_entry(&mut self) -> Option<LogEntry> {
256        let header = self.current_header.take()?;
257        let body = self.lines.join("\n");
258        self.lines.clear();
259        Some(LogEntry { header, body })
260    }
261}
262
263impl Default for LineBuffer {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Tests
271// ---------------------------------------------------------------------------
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    /// Helper: build an expected `LogEntry` for concise assertions.
278    ///
279    /// Wrap in `Some(...)` to compare against `Option<LogEntry>` returns,
280    /// avoiding `unwrap()`/`expect()` (denied crate-wide by `Cargo.toml`).
281    fn expected(header: EntryHeader, body: &str) -> LogEntry {
282        LogEntry {
283            header,
284            body: body.to_owned(),
285        }
286    }
287
288    // -- EntryHeader --------------------------------------------------------
289
290    mod entry_header {
291        use super::*;
292
293        #[test]
294        fn test_as_str_unity() {
295            assert_eq!(
296                EntryHeader::UnityCrossThreadLogger.as_str(),
297                "[UnityCrossThreadLogger]"
298            );
299        }
300
301        #[test]
302        fn test_as_str_client_gre() {
303            assert_eq!(EntryHeader::ClientGre.as_str(), "[Client GRE]");
304        }
305
306        #[test]
307        fn test_display_unity() {
308            assert_eq!(
309                EntryHeader::UnityCrossThreadLogger.to_string(),
310                "[UnityCrossThreadLogger]"
311            );
312        }
313
314        #[test]
315        fn test_display_client_gre() {
316            assert_eq!(EntryHeader::ClientGre.to_string(), "[Client GRE]");
317        }
318
319        #[test]
320        fn test_clone_and_eq() {
321            let a = EntryHeader::UnityCrossThreadLogger;
322            let b = a;
323            assert_eq!(a, b);
324        }
325    }
326
327    // -- LineBuffer: basic operation ----------------------------------------
328
329    mod push_line {
330        use super::*;
331
332        #[test]
333        fn test_push_line_first_header_returns_none() {
334            let mut buf = LineBuffer::new();
335            assert!(buf
336                .push_line("[UnityCrossThreadLogger] 1/1/2025 12:00:00 Event")
337                .is_none());
338        }
339
340        #[test]
341        fn test_push_line_second_header_flushes_first_entry() {
342            let mut buf = LineBuffer::new();
343            buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1");
344            assert_eq!(
345                buf.push_line("[Client GRE] 1/1/2025 Event2"),
346                Some(expected(
347                    EntryHeader::UnityCrossThreadLogger,
348                    "[UnityCrossThreadLogger] 1/1/2025 Event1",
349                )),
350            );
351        }
352
353        #[test]
354        fn test_push_line_continuation_appended() {
355            let mut buf = LineBuffer::new();
356            buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1");
357            buf.push_line(r#"{"key": "value"}"#);
358            buf.push_line(r#"{"more": "data"}"#);
359            assert_eq!(
360                buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event2"),
361                Some(expected(
362                    EntryHeader::UnityCrossThreadLogger,
363                    "[UnityCrossThreadLogger] 1/1/2025 Event1\n\
364                     {\"key\": \"value\"}\n\
365                     {\"more\": \"data\"}",
366                )),
367            );
368        }
369
370        #[test]
371        fn test_push_line_client_gre_header_detected() {
372            let mut buf = LineBuffer::new();
373            buf.push_line("[Client GRE] GreMessage");
374            assert_eq!(
375                buf.flush(),
376                Some(expected(EntryHeader::ClientGre, "[Client GRE] GreMessage")),
377            );
378        }
379
380        #[test]
381        fn test_push_line_alternating_headers() {
382            let mut buf = LineBuffer::new();
383            buf.push_line("[UnityCrossThreadLogger] Event1");
384
385            assert_eq!(
386                buf.push_line("[Client GRE] Event2"),
387                Some(expected(
388                    EntryHeader::UnityCrossThreadLogger,
389                    "[UnityCrossThreadLogger] Event1",
390                )),
391            );
392
393            assert_eq!(
394                buf.push_line("[UnityCrossThreadLogger] Event3"),
395                Some(expected(EntryHeader::ClientGre, "[Client GRE] Event2")),
396            );
397
398            assert_eq!(
399                buf.flush(),
400                Some(expected(
401                    EntryHeader::UnityCrossThreadLogger,
402                    "[UnityCrossThreadLogger] Event3",
403                )),
404            );
405        }
406    }
407
408    // -- LineBuffer: headerless lines ---------------------------------------
409
410    mod headerless {
411        use super::*;
412
413        #[test]
414        fn test_push_line_headerless_before_first_header_returns_none() {
415            let mut buf = LineBuffer::new();
416            assert!(buf.push_line("some random line").is_none());
417            assert!(buf.push_line("another orphan").is_none());
418            // After discarding, the next header should still work.
419            buf.push_line("[UnityCrossThreadLogger] Real entry");
420            assert_eq!(
421                buf.flush(),
422                Some(expected(
423                    EntryHeader::UnityCrossThreadLogger,
424                    "[UnityCrossThreadLogger] Real entry",
425                )),
426            );
427        }
428
429        #[test]
430        fn test_push_line_empty_line_as_continuation() {
431            let mut buf = LineBuffer::new();
432            buf.push_line("[UnityCrossThreadLogger] Event");
433            buf.push_line("");
434            buf.push_line("continuation");
435            assert_eq!(
436                buf.flush(),
437                Some(expected(
438                    EntryHeader::UnityCrossThreadLogger,
439                    "[UnityCrossThreadLogger] Event\n\ncontinuation",
440                )),
441            );
442        }
443    }
444
445    // -- LineBuffer: flush --------------------------------------------------
446
447    mod flush {
448        use super::*;
449
450        #[test]
451        fn test_flush_empty_buffer_returns_none() {
452            let mut buf = LineBuffer::new();
453            assert!(buf.flush().is_none());
454        }
455
456        #[test]
457        fn test_flush_returns_buffered_entry() {
458            let mut buf = LineBuffer::new();
459            buf.push_line("[UnityCrossThreadLogger] Event");
460            assert_eq!(
461                buf.flush(),
462                Some(expected(
463                    EntryHeader::UnityCrossThreadLogger,
464                    "[UnityCrossThreadLogger] Event",
465                )),
466            );
467        }
468
469        #[test]
470        fn test_flush_clears_buffer() {
471            let mut buf = LineBuffer::new();
472            buf.push_line("[UnityCrossThreadLogger] Event");
473            buf.flush();
474            assert!(buf.flush().is_none());
475            assert!(buf.is_empty());
476        }
477
478        #[test]
479        fn test_flush_multi_line_entry() {
480            let mut buf = LineBuffer::new();
481            buf.push_line("[Client GRE] GreToClientEvent");
482            buf.push_line("{");
483            buf.push_line(r#"  "gameObjects": ["obj1", "obj2"],"#);
484            buf.push_line(r#"  "actions": []"#);
485            buf.push_line("}");
486            let expected_body = [
487                "[Client GRE] GreToClientEvent",
488                "{",
489                r#"  "gameObjects": ["obj1", "obj2"],"#,
490                r#"  "actions": []"#,
491                "}",
492            ]
493            .join("\n");
494            assert_eq!(
495                buf.flush(),
496                Some(expected(EntryHeader::ClientGre, &expected_body)),
497            );
498        }
499    }
500
501    // -- LineBuffer: reset --------------------------------------------------
502
503    mod reset {
504        use super::*;
505
506        #[test]
507        fn test_reset_clears_in_progress_entry() {
508            let mut buf = LineBuffer::new();
509            buf.push_line("[UnityCrossThreadLogger] Event");
510            buf.push_line("continuation");
511            buf.reset();
512            assert!(buf.is_empty());
513            assert!(buf.flush().is_none());
514        }
515
516        #[test]
517        fn test_reset_allows_fresh_accumulation() {
518            let mut buf = LineBuffer::new();
519            buf.push_line("[UnityCrossThreadLogger] Old");
520            buf.reset();
521            buf.push_line("[Client GRE] New");
522            assert_eq!(
523                buf.flush(),
524                Some(expected(EntryHeader::ClientGre, "[Client GRE] New")),
525            );
526        }
527    }
528
529    // -- LineBuffer: is_empty -----------------------------------------------
530
531    mod is_empty {
532        use super::*;
533
534        #[test]
535        fn test_is_empty_on_new_buffer() {
536            let buf = LineBuffer::new();
537            assert!(buf.is_empty());
538        }
539
540        #[test]
541        fn test_is_empty_false_after_header() {
542            let mut buf = LineBuffer::new();
543            buf.push_line("[UnityCrossThreadLogger] Event");
544            assert!(!buf.is_empty());
545        }
546
547        #[test]
548        fn test_is_empty_true_after_flush() {
549            let mut buf = LineBuffer::new();
550            buf.push_line("[UnityCrossThreadLogger] Event");
551            buf.flush();
552            assert!(buf.is_empty());
553        }
554
555        #[test]
556        fn test_is_empty_true_after_headerless_lines() {
557            let mut buf = LineBuffer::new();
558            buf.push_line("orphan line");
559            assert!(buf.is_empty());
560        }
561    }
562
563    // -- LineBuffer: default ------------------------------------------------
564
565    mod default_impl {
566        use super::*;
567
568        #[test]
569        fn test_default_creates_functional_buffer() {
570            let mut buf = LineBuffer::default();
571            buf.push_line("[UnityCrossThreadLogger] Event");
572            assert_eq!(
573                buf.flush(),
574                Some(expected(
575                    EntryHeader::UnityCrossThreadLogger,
576                    "[UnityCrossThreadLogger] Event",
577                )),
578            );
579        }
580    }
581
582    // -- Header detection edge cases ----------------------------------------
583
584    mod header_detection {
585        use super::*;
586
587        #[test]
588        fn test_header_not_at_start_of_line_is_continuation() {
589            let mut buf = LineBuffer::new();
590            buf.push_line("[UnityCrossThreadLogger] Event");
591            // Header pattern in the middle of a line is NOT a boundary.
592            buf.push_line("some text [UnityCrossThreadLogger] not a header");
593            assert_eq!(
594                buf.flush(),
595                Some(expected(
596                    EntryHeader::UnityCrossThreadLogger,
597                    "[UnityCrossThreadLogger] Event\n\
598                     some text [UnityCrossThreadLogger] not a header",
599                )),
600            );
601        }
602
603        #[test]
604        fn test_similar_but_wrong_header_is_continuation() {
605            let mut buf = LineBuffer::new();
606            buf.push_line("[UnityCrossThreadLogger] Event");
607            buf.push_line("[UnityMainThreadLogger] not a valid header");
608            let result = buf.flush();
609            assert!(result.is_some());
610            if let Some(e) = result {
611                assert!(e.body.contains("[UnityMainThreadLogger]"));
612            }
613        }
614
615        #[test]
616        fn test_bracket_only_is_not_header() {
617            let mut buf = LineBuffer::new();
618            buf.push_line("[UnityCrossThreadLogger] Event");
619            buf.push_line("[]");
620            assert_eq!(
621                buf.flush(),
622                Some(expected(
623                    EntryHeader::UnityCrossThreadLogger,
624                    "[UnityCrossThreadLogger] Event\n[]",
625                )),
626            );
627        }
628
629        #[test]
630        fn test_header_with_nothing_after_bracket() {
631            let mut buf = LineBuffer::new();
632            // Header with no trailing content — still a valid header.
633            buf.push_line("[UnityCrossThreadLogger]");
634            assert_eq!(
635                buf.flush(),
636                Some(expected(
637                    EntryHeader::UnityCrossThreadLogger,
638                    "[UnityCrossThreadLogger]",
639                )),
640            );
641        }
642    }
643
644    // -- Realistic multi-line entry -----------------------------------------
645
646    mod realistic_entries {
647        use super::*;
648
649        #[test]
650        fn test_realistic_game_state_message() {
651            let mut buf = LineBuffer::new();
652            buf.push_line(
653                "[UnityCrossThreadLogger]1/15/2025 3:42:17 PM \
654                 greToClientEvent",
655            );
656            buf.push_line("{");
657            buf.push_line(r#"  "greToClientMessages": ["#);
658            buf.push_line(r"    {");
659            buf.push_line(r#"      "type": "GREMessageType_GameStateMessage","#);
660            buf.push_line(r#"      "gameStateMessage": {"#);
661            buf.push_line(r#"        "gameObjects": []"#);
662            buf.push_line(r"      }");
663            buf.push_line(r"    }");
664            buf.push_line(r"  ]");
665            buf.push_line("}");
666
667            // [Client GRE] flushes the UnityCrossThreadLogger entry.
668            let unity_entry = buf.push_line("[Client GRE] Next event");
669            assert!(unity_entry.is_some());
670            if let Some(e) = unity_entry {
671                assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
672                assert!(e.body.contains("greToClientMessages"));
673                assert!(e.body.contains("GameStateMessage"));
674            }
675
676            // Another header flushes the Client GRE entry.
677            assert_eq!(
678                buf.push_line("[UnityCrossThreadLogger] After"),
679                Some(expected(EntryHeader::ClientGre, "[Client GRE] Next event",)),
680            );
681        }
682
683        #[test]
684        fn test_many_entries_in_sequence() {
685            let mut buf = LineBuffer::new();
686            let mut entries = Vec::new();
687
688            for i in 0..5 {
689                if let Some(e) = buf.push_line(&format!("[UnityCrossThreadLogger] Event{i}")) {
690                    entries.push(e);
691                }
692            }
693            if let Some(e) = buf.flush() {
694                entries.push(e);
695            }
696
697            assert_eq!(entries.len(), 5);
698            for (i, e) in entries.iter().enumerate() {
699                assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
700                assert_eq!(e.body, format!("[UnityCrossThreadLogger] Event{i}"));
701            }
702        }
703    }
704
705    // -- Metadata line detection -----------------------------------------------
706
707    mod metadata_lines {
708        use super::*;
709
710        #[test]
711        fn test_push_line_detailed_logs_enabled_as_first_line() {
712            let mut buf = LineBuffer::new();
713            let result = buf.push_line("DETAILED LOGS: ENABLED");
714
715            assert_eq!(
716                result,
717                Some(expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")),
718            );
719            // Buffer should be empty after — metadata is self-contained.
720            assert!(buf.is_empty());
721        }
722
723        #[test]
724        fn test_push_line_detailed_logs_disabled_as_first_line() {
725            let mut buf = LineBuffer::new();
726            let result = buf.push_line("DETAILED LOGS: DISABLED");
727
728            assert_eq!(
729                result,
730                Some(expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")),
731            );
732        }
733
734        #[test]
735        fn test_push_line_metadata_flushes_buffered_entry() {
736            let mut buf = LineBuffer::new();
737            buf.push_line("[UnityCrossThreadLogger] Event1");
738
739            // Metadata line should flush the buffered entry.
740            let flushed = buf.push_line("DETAILED LOGS: ENABLED");
741            assert_eq!(
742                flushed,
743                Some(expected(
744                    EntryHeader::UnityCrossThreadLogger,
745                    "[UnityCrossThreadLogger] Event1",
746                )),
747            );
748
749            // The metadata entry should be available on next flush.
750            let metadata = buf.flush();
751            assert_eq!(
752                metadata,
753                Some(expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")),
754            );
755        }
756
757        #[test]
758        fn test_push_line_metadata_then_header_flushes_metadata() {
759            let mut buf = LineBuffer::new();
760            buf.push_line("DETAILED LOGS: ENABLED");
761
762            // Next header should work normally (nothing to flush since
763            // the metadata entry was returned immediately).
764            assert!(buf.push_line("[UnityCrossThreadLogger] Event").is_none());
765            assert_eq!(
766                buf.flush(),
767                Some(expected(
768                    EntryHeader::UnityCrossThreadLogger,
769                    "[UnityCrossThreadLogger] Event",
770                )),
771            );
772        }
773
774        #[test]
775        fn test_push_line_metadata_buffered_then_next_header_flushes() {
776            let mut buf = LineBuffer::new();
777            buf.push_line("[UnityCrossThreadLogger] Event1");
778
779            // Metadata line flushes Event1, buffers itself.
780            buf.push_line("DETAILED LOGS: DISABLED");
781
782            // Next header flushes the metadata entry.
783            let flushed = buf.push_line("[UnityCrossThreadLogger] Event2");
784            assert_eq!(
785                flushed,
786                Some(expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")),
787            );
788        }
789
790        #[test]
791        fn test_push_line_metadata_similar_text_not_matched() {
792            let mut buf = LineBuffer::new();
793            // Similar but not exact — should be treated as headerless.
794            assert!(buf.push_line("DETAILED LOGS: UNKNOWN").is_none());
795            assert!(buf.push_line("detailed logs: enabled").is_none());
796            assert!(buf.push_line("DETAILED LOGS:ENABLED").is_none());
797        }
798
799        #[test]
800        fn test_push_line_metadata_with_leading_trailing_whitespace() {
801            let mut buf = LineBuffer::new();
802            // Whitespace around the exact text should still match.
803            let result = buf.push_line("  DETAILED LOGS: ENABLED  ");
804            assert!(result.is_some());
805            if let Some(entry) = result {
806                assert_eq!(entry.header, EntryHeader::Metadata);
807            }
808        }
809
810        #[test]
811        fn test_entry_header_metadata_as_str() {
812            assert_eq!(EntryHeader::Metadata.as_str(), "METADATA");
813        }
814
815        #[test]
816        fn test_entry_header_metadata_display() {
817            assert_eq!(EntryHeader::Metadata.to_string(), "METADATA");
818        }
819    }
820
821    // -- ConnectionManager / Matchmaking header framing ---------------------
822
823    mod connection_and_matchmaking_headers {
824        use super::*;
825
826        #[test]
827        fn test_as_str_connection_manager() {
828            assert_eq!(
829                EntryHeader::ConnectionManager.as_str(),
830                "[ConnectionManager]"
831            );
832        }
833
834        #[test]
835        fn test_as_str_matchmaking() {
836            // The `Matchmaking:` prefix keeps the colon — this matches how
837            // the line appears in Arena's actual log.
838            assert_eq!(EntryHeader::Matchmaking.as_str(), "Matchmaking:");
839        }
840
841        #[test]
842        fn test_display_connection_manager() {
843            assert_eq!(
844                EntryHeader::ConnectionManager.to_string(),
845                "[ConnectionManager]"
846            );
847        }
848
849        #[test]
850        fn test_display_matchmaking() {
851            assert_eq!(EntryHeader::Matchmaking.to_string(), "Matchmaking:");
852        }
853
854        #[test]
855        fn test_connection_manager_header_mid_stream_flushes_unity() {
856            let mut buf = LineBuffer::new();
857            buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1");
858
859            let flushed = buf.push_line("[ConnectionManager] Reconnect result : Error");
860            assert_eq!(
861                flushed,
862                Some(expected(
863                    EntryHeader::UnityCrossThreadLogger,
864                    "[UnityCrossThreadLogger] 1/1/2025 Event1",
865                )),
866            );
867
868            // The ConnectionManager entry should be buffered and flushable.
869            assert_eq!(
870                buf.flush(),
871                Some(expected(
872                    EntryHeader::ConnectionManager,
873                    "[ConnectionManager] Reconnect result : Error",
874                )),
875            );
876        }
877
878        #[test]
879        fn test_matchmaking_header_mid_stream_flushes_unity() {
880            let mut buf = LineBuffer::new();
881            buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1");
882
883            let flushed = buf.push_line("Matchmaking: GRE connection lost");
884            assert_eq!(
885                flushed,
886                Some(expected(
887                    EntryHeader::UnityCrossThreadLogger,
888                    "[UnityCrossThreadLogger] 1/1/2025 Event1",
889                )),
890            );
891
892            assert_eq!(
893                buf.flush(),
894                Some(expected(
895                    EntryHeader::Matchmaking,
896                    "Matchmaking: GRE connection lost",
897                )),
898            );
899        }
900
901        #[test]
902        fn test_connection_manager_as_first_line_no_warning_emitted() {
903            // A ConnectionManager entry as the very first line should not
904            // be discarded as headerless — push_line returns None only
905            // because there is nothing to flush yet; the entry is buffered
906            // and flushable normally.
907            let mut buf = LineBuffer::new();
908            assert!(buf
909                .push_line("[ConnectionManager] Reconnect succeeded")
910                .is_none());
911            assert!(!buf.is_empty());
912            assert_eq!(
913                buf.flush(),
914                Some(expected(
915                    EntryHeader::ConnectionManager,
916                    "[ConnectionManager] Reconnect succeeded",
917                )),
918            );
919        }
920
921        #[test]
922        fn test_matchmaking_as_first_line_no_warning_emitted() {
923            let mut buf = LineBuffer::new();
924            assert!(buf.push_line("Matchmaking: GRE connection lost").is_none());
925            assert!(!buf.is_empty());
926            assert_eq!(
927                buf.flush(),
928                Some(expected(
929                    EntryHeader::Matchmaking,
930                    "Matchmaking: GRE connection lost",
931                )),
932            );
933        }
934
935        #[test]
936        fn test_four_way_interleave_yields_four_entries() {
937            // Realistic corpus-derived pattern from issues #528/#529:
938            // Unity STATE CHANGED → Matchmaking: GRE connection lost →
939            // ConnectionManager Reconnect result → Unity (next event).
940            let mut buf = LineBuffer::new();
941            let mut entries = Vec::new();
942
943            if let Some(e) = buf.push_line(
944                "[UnityCrossThreadLogger]STATE CHANGED {\"old\":\"Playing\",\"new\":\"Disconnected\"}",
945            ) {
946                entries.push(e);
947            }
948            if let Some(e) = buf.push_line("Matchmaking: GRE connection lost") {
949                entries.push(e);
950            }
951            if let Some(e) = buf.push_line("[ConnectionManager] Reconnect result : Error") {
952                entries.push(e);
953            }
954            if let Some(e) = buf.push_line("[UnityCrossThreadLogger] Next event") {
955                entries.push(e);
956            }
957            if let Some(e) = buf.flush() {
958                entries.push(e);
959            }
960
961            assert_eq!(entries.len(), 4);
962            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
963            assert!(entries[0].body.contains("STATE CHANGED"));
964            assert_eq!(entries[1].header, EntryHeader::Matchmaking);
965            assert_eq!(entries[1].body, "Matchmaking: GRE connection lost");
966            assert_eq!(entries[2].header, EntryHeader::ConnectionManager);
967            assert_eq!(
968                entries[2].body,
969                "[ConnectionManager] Reconnect result : Error"
970            );
971            assert_eq!(entries[3].header, EntryHeader::UnityCrossThreadLogger);
972            assert_eq!(entries[3].body, "[UnityCrossThreadLogger] Next event");
973        }
974
975        #[test]
976        fn test_connection_manager_accumulates_continuation_lines() {
977            // Corpus shows these entries are single-line in practice, but
978            // verify continuation lines are accumulated if they appear.
979            let mut buf = LineBuffer::new();
980            buf.push_line("[ConnectionManager] Reconnect result : Error");
981            buf.push_line("  extra detail line");
982            buf.push_line("  another detail line");
983
984            assert_eq!(
985                buf.flush(),
986                Some(expected(
987                    EntryHeader::ConnectionManager,
988                    "[ConnectionManager] Reconnect result : Error\n  extra detail line\n  another detail line",
989                )),
990            );
991        }
992
993        #[test]
994        fn test_matchmaking_accumulates_continuation_lines() {
995            let mut buf = LineBuffer::new();
996            buf.push_line("Matchmaking: GRE connection lost");
997            buf.push_line("extra continuation");
998
999            assert_eq!(
1000                buf.flush(),
1001                Some(expected(
1002                    EntryHeader::Matchmaking,
1003                    "Matchmaking: GRE connection lost\nextra continuation",
1004                )),
1005            );
1006        }
1007
1008        #[test]
1009        fn test_matchmaking_without_trailing_space_is_not_header() {
1010            // The starts_with check requires the trailing space ("Matchmaking: ")
1011            // to avoid matching unrelated prefixes that happen to start
1012            // with "Matchmaking:". Without the space it should be a
1013            // headerless line (discarded at start of stream).
1014            let mut buf = LineBuffer::new();
1015            assert!(buf.push_line("Matchmaking:compact-no-space").is_none());
1016            assert!(buf.is_empty());
1017        }
1018
1019        #[test]
1020        fn test_connection_manager_mid_line_is_continuation() {
1021            let mut buf = LineBuffer::new();
1022            buf.push_line("[UnityCrossThreadLogger] Event");
1023            // ConnectionManager bracket pattern in the middle of a line is
1024            // NOT a boundary — same rule as other bracketed headers.
1025            buf.push_line("some text [ConnectionManager] not a header");
1026            assert_eq!(
1027                buf.flush(),
1028                Some(expected(
1029                    EntryHeader::UnityCrossThreadLogger,
1030                    "[UnityCrossThreadLogger] Event\n\
1031                     some text [ConnectionManager] not a header",
1032                )),
1033            );
1034        }
1035    }
1036}