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//! # Header classification (Phase 1 of #153)
9//!
10//! Each detected header is classified as either single-line or multi-line:
11//!
12//! - **Single-line**: `[UnityCrossThreadLogger]` followed by anything other
13//!   than a date digit (e.g., alpha labels like `STATE CHANGED`,
14//!   `Client.SceneChange`, or `==>` API request markers),
15//!   `[ConnectionManager]…`, and `Matchmaking:…`. These entries are
16//!   flushed in the same [`LineBuffer::push_line`] call that received them
17//!   — no continuation accumulation.
18//! - **Multi-line**: `[UnityCrossThreadLogger]<digit>` (date-prefixed API
19//!   responses, match events) and `[Client GRE]…`. These entries
20//!   accumulate continuation lines until the next header boundary, matching
21//!   the historical behavior.
22//!
23//! # Data flow
24//!
25//! ```text
26//! File Tailer ──(raw lines)──▸ LineBuffer ──(complete entries)──▸ Router
27//! ```
28//!
29//! The [`LineBuffer`] receives individual lines from the file tailer. When a
30//! new log entry header is detected, it flushes the previously accumulated
31//! lines as a complete [`LogEntry`] and either emits the new entry
32//! immediately (single-line class) or begins accumulating it (multi-line
33//! class).
34
35use regex::Regex;
36
37use crate::util::truncate_for_log;
38
39/// The known log entry header prefixes in MTG Arena's `Player.log`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum EntryHeader {
43    /// `[UnityCrossThreadLogger]` — the most common header, used for
44    /// game state, client actions, match lifecycle, and most other events.
45    UnityCrossThreadLogger,
46    /// `[Client GRE]` — used for Game Rules Engine messages.
47    ClientGre,
48    /// `[ConnectionManager]` — emitted for Arena's connection-lifecycle
49    /// diagnostics (e.g., `Reconnect result : ...`, `Reconnect succeeded`,
50    /// `Reconnect failed`). These lines are plain-text, single-line entries
51    /// in practice.
52    ConnectionManager,
53    /// `Matchmaking:` — a bare (non-bracketed) prefix Arena emits for
54    /// matchmaking-side connection markers such as
55    /// `Matchmaking: GRE connection lost`. These lines are plain-text,
56    /// single-line entries in practice.
57    Matchmaking,
58    /// Metadata lines that appear outside bracket-delimited entries.
59    ///
60    /// Currently covers `DETAILED LOGS: ENABLED` and `DETAILED LOGS: DISABLED`,
61    /// which Arena writes near the top of every session (typically line 24).
62    Metadata,
63}
64
65impl EntryHeader {
66    /// Returns the header string as it appears in the log.
67    ///
68    /// Bracket-delimited headers return the full `[...]` prefix.
69    /// `Metadata` returns `"METADATA"` as a synthetic label (metadata
70    /// lines have no bracket prefix in the actual log).
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::UnityCrossThreadLogger => "[UnityCrossThreadLogger]",
74            Self::ClientGre => "[Client GRE]",
75            Self::ConnectionManager => "[ConnectionManager]",
76            Self::Matchmaking => "Matchmaking:",
77            Self::Metadata => "METADATA",
78        }
79    }
80}
81
82impl std::fmt::Display for EntryHeader {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88/// A complete log entry extracted from the line buffer.
89///
90/// Contains the detected header prefix and the full raw text of the entry
91/// (header line plus any continuation lines for multi-line payloads).
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct LogEntry {
94    /// Which header prefix introduced this entry.
95    pub header: EntryHeader,
96    /// The full raw text of the entry, including the header line and all
97    /// continuation lines. Lines are joined with `'\n'`.
98    pub body: String,
99}
100
101/// Internal classification of a header line for flush-timing decisions.
102///
103/// See module-level docs for the full classification rule.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum HeaderClass {
106    /// The entry is self-contained — flush immediately.
107    SingleLine,
108    /// The entry may span multiple lines — accumulate until the next header.
109    MultiLine,
110}
111
112/// Accumulates raw lines and produces complete [`LogEntry`] values when a
113/// new header boundary is detected.
114///
115/// # Usage
116///
117/// Feed lines one at a time via [`push_line`](Self::push_line). Each call
118/// returns a `Vec<LogEntry>` containing zero, one, or two complete entries:
119///
120/// - **Zero entries**: continuation line for an in-progress multi-line entry,
121///   or a headerless line discarded with a warning.
122/// - **One entry**: either a multi-line entry being flushed by a new
123///   single-line entry's arrival, or a single-line entry emitted alone when
124///   no prior entry was in progress.
125/// - **Two entries**: a multi-line entry being flushed *plus* the new
126///   single-line entry that triggered the flush, both emitted from one call.
127///
128/// After the input stream ends (EOF or file rotation), call
129/// [`flush`](Self::flush) to retrieve any remaining buffered entry.
130///
131/// # Example
132///
133/// ```
134/// use manasight_parser::log::entry::LineBuffer;
135///
136/// let mut buf = LineBuffer::new();
137///
138/// // First header (multi-line, date-prefixed) — nothing to flush yet.
139/// assert!(buf.push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 PM").is_empty());
140///
141/// // Continuation line — still accumulating.
142/// assert!(buf.push_line(r#"{"key": "value"}"#).is_empty());
143///
144/// // A single-line header arrives — flushes the multi-line entry AND
145/// // emits the single-line entry, both in one call.
146/// let entries = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED");
147/// assert_eq!(entries.len(), 2);
148/// ```
149pub struct LineBuffer {
150    /// Compiled regex for detecting log entry header boundaries.
151    header_re: Regex,
152    /// Header of the entry currently being accumulated, if any.
153    ///
154    /// Only ever populated for multi-line entries. Single-line entries are
155    /// emitted immediately and never set this field — leaving the buffer in
156    /// an idle state after every single-line flush.
157    current_header: Option<EntryHeader>,
158    /// Lines accumulated for the current entry.
159    lines: Vec<String>,
160    /// Whether this buffer has ever emitted (or begun accumulating) an entry.
161    ///
162    /// Armed by [`push_line`](Self::push_line) when a real header is detected
163    /// or a metadata line is emitted. Cleared back to `false` by
164    /// [`reset`](Self::reset) so post-rotation orphan lines still surface a
165    /// warning. Used to silence the routine post-flush "orphan discarded"
166    /// warning (Phase 2 of #153 / #161): once any entry has been seen, an
167    /// arriving headerless line is Unity stdout noise rather than a true
168    /// file-start anomaly.
169    has_emitted_anything: bool,
170}
171
172impl LineBuffer {
173    /// Creates a new, empty line buffer with the compiled header regex.
174    pub fn new() -> Self {
175        // The regex crate documents that `Regex::new` only fails on invalid
176        // patterns. This pattern is a compile-time constant and is valid, so
177        // the `Err` branch is unreachable in practice.
178        let header_re =
179            match Regex::new(r"^\[(UnityCrossThreadLogger|Client GRE|ConnectionManager)\]") {
180                Ok(re) => re,
181                Err(e) => unreachable!("invalid header regex: {e}"),
182            };
183        Self {
184            header_re,
185            current_header: None,
186            lines: Vec::new(),
187            has_emitted_anything: false,
188        }
189    }
190
191    /// Feeds a single line into the buffer.
192    ///
193    /// Returns a `Vec<LogEntry>` containing 0, 1, or 2 complete entries
194    /// — see the [type-level documentation](Self) for the full semantics.
195    ///
196    /// # Header classification
197    ///
198    /// When `line` matches a known header pattern, it is classified as either
199    /// single-line or multi-line (see module-level docs). Single-line
200    /// headers (`[UnityCrossThreadLogger]<non-digit>`, `[ConnectionManager]…`,
201    /// `Matchmaking:…`) flush any prior multi-line entry and emit the new
202    /// entry in the same call. Multi-line headers
203    /// (`[UnityCrossThreadLogger]<digit>`, `[Client GRE]…`) flush any prior
204    /// entry and begin a fresh accumulation.
205    ///
206    /// Metadata lines (`DETAILED LOGS: ENABLED` / `DISABLED`) are
207    /// self-contained — treated as single-line entries that flush any prior
208    /// in-progress entry alongside themselves.
209    ///
210    /// Lines that arrive before any header has been seen are discarded with
211    /// a warning log — this handles partial entries at the start of a file
212    /// or after rotation.
213    ///
214    /// # Input contract
215    ///
216    /// Callers must strip any trailing `\r` (Windows CRLF) before invoking
217    /// this method. [`crate::log::tailer::FileTailer::poll`] already does
218    /// this; direct callers in tests must do the same to keep classification
219    /// well-defined.
220    pub fn push_line(&mut self, line: &str) -> Vec<LogEntry> {
221        // Check for metadata lines first — these are self-contained.
222        if Self::is_metadata_line(line) {
223            let mut out = Vec::new();
224            if let Some(prior) = self.take_entry() {
225                out.push(prior);
226            }
227            out.push(LogEntry {
228                header: EntryHeader::Metadata,
229                body: line.to_owned(),
230            });
231            // Metadata is a successfully emitted entry — subsequent orphan
232            // lines are routine post-flush noise, not a file-start anomaly.
233            self.has_emitted_anything = true;
234            return out;
235        }
236
237        if let Some(header) = self.detect_header(line) {
238            let class = Self::classify_header(header, line);
239            let mut out = Vec::new();
240            if let Some(prior) = self.take_entry() {
241                out.push(prior);
242            }
243            match class {
244                HeaderClass::SingleLine => {
245                    // Emit the new entry immediately; leave the buffer idle
246                    // so Phase 2 (#161) can distinguish post-flush orphans.
247                    out.push(LogEntry {
248                        header,
249                        body: line.to_owned(),
250                    });
251                }
252                HeaderClass::MultiLine => {
253                    // Begin accumulating the new multi-line entry.
254                    self.current_header = Some(header);
255                    self.lines.push(line.to_owned());
256                }
257            }
258            // A real header was seen — arm the flag so subsequent orphans
259            // are silenced.
260            self.has_emitted_anything = true;
261            out
262        } else if self.current_header.is_some() {
263            // Continuation line for the current multi-line entry.
264            self.lines.push(line.to_owned());
265            Vec::new()
266        } else {
267            // Headerless line with no entry in progress. Two cases:
268            //
269            // 1. True file-start / post-rotation anomaly (no header has ever
270            //    been seen): warn — this is what the message is meant to
271            //    flag.
272            // 2. Routine post-flush orphan (Unity stdout noise arriving
273            //    between Arena entries after Phase 1's single-line flush
274            //    landed): silently discard — the warn would be pure noise.
275            if !self.has_emitted_anything {
276                ::log::warn!(
277                    "Discarding headerless line at start of input: {:?}",
278                    truncate_for_log(line, 120),
279                );
280            }
281            Vec::new()
282        }
283    }
284
285    /// Flushes any remaining buffered entry.
286    ///
287    /// Call this when the input stream ends (EOF or file rotation) to
288    /// retrieve the last accumulated multi-line entry, if any. Single-line
289    /// entries are never buffered — they are emitted by [`push_line`] in the
290    /// same call that received them — so this method only ever returns at
291    /// most one entry.
292    pub fn flush(&mut self) -> Option<LogEntry> {
293        self.take_entry()
294    }
295
296    /// Resets the buffer, discarding any in-progress entry.
297    ///
298    /// Useful on file rotation when the previous partial entry should be
299    /// abandoned. Also re-arms the orphan-warning flag so the first
300    /// post-rotation orphan still surfaces a warning (the rotation case
301    /// the warning was originally meant to detect).
302    pub fn reset(&mut self) {
303        self.current_header = None;
304        self.lines.clear();
305        self.has_emitted_anything = false;
306    }
307
308    /// Returns `true` if no entry is currently being accumulated.
309    pub fn is_empty(&self) -> bool {
310        self.current_header.is_none()
311    }
312
313    /// Returns `true` if the line is a metadata line that should be
314    /// treated as a self-contained entry.
315    ///
316    /// Currently matches `DETAILED LOGS: ENABLED` and
317    /// `DETAILED LOGS: DISABLED`.
318    fn is_metadata_line(line: &str) -> bool {
319        let trimmed = line.trim();
320        trimmed == "DETAILED LOGS: ENABLED" || trimmed == "DETAILED LOGS: DISABLED"
321    }
322
323    /// Detects whether `line` starts with a known header prefix.
324    ///
325    /// Bracketed headers (`[UnityCrossThreadLogger]`, `[Client GRE]`,
326    /// `[ConnectionManager]`) are matched via the compiled regex. The
327    /// bare `Matchmaking: ` prefix is matched via a separate
328    /// `starts_with` check because it has no brackets.
329    fn detect_header(&self, line: &str) -> Option<EntryHeader> {
330        if let Some(caps) = self.header_re.captures(line) {
331            let prefix = caps.get(1)?.as_str();
332            return match prefix {
333                "UnityCrossThreadLogger" => Some(EntryHeader::UnityCrossThreadLogger),
334                "Client GRE" => Some(EntryHeader::ClientGre),
335                "ConnectionManager" => Some(EntryHeader::ConnectionManager),
336                _ => None,
337            };
338        }
339        if line.starts_with("Matchmaking: ") {
340            return Some(EntryHeader::Matchmaking);
341        }
342        None
343    }
344
345    /// Classifies a header line as single-line or multi-line.
346    ///
347    /// Rule (corpus-verified across 27 sessions / 37,593 entries; see #153
348    /// analysis comment):
349    ///
350    /// - `[UnityCrossThreadLogger]` followed by an ASCII digit → multi-line
351    ///   (date-prefixed API responses and match events).
352    /// - `[UnityCrossThreadLogger]` followed by anything else → single-line
353    ///   (alpha labels and `==>` request markers).
354    /// - `[Client GRE]` → multi-line (current behavior preserved; corpus
355    ///   has zero coverage of this header).
356    /// - `[ConnectionManager]…` → single-line.
357    /// - `Matchmaking:…` → single-line.
358    fn classify_header(header: EntryHeader, line: &str) -> HeaderClass {
359        match header {
360            EntryHeader::UnityCrossThreadLogger => {
361                // Look at the first byte after the closing bracket.
362                let after = line
363                    .strip_prefix("[UnityCrossThreadLogger]")
364                    .unwrap_or(line);
365                if after.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
366                    HeaderClass::MultiLine
367                } else {
368                    HeaderClass::SingleLine
369                }
370            }
371            EntryHeader::ClientGre => HeaderClass::MultiLine,
372            // ConnectionManager and Matchmaking are corpus-confirmed
373            // single-line. Metadata (`DETAILED LOGS: …`) is handled directly
374            // in `push_line` and never reaches this function — but it must
375            // appear here because `EntryHeader` is non_exhaustive, and a
376            // single-line classification is the safe default.
377            EntryHeader::ConnectionManager | EntryHeader::Matchmaking | EntryHeader::Metadata => {
378                HeaderClass::SingleLine
379            }
380        }
381    }
382
383    /// Takes the current entry out of the buffer, leaving it empty.
384    fn take_entry(&mut self) -> Option<LogEntry> {
385        let header = self.current_header.take()?;
386        let body = self.lines.join("\n");
387        self.lines.clear();
388        Some(LogEntry { header, body })
389    }
390}
391
392impl Default for LineBuffer {
393    fn default() -> Self {
394        Self::new()
395    }
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    /// Helper: build an expected `LogEntry` for concise assertions.
407    fn expected(header: EntryHeader, body: &str) -> LogEntry {
408        LogEntry {
409            header,
410            body: body.to_owned(),
411        }
412    }
413
414    // -- EntryHeader --------------------------------------------------------
415
416    mod entry_header {
417        use super::*;
418
419        #[test]
420        fn test_as_str_unity() {
421            assert_eq!(
422                EntryHeader::UnityCrossThreadLogger.as_str(),
423                "[UnityCrossThreadLogger]"
424            );
425        }
426
427        #[test]
428        fn test_as_str_client_gre() {
429            assert_eq!(EntryHeader::ClientGre.as_str(), "[Client GRE]");
430        }
431
432        #[test]
433        fn test_display_unity() {
434            assert_eq!(
435                EntryHeader::UnityCrossThreadLogger.to_string(),
436                "[UnityCrossThreadLogger]"
437            );
438        }
439
440        #[test]
441        fn test_display_client_gre() {
442            assert_eq!(EntryHeader::ClientGre.to_string(), "[Client GRE]");
443        }
444
445        #[test]
446        fn test_clone_and_eq() {
447            let a = EntryHeader::UnityCrossThreadLogger;
448            let b = a;
449            assert_eq!(a, b);
450        }
451    }
452
453    // -- LineBuffer: basic operation ----------------------------------------
454
455    mod push_line {
456        use super::*;
457
458        #[test]
459        fn test_push_line_first_multi_line_header_returns_empty() {
460            let mut buf = LineBuffer::new();
461            // Date-prefixed UCTL = multi-line; nothing to flush yet.
462            assert!(buf
463                .push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 Event")
464                .is_empty());
465        }
466
467        #[test]
468        fn test_push_line_second_multi_line_header_flushes_first_entry() {
469            let mut buf = LineBuffer::new();
470            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
471            assert_eq!(
472                buf.push_line("[Client GRE] 1/1/2025 Event2"),
473                vec![expected(
474                    EntryHeader::UnityCrossThreadLogger,
475                    "[UnityCrossThreadLogger]1/1/2025 Event1",
476                )],
477            );
478        }
479
480        #[test]
481        fn test_push_line_continuation_appended() {
482            let mut buf = LineBuffer::new();
483            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
484            buf.push_line(r#"{"key": "value"}"#);
485            buf.push_line(r#"{"more": "data"}"#);
486            assert_eq!(
487                buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event2"),
488                vec![expected(
489                    EntryHeader::UnityCrossThreadLogger,
490                    "[UnityCrossThreadLogger]1/1/2025 Event1\n\
491                     {\"key\": \"value\"}\n\
492                     {\"more\": \"data\"}",
493                )],
494            );
495        }
496
497        #[test]
498        fn test_push_line_client_gre_header_detected() {
499            let mut buf = LineBuffer::new();
500            buf.push_line("[Client GRE] GreMessage");
501            assert_eq!(
502                buf.flush(),
503                Some(expected(EntryHeader::ClientGre, "[Client GRE] GreMessage")),
504            );
505        }
506
507        /// Regression: `[Client GRE]` continues to accumulate continuation
508        /// lines after Phase 1 (multi-line classification preserved).
509        #[test]
510        fn test_push_line_client_gre_header_accumulates() {
511            let mut buf = LineBuffer::new();
512            buf.push_line("[Client GRE] GreToClientEvent");
513            buf.push_line("{");
514            buf.push_line(r#"  "key": "value""#);
515            buf.push_line("}");
516            assert_eq!(
517                buf.flush(),
518                Some(expected(
519                    EntryHeader::ClientGre,
520                    "[Client GRE] GreToClientEvent\n{\n  \"key\": \"value\"\n}",
521                )),
522            );
523        }
524
525        #[test]
526        fn test_push_line_alternating_multi_line_headers() {
527            let mut buf = LineBuffer::new();
528            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
529
530            assert_eq!(
531                buf.push_line("[Client GRE] Event2"),
532                vec![expected(
533                    EntryHeader::UnityCrossThreadLogger,
534                    "[UnityCrossThreadLogger]1/1/2025 Event1",
535                )],
536            );
537
538            assert_eq!(
539                buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event3"),
540                vec![expected(EntryHeader::ClientGre, "[Client GRE] Event2")],
541            );
542
543            assert_eq!(
544                buf.flush(),
545                Some(expected(
546                    EntryHeader::UnityCrossThreadLogger,
547                    "[UnityCrossThreadLogger]1/1/2025 Event3",
548                )),
549            );
550        }
551    }
552
553    // -- LineBuffer: single-line flush (Phase 1 of #153) -------------------
554
555    mod single_line_flush {
556        use super::*;
557
558        /// `[UnityCrossThreadLogger]` followed by an alpha label (e.g.,
559        /// `STATE CHANGED`) is single-line — emit immediately, leave the
560        /// buffer idle.
561        #[test]
562        fn test_push_line_single_line_uctl_label_flushes_immediately() {
563            let mut buf = LineBuffer::new();
564            let entries = buf.push_line(
565                "[UnityCrossThreadLogger]STATE CHANGED \
566                 {\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
567            );
568            assert_eq!(
569                entries,
570                vec![expected(
571                    EntryHeader::UnityCrossThreadLogger,
572                    "[UnityCrossThreadLogger]STATE CHANGED \
573                     {\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
574                )],
575            );
576            assert!(
577                buf.is_empty(),
578                "buffer must be idle after a single-line flush",
579            );
580        }
581
582        /// `[UnityCrossThreadLogger]==>` API request markers are single-line.
583        #[test]
584        fn test_push_line_single_line_uctl_arrow_flushes_immediately() {
585            let mut buf = LineBuffer::new();
586            let entries = buf.push_line(
587                "[UnityCrossThreadLogger]==> GraphGetGraphState \
588                 {\"id\":\"abc\",\"request\":\"{}\"}",
589            );
590            assert_eq!(entries.len(), 1);
591            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
592            assert!(buf.is_empty());
593        }
594
595        /// `[UnityCrossThreadLogger]Client.SceneChange {…}` exercises a
596        /// nested-bracket case where the continuation-detection logic must
597        /// not be confused by the inner `{` body.
598        #[test]
599        fn test_push_line_single_line_uctl_nested_bracket_flushes_immediately() {
600            let mut buf = LineBuffer::new();
601            let entries = buf.push_line(
602                "[UnityCrossThreadLogger]Client.SceneChange \
603                 {\"fromSceneName\":\"Home\",\"toSceneName\":\"Draft\"}",
604            );
605            assert_eq!(entries.len(), 1);
606            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
607            assert!(buf.is_empty());
608        }
609
610        /// `[ConnectionManager]…` is single-line.
611        #[test]
612        fn test_push_line_single_line_connection_manager_flushes_immediately() {
613            let mut buf = LineBuffer::new();
614            let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
615            assert_eq!(
616                entries,
617                vec![expected(
618                    EntryHeader::ConnectionManager,
619                    "[ConnectionManager] Reconnect succeeded",
620                )],
621            );
622            assert!(buf.is_empty());
623        }
624
625        /// `Matchmaking:…` is single-line.
626        #[test]
627        fn test_push_line_single_line_matchmaking_flushes_immediately() {
628            let mut buf = LineBuffer::new();
629            let entries = buf.push_line("Matchmaking: GRE connection lost");
630            assert_eq!(
631                entries,
632                vec![expected(
633                    EntryHeader::Matchmaking,
634                    "Matchmaking: GRE connection lost",
635                )],
636            );
637            assert!(buf.is_empty());
638        }
639
640        /// Multi-line headers (`[UnityCrossThreadLogger]<digit>`) keep
641        /// accumulating continuation lines until the next header — regression
642        /// guard for API-response handling.
643        #[test]
644        fn test_push_line_multi_line_date_header_accumulates() {
645            let mut buf = LineBuffer::new();
646            assert!(buf
647                .push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM")
648                .is_empty());
649            assert!(buf.push_line("<== EventGetCoursesV2(abc-123)").is_empty());
650            assert!(buf.push_line(r#"{"Courses":[]}"#).is_empty());
651
652            // Next header (a single-line UCTL alpha label) flushes the
653            // multi-line entry AND emits itself — both in one call.
654            let entries = buf.push_line("[UnityCrossThreadLogger]Client.SceneChange {}");
655            assert_eq!(entries.len(), 2);
656            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
657            assert_eq!(
658                entries[0].body,
659                "[UnityCrossThreadLogger]3/11/2026 6:08:24 PM\n\
660                 <== EventGetCoursesV2(abc-123)\n\
661                 {\"Courses\":[]}",
662            );
663            assert_eq!(entries[1].header, EntryHeader::UnityCrossThreadLogger);
664            assert_eq!(
665                entries[1].body,
666                "[UnityCrossThreadLogger]Client.SceneChange {}",
667            );
668        }
669
670        /// Unity stdout noise that arrives *after* a single-line flush is
671        /// orphaned (the buffer is idle) and discarded — it must not be
672        /// absorbed into the prior entry's body.
673        #[test]
674        fn test_push_line_post_single_line_orphan_discarded() {
675            let mut buf = LineBuffer::new();
676            // Single-line header — buffer goes idle immediately after.
677            let first = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
678            assert_eq!(first.len(), 1);
679            assert!(buf.is_empty());
680
681            // Unity stdout noise → orphan, discarded.
682            let noise = buf.push_line("PreviousPlayBladeVisualState is being set ...");
683            assert!(noise.is_empty());
684            assert!(buf.is_empty());
685
686            // Next header — emit cleanly with no contamination from the noise.
687            let next = buf.push_line("[UnityCrossThreadLogger]Connecting to matchId abc");
688            assert_eq!(next.len(), 1);
689            assert!(!next[0].body.contains("PreviousPlayBladeVisualState"));
690        }
691
692        /// A multi-line entry being flushed by a single-line header must
693        /// emit BOTH entries from one `push_line` call.
694        #[test]
695        fn test_push_line_multi_line_then_single_line_emits_two() {
696            let mut buf = LineBuffer::new();
697            buf.push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM");
698            buf.push_line("<== Foo(123)");
699
700            let entries = buf.push_line("[ConnectionManager] Reconnect failed");
701            assert_eq!(entries.len(), 2);
702            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
703            assert!(entries[0].body.contains("<== Foo(123)"));
704            assert_eq!(entries[1].header, EntryHeader::ConnectionManager);
705            assert_eq!(entries[1].body, "[ConnectionManager] Reconnect failed");
706            assert!(buf.is_empty());
707        }
708    }
709
710    // -- LineBuffer: headerless lines ---------------------------------------
711
712    mod headerless {
713        use super::*;
714
715        #[test]
716        fn test_push_line_headerless_before_first_header_returns_empty() {
717            let mut buf = LineBuffer::new();
718            assert!(buf.push_line("some random line").is_empty());
719            assert!(buf.push_line("another orphan").is_empty());
720            // After discarding, the next header should still work.
721            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Real entry");
722            assert_eq!(
723                buf.flush(),
724                Some(expected(
725                    EntryHeader::UnityCrossThreadLogger,
726                    "[UnityCrossThreadLogger]1/1/2025 Real entry",
727                )),
728            );
729        }
730
731        #[test]
732        fn test_push_line_empty_line_as_continuation() {
733            let mut buf = LineBuffer::new();
734            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
735            buf.push_line("");
736            buf.push_line("continuation");
737            assert_eq!(
738                buf.flush(),
739                Some(expected(
740                    EntryHeader::UnityCrossThreadLogger,
741                    "[UnityCrossThreadLogger]1/1/2025 Event\n\ncontinuation",
742                )),
743            );
744        }
745    }
746
747    // -- LineBuffer: flush --------------------------------------------------
748
749    mod flush {
750        use super::*;
751
752        #[test]
753        fn test_flush_empty_buffer_returns_none() {
754            let mut buf = LineBuffer::new();
755            assert!(buf.flush().is_none());
756        }
757
758        #[test]
759        fn test_flush_returns_buffered_multi_line_entry() {
760            let mut buf = LineBuffer::new();
761            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
762            assert_eq!(
763                buf.flush(),
764                Some(expected(
765                    EntryHeader::UnityCrossThreadLogger,
766                    "[UnityCrossThreadLogger]1/1/2025 Event",
767                )),
768            );
769        }
770
771        #[test]
772        fn test_flush_clears_buffer() {
773            let mut buf = LineBuffer::new();
774            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
775            buf.flush();
776            assert!(buf.flush().is_none());
777            assert!(buf.is_empty());
778        }
779
780        #[test]
781        fn test_flush_multi_line_entry() {
782            let mut buf = LineBuffer::new();
783            buf.push_line("[Client GRE] GreToClientEvent");
784            buf.push_line("{");
785            buf.push_line(r#"  "gameObjects": ["obj1", "obj2"],"#);
786            buf.push_line(r#"  "actions": []"#);
787            buf.push_line("}");
788            let expected_body = [
789                "[Client GRE] GreToClientEvent",
790                "{",
791                r#"  "gameObjects": ["obj1", "obj2"],"#,
792                r#"  "actions": []"#,
793                "}",
794            ]
795            .join("\n");
796            assert_eq!(
797                buf.flush(),
798                Some(expected(EntryHeader::ClientGre, &expected_body)),
799            );
800        }
801    }
802
803    // -- LineBuffer: reset --------------------------------------------------
804
805    mod reset {
806        use super::*;
807
808        #[test]
809        fn test_reset_clears_in_progress_entry() {
810            let mut buf = LineBuffer::new();
811            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
812            buf.push_line("continuation");
813            buf.reset();
814            assert!(buf.is_empty());
815            assert!(buf.flush().is_none());
816        }
817
818        #[test]
819        fn test_reset_allows_fresh_accumulation() {
820            let mut buf = LineBuffer::new();
821            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Old");
822            buf.reset();
823            buf.push_line("[Client GRE] New");
824            assert_eq!(
825                buf.flush(),
826                Some(expected(EntryHeader::ClientGre, "[Client GRE] New")),
827            );
828        }
829    }
830
831    // -- LineBuffer: is_empty -----------------------------------------------
832
833    mod is_empty {
834        use super::*;
835
836        #[test]
837        fn test_is_empty_on_new_buffer() {
838            let buf = LineBuffer::new();
839            assert!(buf.is_empty());
840        }
841
842        #[test]
843        fn test_is_empty_false_after_multi_line_header() {
844            let mut buf = LineBuffer::new();
845            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
846            assert!(!buf.is_empty());
847        }
848
849        /// Single-line entries leave the buffer idle — invariant relied on
850        /// by Phase 2 (#161).
851        #[test]
852        fn test_is_empty_true_after_single_line_flush() {
853            let mut buf = LineBuffer::new();
854            buf.push_line("[UnityCrossThreadLogger]STATE CHANGED");
855            assert!(buf.is_empty());
856        }
857
858        #[test]
859        fn test_is_empty_true_after_flush() {
860            let mut buf = LineBuffer::new();
861            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
862            buf.flush();
863            assert!(buf.is_empty());
864        }
865
866        #[test]
867        fn test_is_empty_true_after_headerless_lines() {
868            let mut buf = LineBuffer::new();
869            buf.push_line("orphan line");
870            assert!(buf.is_empty());
871        }
872    }
873
874    // -- LineBuffer: default ------------------------------------------------
875
876    mod default_impl {
877        use super::*;
878
879        #[test]
880        fn test_default_creates_functional_buffer() {
881            let mut buf = LineBuffer::default();
882            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
883            assert_eq!(
884                buf.flush(),
885                Some(expected(
886                    EntryHeader::UnityCrossThreadLogger,
887                    "[UnityCrossThreadLogger]1/1/2025 Event",
888                )),
889            );
890        }
891    }
892
893    // -- Header detection edge cases ----------------------------------------
894
895    mod header_detection {
896        use super::*;
897
898        #[test]
899        fn test_header_not_at_start_of_line_is_continuation() {
900            let mut buf = LineBuffer::new();
901            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
902            // Header pattern in the middle of a line is NOT a boundary.
903            buf.push_line("some text [UnityCrossThreadLogger] not a header");
904            assert_eq!(
905                buf.flush(),
906                Some(expected(
907                    EntryHeader::UnityCrossThreadLogger,
908                    "[UnityCrossThreadLogger]1/1/2025 Event\n\
909                     some text [UnityCrossThreadLogger] not a header",
910                )),
911            );
912        }
913
914        #[test]
915        fn test_similar_but_wrong_header_is_continuation() {
916            let mut buf = LineBuffer::new();
917            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
918            buf.push_line("[UnityMainThreadLogger] not a valid header");
919            let result = buf.flush();
920            assert!(result.is_some());
921            if let Some(e) = result {
922                assert!(e.body.contains("[UnityMainThreadLogger]"));
923            }
924        }
925
926        #[test]
927        fn test_bracket_only_is_not_header() {
928            let mut buf = LineBuffer::new();
929            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
930            buf.push_line("[]");
931            assert_eq!(
932                buf.flush(),
933                Some(expected(
934                    EntryHeader::UnityCrossThreadLogger,
935                    "[UnityCrossThreadLogger]1/1/2025 Event\n[]",
936                )),
937            );
938        }
939
940        #[test]
941        fn test_header_with_nothing_after_bracket() {
942            let mut buf = LineBuffer::new();
943            // `[UnityCrossThreadLogger]` with no trailing content classifies
944            // as single-line (no leading digit) — emit and go idle.
945            let entries = buf.push_line("[UnityCrossThreadLogger]");
946            assert_eq!(
947                entries,
948                vec![expected(
949                    EntryHeader::UnityCrossThreadLogger,
950                    "[UnityCrossThreadLogger]",
951                )],
952            );
953            assert!(buf.is_empty());
954        }
955    }
956
957    // -- Realistic multi-line entry -----------------------------------------
958
959    mod realistic_entries {
960        use super::*;
961
962        #[test]
963        fn test_realistic_game_state_message() {
964            let mut buf = LineBuffer::new();
965            buf.push_line(
966                "[UnityCrossThreadLogger]1/15/2025 3:42:17 PM \
967                 greToClientEvent",
968            );
969            buf.push_line("{");
970            buf.push_line(r#"  "greToClientMessages": ["#);
971            buf.push_line(r"    {");
972            buf.push_line(r#"      "type": "GREMessageType_GameStateMessage","#);
973            buf.push_line(r#"      "gameStateMessage": {"#);
974            buf.push_line(r#"        "gameObjects": []"#);
975            buf.push_line(r"      }");
976            buf.push_line(r"    }");
977            buf.push_line(r"  ]");
978            buf.push_line("}");
979
980            // [Client GRE] (multi-line) flushes the UnityCrossThreadLogger entry.
981            let unity_entries = buf.push_line("[Client GRE] Next event");
982            assert_eq!(unity_entries.len(), 1);
983            assert_eq!(unity_entries[0].header, EntryHeader::UnityCrossThreadLogger);
984            assert!(unity_entries[0].body.contains("greToClientMessages"));
985            assert!(unity_entries[0].body.contains("GameStateMessage"));
986
987            // Another header flushes the Client GRE entry.
988            assert_eq!(
989                buf.push_line("[UnityCrossThreadLogger]1/15/2025 After"),
990                vec![expected(EntryHeader::ClientGre, "[Client GRE] Next event")],
991            );
992        }
993
994        #[test]
995        fn test_many_single_line_entries_in_sequence() {
996            let mut buf = LineBuffer::new();
997            let mut entries = Vec::new();
998
999            for i in 0..5 {
1000                // Single-line UCTL alpha labels — each flushes immediately.
1001                entries.extend(buf.push_line(&format!("[UnityCrossThreadLogger]Event{i}")));
1002            }
1003            entries.extend(buf.flush());
1004
1005            assert_eq!(entries.len(), 5);
1006            for (i, e) in entries.iter().enumerate() {
1007                assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
1008                assert_eq!(e.body, format!("[UnityCrossThreadLogger]Event{i}"));
1009            }
1010        }
1011    }
1012
1013    // -- Metadata line detection -----------------------------------------------
1014
1015    mod metadata_lines {
1016        use super::*;
1017
1018        #[test]
1019        fn test_push_line_detailed_logs_enabled_as_first_line() {
1020            let mut buf = LineBuffer::new();
1021            let result = buf.push_line("DETAILED LOGS: ENABLED");
1022
1023            assert_eq!(
1024                result,
1025                vec![expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")],
1026            );
1027            // Buffer should be empty after — metadata is self-contained.
1028            assert!(buf.is_empty());
1029        }
1030
1031        #[test]
1032        fn test_push_line_detailed_logs_disabled_as_first_line() {
1033            let mut buf = LineBuffer::new();
1034            let result = buf.push_line("DETAILED LOGS: DISABLED");
1035
1036            assert_eq!(
1037                result,
1038                vec![expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")],
1039            );
1040            assert!(buf.is_empty());
1041        }
1042
1043        /// Metadata after an in-progress multi-line entry flushes the prior
1044        /// entry AND emits the metadata entry — both in one call.
1045        #[test]
1046        fn test_push_line_metadata_flushes_buffered_entry() {
1047            let mut buf = LineBuffer::new();
1048            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1049
1050            let entries = buf.push_line("DETAILED LOGS: ENABLED");
1051            assert_eq!(
1052                entries,
1053                vec![
1054                    expected(
1055                        EntryHeader::UnityCrossThreadLogger,
1056                        "[UnityCrossThreadLogger]1/1/2025 Event1",
1057                    ),
1058                    expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED"),
1059                ],
1060            );
1061            // Buffer is idle after — metadata is self-contained.
1062            assert!(buf.is_empty());
1063        }
1064
1065        #[test]
1066        fn test_push_line_metadata_then_header_flushes_metadata() {
1067            let mut buf = LineBuffer::new();
1068            buf.push_line("DETAILED LOGS: ENABLED");
1069
1070            // Next multi-line header — nothing to flush (metadata was emitted
1071            // immediately on its own call).
1072            assert!(buf
1073                .push_line("[UnityCrossThreadLogger]1/1/2025 Event")
1074                .is_empty());
1075            assert_eq!(
1076                buf.flush(),
1077                Some(expected(
1078                    EntryHeader::UnityCrossThreadLogger,
1079                    "[UnityCrossThreadLogger]1/1/2025 Event",
1080                )),
1081            );
1082        }
1083
1084        #[test]
1085        fn test_push_line_metadata_similar_text_not_matched() {
1086            let mut buf = LineBuffer::new();
1087            // Similar but not exact — should be treated as headerless.
1088            assert!(buf.push_line("DETAILED LOGS: UNKNOWN").is_empty());
1089            assert!(buf.push_line("detailed logs: enabled").is_empty());
1090            assert!(buf.push_line("DETAILED LOGS:ENABLED").is_empty());
1091        }
1092
1093        #[test]
1094        fn test_push_line_metadata_with_leading_trailing_whitespace() {
1095            let mut buf = LineBuffer::new();
1096            // Whitespace around the exact text should still match.
1097            let result = buf.push_line("  DETAILED LOGS: ENABLED  ");
1098            assert_eq!(result.len(), 1);
1099            assert_eq!(result[0].header, EntryHeader::Metadata);
1100        }
1101
1102        #[test]
1103        fn test_entry_header_metadata_as_str() {
1104            assert_eq!(EntryHeader::Metadata.as_str(), "METADATA");
1105        }
1106
1107        #[test]
1108        fn test_entry_header_metadata_display() {
1109            assert_eq!(EntryHeader::Metadata.to_string(), "METADATA");
1110        }
1111    }
1112
1113    // -- Phase 2 (#161): orphan-warn gating ---------------------------------
1114
1115    mod orphan_warn_gating {
1116        use super::*;
1117        use std::sync::{Mutex, OnceLock};
1118
1119        /// In-test log capture: records every record's level + message so the
1120        /// gating tests can assert whether a warn fired.
1121        ///
1122        /// `log` only allows one global logger per process. We install it
1123        /// once via `OnceLock` and serialize the gating tests through a mutex
1124        /// so the captured-record buffer can be inspected race-free.
1125        struct CaptureLogger {
1126            records: Mutex<Vec<(::log::Level, String)>>,
1127        }
1128
1129        impl ::log::Log for CaptureLogger {
1130            fn enabled(&self, _metadata: &::log::Metadata<'_>) -> bool {
1131                true
1132            }
1133            fn log(&self, record: &::log::Record<'_>) {
1134                let mut guard = match self.records.lock() {
1135                    Ok(g) => g,
1136                    Err(poisoned) => poisoned.into_inner(),
1137                };
1138                guard.push((record.level(), record.args().to_string()));
1139            }
1140            fn flush(&self) {}
1141        }
1142
1143        static LOGGER: OnceLock<&'static CaptureLogger> = OnceLock::new();
1144
1145        type RecordsRef = &'static Mutex<Vec<(::log::Level, String)>>;
1146
1147        /// Installs the capture logger (idempotent) and returns a handle to
1148        /// the global capture buffer.
1149        ///
1150        /// The capture buffer accumulates records from every test that runs
1151        /// in this process, so callers MUST filter the captured records by a
1152        /// per-test sentinel marker — see [`warn_count_matching`]. This
1153        /// avoids the parallel-test race that a "clear before each test"
1154        /// strategy would introduce.
1155        fn install_capture() -> RecordsRef {
1156            let logger = LOGGER.get_or_init(|| {
1157                let leaked: &'static CaptureLogger = Box::leak(Box::new(CaptureLogger {
1158                    records: Mutex::new(Vec::new()),
1159                }));
1160                // `set_logger` errors if a logger is already installed by
1161                // another test setup; in that case our captures will be
1162                // silently dropped, which is acceptable here because the
1163                // gating logic is also covered by behavioral tests
1164                // (`is_empty`, header round-trips) above.
1165                let _ = ::log::set_logger(leaked);
1166                ::log::set_max_level(::log::LevelFilter::Trace);
1167                leaked
1168            });
1169            &logger.records
1170        }
1171
1172        /// Counts captured warn-level records that contain `marker` in the
1173        /// message body.
1174        ///
1175        /// Tests pass a per-test sentinel string as the orphan input so the
1176        /// captured warning's truncated payload contains that sentinel.
1177        /// Filtering on the sentinel makes the count race-free even though
1178        /// Rust's test harness runs tests in parallel by default and other
1179        /// modules' tests share the same global logger.
1180        fn warn_count_matching(
1181            records: &Mutex<Vec<(::log::Level, String)>>,
1182            marker: &str,
1183        ) -> usize {
1184            let guard = match records.lock() {
1185                Ok(g) => g,
1186                Err(poisoned) => poisoned.into_inner(),
1187            };
1188            guard
1189                .iter()
1190                .filter(|(lvl, msg)| {
1191                    *lvl == ::log::Level::Warn
1192                        && msg.starts_with("Discarding headerless line at start of input")
1193                        && msg.contains(marker)
1194                })
1195                .count()
1196        }
1197
1198        /// Orphan line before any header has been seen still produces the
1199        /// existing warning — this is the file-start anomaly the message was
1200        /// originally meant to flag.
1201        #[test]
1202        fn test_push_line_first_orphan_warns() {
1203            const MARKER: &str = "P2-MARKER-FIRST-ORPHAN-WARNS-zX9q";
1204            let records = install_capture();
1205            let mut buf = LineBuffer::new();
1206
1207            assert!(buf.push_line(MARKER).is_empty());
1208
1209            assert_eq!(
1210                warn_count_matching(records, MARKER),
1211                1,
1212                "first orphan at file start must warn (rotation/file-start anomaly)",
1213            );
1214        }
1215
1216        /// After a single-line entry has flushed, a subsequent headerless
1217        /// line is routine Unity stdout noise — silently discard, no warn.
1218        #[test]
1219        fn test_push_line_post_flush_orphan_silent() {
1220            const MARKER: &str = "P2-MARKER-POST-FLUSH-SILENT-kJ7w";
1221            let records = install_capture();
1222            let mut buf = LineBuffer::new();
1223
1224            // Single-line flush arms the gating flag.
1225            let entries = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
1226            assert_eq!(entries.len(), 1);
1227            assert!(buf.is_empty());
1228
1229            // Unity stdout noise — should be silently dropped.
1230            assert!(buf.push_line(MARKER).is_empty());
1231
1232            assert_eq!(
1233                warn_count_matching(records, MARKER),
1234                0,
1235                "post-flush orphan must be silently discarded (no warn)",
1236            );
1237        }
1238
1239        /// `reset()` re-arms the warning so post-rotation orphans still
1240        /// surface — the rotation case the warn was originally meant to
1241        /// catch.
1242        #[test]
1243        fn test_push_line_orphan_after_reset_warns() {
1244            const MARKER: &str = "P2-MARKER-AFTER-RESET-WARNS-vN2t";
1245            let records = install_capture();
1246            let mut buf = LineBuffer::new();
1247
1248            // Flush an entry to arm the flag.
1249            assert_eq!(
1250                buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {}")
1251                    .len(),
1252                1,
1253            );
1254
1255            // Simulate file rotation — flag must drop back to false.
1256            buf.reset();
1257
1258            // First orphan after reset must warn again.
1259            assert!(buf.push_line(MARKER).is_empty());
1260
1261            assert_eq!(
1262                warn_count_matching(records, MARKER),
1263                1,
1264                "first orphan after reset must warn (rotation anomaly)",
1265            );
1266        }
1267
1268        /// A metadata line (`DETAILED LOGS: ENABLED`) is a successfully
1269        /// emitted entry, so subsequent orphan lines are post-flush noise
1270        /// and must be silently discarded.
1271        #[test]
1272        fn test_push_line_orphan_after_metadata_silent() {
1273            const MARKER: &str = "P2-MARKER-AFTER-METADATA-SILENT-bH4r";
1274            let records = install_capture();
1275            let mut buf = LineBuffer::new();
1276
1277            // Metadata arms the flag.
1278            let entries = buf.push_line("DETAILED LOGS: ENABLED");
1279            assert_eq!(entries.len(), 1);
1280            assert_eq!(entries[0].header, EntryHeader::Metadata);
1281
1282            // Subsequent orphan — silent.
1283            assert!(buf.push_line(MARKER).is_empty());
1284
1285            assert_eq!(
1286                warn_count_matching(records, MARKER),
1287                0,
1288                "orphan after metadata must be silently discarded (no warn)",
1289            );
1290        }
1291    }
1292
1293    // -- ConnectionManager / Matchmaking header framing ---------------------
1294
1295    mod connection_and_matchmaking_headers {
1296        use super::*;
1297
1298        #[test]
1299        fn test_as_str_connection_manager() {
1300            assert_eq!(
1301                EntryHeader::ConnectionManager.as_str(),
1302                "[ConnectionManager]"
1303            );
1304        }
1305
1306        #[test]
1307        fn test_as_str_matchmaking() {
1308            // The `Matchmaking:` prefix keeps the colon — this matches how
1309            // the line appears in Arena's actual log.
1310            assert_eq!(EntryHeader::Matchmaking.as_str(), "Matchmaking:");
1311        }
1312
1313        #[test]
1314        fn test_display_connection_manager() {
1315            assert_eq!(
1316                EntryHeader::ConnectionManager.to_string(),
1317                "[ConnectionManager]"
1318            );
1319        }
1320
1321        #[test]
1322        fn test_display_matchmaking() {
1323            assert_eq!(EntryHeader::Matchmaking.to_string(), "Matchmaking:");
1324        }
1325
1326        #[test]
1327        fn test_connection_manager_header_mid_stream_flushes_unity() {
1328            let mut buf = LineBuffer::new();
1329            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1330
1331            let entries = buf.push_line("[ConnectionManager] Reconnect result : Error");
1332            assert_eq!(
1333                entries,
1334                vec![
1335                    expected(
1336                        EntryHeader::UnityCrossThreadLogger,
1337                        "[UnityCrossThreadLogger]1/1/2025 Event1",
1338                    ),
1339                    expected(
1340                        EntryHeader::ConnectionManager,
1341                        "[ConnectionManager] Reconnect result : Error",
1342                    ),
1343                ],
1344            );
1345            // ConnectionManager is single-line — buffer is idle.
1346            assert!(buf.is_empty());
1347        }
1348
1349        #[test]
1350        fn test_matchmaking_header_mid_stream_flushes_unity() {
1351            let mut buf = LineBuffer::new();
1352            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1353
1354            let entries = buf.push_line("Matchmaking: GRE connection lost");
1355            assert_eq!(
1356                entries,
1357                vec![
1358                    expected(
1359                        EntryHeader::UnityCrossThreadLogger,
1360                        "[UnityCrossThreadLogger]1/1/2025 Event1",
1361                    ),
1362                    expected(EntryHeader::Matchmaking, "Matchmaking: GRE connection lost",),
1363                ],
1364            );
1365            assert!(buf.is_empty());
1366        }
1367
1368        #[test]
1369        fn test_connection_manager_as_first_line_emits_immediately() {
1370            // Single-line semantics: the ConnectionManager entry is emitted
1371            // by the same `push_line` call that received it.
1372            let mut buf = LineBuffer::new();
1373            let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
1374            assert_eq!(
1375                entries,
1376                vec![expected(
1377                    EntryHeader::ConnectionManager,
1378                    "[ConnectionManager] Reconnect succeeded",
1379                )],
1380            );
1381            assert!(buf.is_empty());
1382        }
1383
1384        #[test]
1385        fn test_matchmaking_as_first_line_emits_immediately() {
1386            let mut buf = LineBuffer::new();
1387            let entries = buf.push_line("Matchmaking: GRE connection lost");
1388            assert_eq!(
1389                entries,
1390                vec![expected(
1391                    EntryHeader::Matchmaking,
1392                    "Matchmaking: GRE connection lost",
1393                )],
1394            );
1395            assert!(buf.is_empty());
1396        }
1397
1398        #[test]
1399        fn test_four_way_interleave_yields_four_entries() {
1400            // Realistic corpus-derived pattern from issues #528/#529:
1401            // Unity STATE CHANGED → Matchmaking: GRE connection lost →
1402            // ConnectionManager Reconnect result → Unity (next event).
1403            // All four are single-line, so each `push_line` returns 1 entry.
1404            let mut buf = LineBuffer::new();
1405            let mut entries = Vec::new();
1406
1407            entries.extend(buf.push_line(
1408                "[UnityCrossThreadLogger]STATE CHANGED \
1409                 {\"old\":\"Playing\",\"new\":\"Disconnected\"}",
1410            ));
1411            entries.extend(buf.push_line("Matchmaking: GRE connection lost"));
1412            entries.extend(buf.push_line("[ConnectionManager] Reconnect result : Error"));
1413            entries.extend(buf.push_line("[UnityCrossThreadLogger]Next event"));
1414            entries.extend(buf.flush());
1415
1416            assert_eq!(entries.len(), 4);
1417            assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
1418            assert!(entries[0].body.contains("STATE CHANGED"));
1419            assert_eq!(entries[1].header, EntryHeader::Matchmaking);
1420            assert_eq!(entries[1].body, "Matchmaking: GRE connection lost");
1421            assert_eq!(entries[2].header, EntryHeader::ConnectionManager);
1422            assert_eq!(
1423                entries[2].body,
1424                "[ConnectionManager] Reconnect result : Error"
1425            );
1426            assert_eq!(entries[3].header, EntryHeader::UnityCrossThreadLogger);
1427            assert_eq!(entries[3].body, "[UnityCrossThreadLogger]Next event");
1428        }
1429
1430        #[test]
1431        fn test_matchmaking_without_trailing_space_is_not_header() {
1432            // The starts_with check requires the trailing space ("Matchmaking: ")
1433            // to avoid matching unrelated prefixes that happen to start
1434            // with "Matchmaking:". Without the space it should be a
1435            // headerless line (discarded at start of stream).
1436            let mut buf = LineBuffer::new();
1437            assert!(buf.push_line("Matchmaking:compact-no-space").is_empty());
1438            assert!(buf.is_empty());
1439        }
1440
1441        #[test]
1442        fn test_connection_manager_mid_line_is_continuation() {
1443            let mut buf = LineBuffer::new();
1444            buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1445            // ConnectionManager bracket pattern in the middle of a line is
1446            // NOT a boundary — same rule as other bracketed headers.
1447            buf.push_line("some text [ConnectionManager] not a header");
1448            assert_eq!(
1449                buf.flush(),
1450                Some(expected(
1451                    EntryHeader::UnityCrossThreadLogger,
1452                    "[UnityCrossThreadLogger]1/1/2025 Event\n\
1453                     some text [ConnectionManager] not a header",
1454                )),
1455            );
1456        }
1457    }
1458}