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}