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 entry is structurally complete.
6//!
7//! # Header classification (Phase 1 of #153)
8//!
9//! Each detected header is classified as either single-line or multi-line:
10//!
11//! - **Single-line**: `[UnityCrossThreadLogger]` followed by anything other
12//! than a date digit (e.g., alpha labels like `STATE CHANGED`,
13//! `Client.SceneChange`, or `==>` API request markers),
14//! `[ConnectionManager]…`, and `Matchmaking:…`. These entries are
15//! flushed in the same [`LineBuffer::push_line`] call that received them
16//! — no continuation accumulation.
17//! - **Multi-line**: `[UnityCrossThreadLogger]<digit>` (date-prefixed API
18//! responses, match events) and `[Client GRE]…`. These entries
19//! accumulate continuation lines until the entry's JSON body is
20//! structurally complete (brace-balance flush) or the next header arrives
21//! (fallback for non-JSON bodies).
22//!
23//! # Brace-balance flush (Phase 3 of #153 / #193)
24//!
25//! Multi-line entries whose body contains a `{` are flushed the moment the
26//! brace depth returns to 0 — they no longer wait for the next header to
27//! arrive. A small state machine counts `{` and `}` while tracking string
28//! literals (`"`) and backslash escapes (`\\`), so braces appearing inside
29//! JSON string values do not count. Corpus analysis (44 sessions, 47,412
30//! multi-line entries) shows every entry that opens a `{` closes it within
31//! the entry boundary; bodies that never open a `{` (the rare "Message
32//! summarized…" GRE markers and a few `true`-only REST responses) still
33//! flush on the next header via the original fallback path.
34//!
35//! This behavior is enabled by default via the `brace_depth_flush` cargo
36//! feature. Disabling the feature reverts to the original "flush on next
37//! header" behavior for every multi-line entry — kept as a one-flip rollback
38//! in case a string-literal edge case surfaces in live Arena traffic.
39//!
40//! # Data flow
41//!
42//! ```text
43//! File Tailer ──(raw lines)──▸ LineBuffer ──(complete entries)──▸ Router
44//! ```
45//!
46//! The [`LineBuffer`] receives individual lines from the file tailer. When a
47//! new log entry header is detected, it flushes the previously accumulated
48//! lines as a complete [`LogEntry`] and either emits the new entry
49//! immediately (single-line class) or begins accumulating it (multi-line
50//! class).
51
52use regex::Regex;
53
54use crate::util::truncate_for_log;
55
56/// The known log entry header prefixes in MTG Arena's `Player.log`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum EntryHeader {
60 /// `[UnityCrossThreadLogger]` — the most common header, used for
61 /// game state, client actions, match lifecycle, and most other events.
62 UnityCrossThreadLogger,
63 /// `[Client GRE]` — used for Game Rules Engine messages.
64 ClientGre,
65 /// `[ConnectionManager]` — emitted for Arena's connection-lifecycle
66 /// diagnostics (e.g., `Reconnect result : ...`, `Reconnect succeeded`,
67 /// `Reconnect failed`). These lines are plain-text, single-line entries
68 /// in practice.
69 ConnectionManager,
70 /// `Matchmaking:` — a bare (non-bracketed) prefix Arena emits for
71 /// matchmaking-side connection markers such as
72 /// `Matchmaking: GRE connection lost`. These lines are plain-text,
73 /// single-line entries in practice.
74 Matchmaking,
75 /// Metadata lines that appear outside bracket-delimited entries.
76 ///
77 /// Currently covers `DETAILED LOGS: ENABLED` and `DETAILED LOGS: DISABLED`,
78 /// which Arena writes near the top of every session (typically line 24).
79 Metadata,
80}
81
82impl EntryHeader {
83 /// Returns the header string as it appears in the log.
84 ///
85 /// Bracket-delimited headers return the full `[...]` prefix.
86 /// `Metadata` returns `"METADATA"` as a synthetic label (metadata
87 /// lines have no bracket prefix in the actual log).
88 pub fn as_str(self) -> &'static str {
89 match self {
90 Self::UnityCrossThreadLogger => "[UnityCrossThreadLogger]",
91 Self::ClientGre => "[Client GRE]",
92 Self::ConnectionManager => "[ConnectionManager]",
93 Self::Matchmaking => "Matchmaking:",
94 Self::Metadata => "METADATA",
95 }
96 }
97}
98
99impl std::fmt::Display for EntryHeader {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(self.as_str())
102 }
103}
104
105/// A complete log entry extracted from the line buffer.
106///
107/// Contains the detected header prefix and the full raw text of the entry
108/// (header line plus any continuation lines for multi-line payloads).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct LogEntry {
111 /// Which header prefix introduced this entry.
112 pub header: EntryHeader,
113 /// The full raw text of the entry, including the header line and all
114 /// continuation lines. Lines are joined with `'\n'`.
115 pub body: String,
116}
117
118/// Internal classification of a header line for flush-timing decisions.
119///
120/// See module-level docs for the full classification rule.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum HeaderClass {
123 /// The entry is self-contained — flush immediately.
124 SingleLine,
125 /// The entry may span multiple lines — accumulate until the next header.
126 MultiLine,
127}
128
129/// Accumulates raw lines and produces complete [`LogEntry`] values when an
130/// entry is structurally complete.
131///
132/// # Usage
133///
134/// Feed lines one at a time via [`push_line`](Self::push_line). Each call
135/// returns a `Vec<LogEntry>` containing zero, one, or two complete entries:
136///
137/// - **Zero entries**: continuation line for an in-progress multi-line entry,
138/// or a headerless line discarded with a warning.
139/// - **One entry**: a single-line entry emitted on arrival, a multi-line
140/// entry being brace-balance-flushed (default feature behavior), or a
141/// multi-line entry being flushed by the arrival of the next header.
142/// - **Two entries**: a multi-line entry being flushed by a new header
143/// *plus* the new single-line entry that triggered the flush, both
144/// emitted from one call.
145///
146/// After the input stream ends (EOF or file rotation), call
147/// [`flush`](Self::flush) to retrieve any remaining buffered entry.
148///
149/// # Flush triggers
150///
151/// With the default `brace_depth_flush` feature enabled, a multi-line
152/// entry flushes the moment its body's JSON depth returns to 0 — no need
153/// to wait for the next header. Bodies that never contain a `{` (rare
154/// non-JSON GRE markers and `true`-bodied REST responses) still fall back
155/// to the original "flush on next header" path. See the module-level docs
156/// for the corpus analysis backing this design.
157///
158/// # Example
159///
160/// ```
161/// use manasight_parser::log::entry::LineBuffer;
162///
163/// let mut buf = LineBuffer::new();
164///
165/// // First header (multi-line, date-prefixed) — nothing to flush yet.
166/// assert!(buf.push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 PM").is_empty());
167///
168/// // Continuation line opens a `{` — still accumulating until the body
169/// // brace-balances (or, with the feature disabled, until the next header).
170/// assert!(buf.push_line(r#"{"key": "ba"#).is_empty());
171///
172/// // The body's brace depth returns to 0 — entry flushes immediately
173/// // (default feature on); the next header is not required.
174/// let entries = buf.push_line(r#" r"}"#);
175/// # #[cfg(feature = "brace_depth_flush")]
176/// assert_eq!(entries.len(), 1);
177/// ```
178pub struct LineBuffer {
179 /// Compiled regex for detecting log entry header boundaries.
180 header_re: Regex,
181 /// Header of the entry currently being accumulated, if any.
182 ///
183 /// Only ever populated for multi-line entries. Single-line entries are
184 /// emitted immediately and never set this field — leaving the buffer in
185 /// an idle state after every single-line flush.
186 current_header: Option<EntryHeader>,
187 /// Lines accumulated for the current entry.
188 lines: Vec<String>,
189 /// Whether this buffer has ever emitted (or begun accumulating) an entry.
190 ///
191 /// Armed by [`push_line`](Self::push_line) when a real header is detected
192 /// or a metadata line is emitted. Cleared back to `false` by
193 /// [`reset`](Self::reset) so post-rotation orphan lines still surface a
194 /// warning. Used to silence the routine post-flush "orphan discarded"
195 /// warning (Phase 2 of #153 / #161): once any entry has been seen, an
196 /// arriving headerless line is Unity stdout noise rather than a true
197 /// file-start anomaly.
198 has_emitted_anything: bool,
199
200 /// Brace-balance state machine used to detect structurally complete
201 /// JSON bodies inside multi-line entries. See [`BraceState`].
202 #[cfg(feature = "brace_depth_flush")]
203 brace_state: BraceState,
204}
205
206/// In-entry brace-depth and string-literal state for the brace-balance
207/// flush trigger. See [`LineBuffer::advance_brace_state`].
208///
209/// Grouped into its own struct so [`LineBuffer`] does not exceed clippy's
210/// pedantic `struct_excessive_bools` threshold once all four fields are
211/// added — and to make the "reset to defaults on take/reset/new" pattern
212/// a single field swap rather than four parallel writes.
213#[cfg(feature = "brace_depth_flush")]
214#[derive(Default)]
215struct BraceState {
216 /// Running brace depth for the current entry's body. Zero when no `{`
217 /// has been seen yet in this entry. Combined with [`Self::ever_opened`],
218 /// returning to 0 signals a structurally complete JSON body.
219 depth: u32,
220 /// Whether the character cursor is currently inside a JSON string literal.
221 /// Toggled by an unescaped `"`; braces inside a string literal are
222 /// ignored so structurally-complete JSON bodies cannot be falsely
223 /// signaled by `{`/`}` characters embedded in string values.
224 in_string: bool,
225 /// Whether the next character should be treated as escaped — i.e., the
226 /// previous character was a backslash inside a string literal.
227 escape_pending: bool,
228 /// True once any `{` has been observed in the current entry's body.
229 /// Combined with `depth == 0`, signals a complete JSON body and triggers
230 /// an immediate flush. Entries that never open a `{` keep this false
231 /// and fall through to the next-header flush path.
232 ever_opened: bool,
233}
234
235impl LineBuffer {
236 /// Creates a new, empty line buffer with the compiled header regex.
237 pub fn new() -> Self {
238 // The regex crate documents that `Regex::new` only fails on invalid
239 // patterns. This pattern is a compile-time constant and is valid, so
240 // the `Err` branch is unreachable in practice.
241 let header_re =
242 match Regex::new(r"^\[(UnityCrossThreadLogger|Client GRE|ConnectionManager)\]") {
243 Ok(re) => re,
244 Err(e) => unreachable!("invalid header regex: {e}"),
245 };
246 Self {
247 header_re,
248 current_header: None,
249 lines: Vec::new(),
250 has_emitted_anything: false,
251 #[cfg(feature = "brace_depth_flush")]
252 brace_state: BraceState::default(),
253 }
254 }
255
256 /// Feeds a single line into the buffer.
257 ///
258 /// Returns a `Vec<LogEntry>` containing 0, 1, or 2 complete entries
259 /// — see the [type-level documentation](Self) for the full semantics.
260 ///
261 /// # Header classification
262 ///
263 /// When `line` matches a known header pattern, it is classified as either
264 /// single-line or multi-line (see module-level docs). Single-line
265 /// headers (`[UnityCrossThreadLogger]<non-digit>`, `[ConnectionManager]…`,
266 /// `Matchmaking:…`) flush any prior multi-line entry and emit the new
267 /// entry in the same call. Multi-line headers
268 /// (`[UnityCrossThreadLogger]<digit>`, `[Client GRE]…`) flush any prior
269 /// entry and begin a fresh accumulation.
270 ///
271 /// Metadata lines (`DETAILED LOGS: ENABLED` / `DISABLED`) are
272 /// self-contained — treated as single-line entries that flush any prior
273 /// in-progress entry alongside themselves.
274 ///
275 /// Lines that arrive before any header has been seen are discarded with
276 /// a warning log — this handles partial entries at the start of a file
277 /// or after rotation.
278 ///
279 /// # Input contract
280 ///
281 /// Callers must strip any trailing `\r` (Windows CRLF) before invoking
282 /// this method. [`crate::log::tailer::FileTailer::poll`] already does
283 /// this; direct callers in tests must do the same to keep classification
284 /// well-defined.
285 pub fn push_line(&mut self, line: &str) -> Vec<LogEntry> {
286 // Check for metadata lines first — these are self-contained.
287 if Self::is_metadata_line(line) {
288 let mut out = Vec::new();
289 if let Some(prior) = self.take_entry() {
290 out.push(prior);
291 }
292 out.push(LogEntry {
293 header: EntryHeader::Metadata,
294 body: line.to_owned(),
295 });
296 // Metadata is a successfully emitted entry — subsequent orphan
297 // lines are routine post-flush noise, not a file-start anomaly.
298 self.has_emitted_anything = true;
299 return out;
300 }
301
302 if let Some(header) = self.detect_header(line) {
303 let class = Self::classify_header(header, line);
304 let mut out = Vec::new();
305 if let Some(prior) = self.take_entry() {
306 out.push(prior);
307 }
308 match class {
309 HeaderClass::SingleLine => {
310 // Emit the new entry immediately; leave the buffer idle
311 // so Phase 2 (#161) can distinguish post-flush orphans.
312 out.push(LogEntry {
313 header,
314 body: line.to_owned(),
315 });
316 }
317 HeaderClass::MultiLine => {
318 // Begin accumulating the new multi-line entry.
319 self.current_header = Some(header);
320 self.lines.push(line.to_owned());
321 }
322 }
323 // A real header was seen — arm the flag so subsequent orphans
324 // are silenced.
325 self.has_emitted_anything = true;
326 out
327 } else if self.current_header.is_some() {
328 // Continuation line for the current multi-line entry.
329 self.lines.push(line.to_owned());
330 #[cfg(feature = "brace_depth_flush")]
331 if self.advance_brace_state(line) {
332 // The body's JSON depth has returned to 0 with at least one
333 // `{` seen — the entry is structurally complete. Flush now
334 // rather than waiting for the next header to arrive.
335 if let Some(entry) = self.take_entry() {
336 return vec![entry];
337 }
338 }
339 Vec::new()
340 } else {
341 // Headerless line with no entry in progress. Two cases:
342 //
343 // 1. True file-start / post-rotation anomaly (no header has ever
344 // been seen): warn — this is what the message is meant to
345 // flag.
346 // 2. Routine post-flush orphan (Unity stdout noise arriving
347 // between Arena entries after Phase 1's single-line flush
348 // landed): silently discard — the warn would be pure noise.
349 if !self.has_emitted_anything {
350 ::log::warn!(
351 "Discarding headerless line at start of input: {:?}",
352 truncate_for_log(line, 120),
353 );
354 }
355 Vec::new()
356 }
357 }
358
359 /// Flushes any remaining buffered entry.
360 ///
361 /// Call this when the input stream ends (EOF or file rotation) to
362 /// retrieve the last accumulated multi-line entry, if any. Single-line
363 /// entries are never buffered — they are emitted by [`push_line`] in the
364 /// same call that received them — so this method only ever returns at
365 /// most one entry.
366 pub fn flush(&mut self) -> Option<LogEntry> {
367 self.take_entry()
368 }
369
370 /// Resets the buffer, discarding any in-progress entry.
371 ///
372 /// Useful on file rotation when the previous partial entry should be
373 /// abandoned. Also re-arms the orphan-warning flag so the first
374 /// post-rotation orphan still surfaces a warning (the rotation case
375 /// the warning was originally meant to detect).
376 pub fn reset(&mut self) {
377 self.current_header = None;
378 self.lines.clear();
379 self.has_emitted_anything = false;
380 #[cfg(feature = "brace_depth_flush")]
381 {
382 self.brace_state = BraceState::default();
383 }
384 }
385
386 /// Returns `true` if no entry is currently being accumulated.
387 pub fn is_empty(&self) -> bool {
388 self.current_header.is_none()
389 }
390
391 /// Returns `true` if the line is a metadata line that should be
392 /// treated as a self-contained entry.
393 ///
394 /// Currently matches `DETAILED LOGS: ENABLED` and
395 /// `DETAILED LOGS: DISABLED`.
396 fn is_metadata_line(line: &str) -> bool {
397 let trimmed = line.trim();
398 trimmed == "DETAILED LOGS: ENABLED" || trimmed == "DETAILED LOGS: DISABLED"
399 }
400
401 /// Detects whether `line` starts with a known header prefix.
402 ///
403 /// Bracketed headers (`[UnityCrossThreadLogger]`, `[Client GRE]`,
404 /// `[ConnectionManager]`) are matched via the compiled regex. The
405 /// bare `Matchmaking: ` prefix is matched via a separate
406 /// `starts_with` check because it has no brackets.
407 fn detect_header(&self, line: &str) -> Option<EntryHeader> {
408 if let Some(caps) = self.header_re.captures(line) {
409 let prefix = caps.get(1)?.as_str();
410 return match prefix {
411 "UnityCrossThreadLogger" => Some(EntryHeader::UnityCrossThreadLogger),
412 "Client GRE" => Some(EntryHeader::ClientGre),
413 "ConnectionManager" => Some(EntryHeader::ConnectionManager),
414 _ => None,
415 };
416 }
417 if line.starts_with("Matchmaking: ") {
418 return Some(EntryHeader::Matchmaking);
419 }
420 None
421 }
422
423 /// Classifies a header line as single-line or multi-line.
424 ///
425 /// Rule (corpus-verified across 27 sessions / 37,593 entries; see #153
426 /// analysis comment):
427 ///
428 /// - `[UnityCrossThreadLogger]` followed by an ASCII digit → multi-line
429 /// (date-prefixed API responses and match events).
430 /// - `[UnityCrossThreadLogger]` followed by anything else → single-line
431 /// (alpha labels and `==>` request markers).
432 /// - `[Client GRE]` → multi-line (current behavior preserved; corpus
433 /// has zero coverage of this header).
434 /// - `[ConnectionManager]…` → single-line.
435 /// - `Matchmaking:…` → single-line.
436 fn classify_header(header: EntryHeader, line: &str) -> HeaderClass {
437 match header {
438 EntryHeader::UnityCrossThreadLogger => {
439 // Look at the first byte after the closing bracket.
440 let after = line
441 .strip_prefix("[UnityCrossThreadLogger]")
442 .unwrap_or(line);
443 if after.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
444 HeaderClass::MultiLine
445 } else {
446 HeaderClass::SingleLine
447 }
448 }
449 EntryHeader::ClientGre => HeaderClass::MultiLine,
450 // ConnectionManager and Matchmaking are corpus-confirmed
451 // single-line. Metadata (`DETAILED LOGS: …`) is handled directly
452 // in `push_line` and never reaches this function — but it must
453 // appear here because `EntryHeader` is non_exhaustive, and a
454 // single-line classification is the safe default.
455 EntryHeader::ConnectionManager | EntryHeader::Matchmaking | EntryHeader::Metadata => {
456 HeaderClass::SingleLine
457 }
458 }
459 }
460
461 /// Takes the current entry out of the buffer, leaving it empty.
462 fn take_entry(&mut self) -> Option<LogEntry> {
463 let header = self.current_header.take()?;
464 let body = self.lines.join("\n");
465 self.lines.clear();
466 #[cfg(feature = "brace_depth_flush")]
467 {
468 self.brace_state = BraceState::default();
469 }
470 Some(LogEntry { header, body })
471 }
472
473 /// Walks `line` one character at a time, updating the in-string /
474 /// escape / depth state used by the brace-balance flush trigger.
475 ///
476 /// Returns `true` when the entry's body is structurally complete — i.e.,
477 /// the running brace depth is 0 *and* at least one `{` has been observed
478 /// since the entry started accumulating. Returning `true` signals
479 /// [`push_line`](Self::push_line) to flush the entry immediately.
480 ///
481 /// The state machine treats `"` as a string-literal toggle (when not
482 /// preceded by an unescaped backslash) and `\\` as an escape marker for
483 /// the next character. Braces appearing inside string literals are
484 /// ignored. Corpus analysis (44 sessions, 47,412 multi-line entries)
485 /// shows this state machine balances correctly on every entry that
486 /// opens a `{`, including 585 with nested JSON-in-string values.
487 #[cfg(feature = "brace_depth_flush")]
488 fn advance_brace_state(&mut self, line: &str) -> bool {
489 let state = &mut self.brace_state;
490 for ch in line.chars() {
491 if state.escape_pending {
492 state.escape_pending = false;
493 continue;
494 }
495 if state.in_string {
496 match ch {
497 '\\' => state.escape_pending = true,
498 '"' => state.in_string = false,
499 _ => {}
500 }
501 continue;
502 }
503 match ch {
504 '"' => state.in_string = true,
505 '{' => {
506 state.depth = state.depth.saturating_add(1);
507 state.ever_opened = true;
508 }
509 '}' => {
510 if state.depth == 0 {
511 // Corpus has zero unbalanced cases — log an
512 // observability warning so any future drift surfaces
513 // rather than being silently floored at zero.
514 ::log::warn!(
515 "brace_depth underflow at unbalanced '}}' in entry body \
516 (line prefix: {:?})",
517 truncate_for_log(line, 120),
518 );
519 }
520 state.depth = state.depth.saturating_sub(1);
521 }
522 _ => {}
523 }
524 }
525 state.ever_opened && state.depth == 0
526 }
527}
528
529impl Default for LineBuffer {
530 fn default() -> Self {
531 Self::new()
532 }
533}
534
535// ---------------------------------------------------------------------------
536// Tests
537// ---------------------------------------------------------------------------
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 /// Helper: build an expected `LogEntry` for concise assertions.
544 fn expected(header: EntryHeader, body: &str) -> LogEntry {
545 LogEntry {
546 header,
547 body: body.to_owned(),
548 }
549 }
550
551 // -- EntryHeader --------------------------------------------------------
552
553 mod entry_header {
554 use super::*;
555
556 #[test]
557 fn test_as_str_unity() {
558 assert_eq!(
559 EntryHeader::UnityCrossThreadLogger.as_str(),
560 "[UnityCrossThreadLogger]"
561 );
562 }
563
564 #[test]
565 fn test_as_str_client_gre() {
566 assert_eq!(EntryHeader::ClientGre.as_str(), "[Client GRE]");
567 }
568
569 #[test]
570 fn test_display_unity() {
571 assert_eq!(
572 EntryHeader::UnityCrossThreadLogger.to_string(),
573 "[UnityCrossThreadLogger]"
574 );
575 }
576
577 #[test]
578 fn test_display_client_gre() {
579 assert_eq!(EntryHeader::ClientGre.to_string(), "[Client GRE]");
580 }
581
582 #[test]
583 fn test_clone_and_eq() {
584 let a = EntryHeader::UnityCrossThreadLogger;
585 let b = a;
586 assert_eq!(a, b);
587 }
588 }
589
590 // -- LineBuffer: basic operation ----------------------------------------
591
592 mod push_line {
593 use super::*;
594
595 #[test]
596 fn test_push_line_first_multi_line_header_returns_empty() {
597 let mut buf = LineBuffer::new();
598 // Date-prefixed UCTL = multi-line; nothing to flush yet.
599 assert!(buf
600 .push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 Event")
601 .is_empty());
602 }
603
604 #[test]
605 fn test_push_line_second_multi_line_header_flushes_first_entry() {
606 let mut buf = LineBuffer::new();
607 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
608 assert_eq!(
609 buf.push_line("[Client GRE] 1/1/2025 Event2"),
610 vec![expected(
611 EntryHeader::UnityCrossThreadLogger,
612 "[UnityCrossThreadLogger]1/1/2025 Event1",
613 )],
614 );
615 }
616
617 #[test]
618 fn test_push_line_continuation_appended() {
619 // Body has no `{`/`}`, so brace-balance flush does not trigger
620 // — the entry accumulates until the next header arrives under
621 // both feature configurations.
622 let mut buf = LineBuffer::new();
623 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
624 buf.push_line("plain text continuation one");
625 buf.push_line("plain text continuation two");
626 assert_eq!(
627 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event2"),
628 vec![expected(
629 EntryHeader::UnityCrossThreadLogger,
630 "[UnityCrossThreadLogger]1/1/2025 Event1\n\
631 plain text continuation one\n\
632 plain text continuation two",
633 )],
634 );
635 }
636
637 #[test]
638 fn test_push_line_client_gre_header_detected() {
639 let mut buf = LineBuffer::new();
640 buf.push_line("[Client GRE] GreMessage");
641 assert_eq!(
642 buf.flush(),
643 Some(expected(EntryHeader::ClientGre, "[Client GRE] GreMessage")),
644 );
645 }
646
647 /// Regression: `[Client GRE]` continues to accumulate continuation
648 /// lines after Phase 1 (multi-line classification preserved). With
649 /// the default `brace_depth_flush` feature on, the entry is emitted
650 /// by the closing `}` line via `push_line` rather than waiting for
651 /// `flush()` — both code paths assemble the same body.
652 #[test]
653 fn test_push_line_client_gre_header_accumulates() {
654 let expected_body = "[Client GRE] GreToClientEvent\n{\n \"key\": \"value\"\n}";
655 let mut buf = LineBuffer::new();
656 buf.push_line("[Client GRE] GreToClientEvent");
657 buf.push_line("{");
658 buf.push_line(r#" "key": "value""#);
659 let closing = buf.push_line("}");
660 #[cfg(feature = "brace_depth_flush")]
661 {
662 assert_eq!(
663 closing,
664 vec![expected(EntryHeader::ClientGre, expected_body)],
665 "closing brace must flush the entry under brace_depth_flush",
666 );
667 assert!(buf.flush().is_none());
668 }
669 #[cfg(not(feature = "brace_depth_flush"))]
670 {
671 assert!(closing.is_empty());
672 assert_eq!(
673 buf.flush(),
674 Some(expected(EntryHeader::ClientGre, expected_body)),
675 );
676 }
677 }
678
679 #[test]
680 fn test_push_line_alternating_multi_line_headers() {
681 let mut buf = LineBuffer::new();
682 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
683
684 assert_eq!(
685 buf.push_line("[Client GRE] Event2"),
686 vec![expected(
687 EntryHeader::UnityCrossThreadLogger,
688 "[UnityCrossThreadLogger]1/1/2025 Event1",
689 )],
690 );
691
692 assert_eq!(
693 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event3"),
694 vec![expected(EntryHeader::ClientGre, "[Client GRE] Event2")],
695 );
696
697 assert_eq!(
698 buf.flush(),
699 Some(expected(
700 EntryHeader::UnityCrossThreadLogger,
701 "[UnityCrossThreadLogger]1/1/2025 Event3",
702 )),
703 );
704 }
705 }
706
707 // -- LineBuffer: single-line flush (Phase 1 of #153) -------------------
708
709 mod single_line_flush {
710 use super::*;
711
712 /// `[UnityCrossThreadLogger]` followed by an alpha label (e.g.,
713 /// `STATE CHANGED`) is single-line — emit immediately, leave the
714 /// buffer idle.
715 #[test]
716 fn test_push_line_single_line_uctl_label_flushes_immediately() {
717 let mut buf = LineBuffer::new();
718 let entries = buf.push_line(
719 "[UnityCrossThreadLogger]STATE CHANGED \
720 {\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
721 );
722 assert_eq!(
723 entries,
724 vec![expected(
725 EntryHeader::UnityCrossThreadLogger,
726 "[UnityCrossThreadLogger]STATE CHANGED \
727 {\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
728 )],
729 );
730 assert!(
731 buf.is_empty(),
732 "buffer must be idle after a single-line flush",
733 );
734 }
735
736 /// `[UnityCrossThreadLogger]==>` API request markers are single-line.
737 #[test]
738 fn test_push_line_single_line_uctl_arrow_flushes_immediately() {
739 let mut buf = LineBuffer::new();
740 let entries = buf.push_line(
741 "[UnityCrossThreadLogger]==> GraphGetGraphState \
742 {\"id\":\"abc\",\"request\":\"{}\"}",
743 );
744 assert_eq!(entries.len(), 1);
745 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
746 assert!(buf.is_empty());
747 }
748
749 /// `[UnityCrossThreadLogger]Client.SceneChange {…}` exercises a
750 /// nested-bracket case where the continuation-detection logic must
751 /// not be confused by the inner `{` body.
752 #[test]
753 fn test_push_line_single_line_uctl_nested_bracket_flushes_immediately() {
754 let mut buf = LineBuffer::new();
755 let entries = buf.push_line(
756 "[UnityCrossThreadLogger]Client.SceneChange \
757 {\"fromSceneName\":\"Home\",\"toSceneName\":\"Draft\"}",
758 );
759 assert_eq!(entries.len(), 1);
760 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
761 assert!(buf.is_empty());
762 }
763
764 /// `[ConnectionManager]…` is single-line.
765 #[test]
766 fn test_push_line_single_line_connection_manager_flushes_immediately() {
767 let mut buf = LineBuffer::new();
768 let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
769 assert_eq!(
770 entries,
771 vec![expected(
772 EntryHeader::ConnectionManager,
773 "[ConnectionManager] Reconnect succeeded",
774 )],
775 );
776 assert!(buf.is_empty());
777 }
778
779 /// `Matchmaking:…` is single-line.
780 #[test]
781 fn test_push_line_single_line_matchmaking_flushes_immediately() {
782 let mut buf = LineBuffer::new();
783 let entries = buf.push_line("Matchmaking: GRE connection lost");
784 assert_eq!(
785 entries,
786 vec![expected(
787 EntryHeader::Matchmaking,
788 "Matchmaking: GRE connection lost",
789 )],
790 );
791 assert!(buf.is_empty());
792 }
793
794 /// Multi-line headers (`[UnityCrossThreadLogger]<digit>`) accumulate
795 /// continuation lines and produce the same body under both feature
796 /// configurations. The only difference is *when* the entry is
797 /// emitted: with `brace_depth_flush` on, the closing `}` of
798 /// `{"Courses":[]}` flushes it; without the feature, the next
799 /// header flushes it alongside its own single-line emission.
800 #[test]
801 fn test_push_line_multi_line_date_header_accumulates() {
802 let expected_multi_body = "[UnityCrossThreadLogger]3/11/2026 6:08:24 PM\n\
803 <== EventGetCoursesV2(abc-123)\n\
804 {\"Courses\":[]}";
805 let expected_single_body = "[UnityCrossThreadLogger]Client.SceneChange {}";
806
807 let mut buf = LineBuffer::new();
808 assert!(buf
809 .push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM")
810 .is_empty());
811 assert!(buf.push_line("<== EventGetCoursesV2(abc-123)").is_empty());
812 let closing = buf.push_line(r#"{"Courses":[]}"#);
813
814 #[cfg(feature = "brace_depth_flush")]
815 {
816 // The closing `}` of `{"Courses":[]}` brace-balance flushes.
817 assert_eq!(
818 closing,
819 vec![expected(
820 EntryHeader::UnityCrossThreadLogger,
821 expected_multi_body
822 )],
823 );
824 // The next single-line header now stands alone.
825 let entries = buf.push_line("[UnityCrossThreadLogger]Client.SceneChange {}");
826 assert_eq!(entries.len(), 1);
827 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
828 assert_eq!(entries[0].body, expected_single_body);
829 }
830 #[cfg(not(feature = "brace_depth_flush"))]
831 {
832 assert!(closing.is_empty());
833 let entries = buf.push_line("[UnityCrossThreadLogger]Client.SceneChange {}");
834 assert_eq!(entries.len(), 2);
835 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
836 assert_eq!(entries[0].body, expected_multi_body);
837 assert_eq!(entries[1].header, EntryHeader::UnityCrossThreadLogger);
838 assert_eq!(entries[1].body, expected_single_body);
839 }
840 }
841
842 /// Unity stdout noise that arrives *after* a single-line flush is
843 /// orphaned (the buffer is idle) and discarded — it must not be
844 /// absorbed into the prior entry's body.
845 #[test]
846 fn test_push_line_post_single_line_orphan_discarded() {
847 let mut buf = LineBuffer::new();
848 // Single-line header — buffer goes idle immediately after.
849 let first = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
850 assert_eq!(first.len(), 1);
851 assert!(buf.is_empty());
852
853 // Unity stdout noise → orphan, discarded.
854 let noise = buf.push_line("PreviousPlayBladeVisualState is being set ...");
855 assert!(noise.is_empty());
856 assert!(buf.is_empty());
857
858 // Next header — emit cleanly with no contamination from the noise.
859 let next = buf.push_line("[UnityCrossThreadLogger]Connecting to matchId abc");
860 assert_eq!(next.len(), 1);
861 assert!(!next[0].body.contains("PreviousPlayBladeVisualState"));
862 }
863
864 /// A multi-line entry being flushed by a single-line header must
865 /// emit BOTH entries from one `push_line` call.
866 #[test]
867 fn test_push_line_multi_line_then_single_line_emits_two() {
868 let mut buf = LineBuffer::new();
869 buf.push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM");
870 buf.push_line("<== Foo(123)");
871
872 let entries = buf.push_line("[ConnectionManager] Reconnect failed");
873 assert_eq!(entries.len(), 2);
874 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
875 assert!(entries[0].body.contains("<== Foo(123)"));
876 assert_eq!(entries[1].header, EntryHeader::ConnectionManager);
877 assert_eq!(entries[1].body, "[ConnectionManager] Reconnect failed");
878 assert!(buf.is_empty());
879 }
880 }
881
882 // -- LineBuffer: headerless lines ---------------------------------------
883
884 mod headerless {
885 use super::*;
886
887 #[test]
888 fn test_push_line_headerless_before_first_header_returns_empty() {
889 let mut buf = LineBuffer::new();
890 assert!(buf.push_line("some random line").is_empty());
891 assert!(buf.push_line("another orphan").is_empty());
892 // After discarding, the next header should still work.
893 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Real entry");
894 assert_eq!(
895 buf.flush(),
896 Some(expected(
897 EntryHeader::UnityCrossThreadLogger,
898 "[UnityCrossThreadLogger]1/1/2025 Real entry",
899 )),
900 );
901 }
902
903 #[test]
904 fn test_push_line_empty_line_as_continuation() {
905 let mut buf = LineBuffer::new();
906 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
907 buf.push_line("");
908 buf.push_line("continuation");
909 assert_eq!(
910 buf.flush(),
911 Some(expected(
912 EntryHeader::UnityCrossThreadLogger,
913 "[UnityCrossThreadLogger]1/1/2025 Event\n\ncontinuation",
914 )),
915 );
916 }
917 }
918
919 // -- LineBuffer: flush --------------------------------------------------
920
921 mod flush {
922 use super::*;
923
924 #[test]
925 fn test_flush_empty_buffer_returns_none() {
926 let mut buf = LineBuffer::new();
927 assert!(buf.flush().is_none());
928 }
929
930 #[test]
931 fn test_flush_returns_buffered_multi_line_entry() {
932 let mut buf = LineBuffer::new();
933 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
934 assert_eq!(
935 buf.flush(),
936 Some(expected(
937 EntryHeader::UnityCrossThreadLogger,
938 "[UnityCrossThreadLogger]1/1/2025 Event",
939 )),
940 );
941 }
942
943 #[test]
944 fn test_flush_clears_buffer() {
945 let mut buf = LineBuffer::new();
946 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
947 buf.flush();
948 assert!(buf.flush().is_none());
949 assert!(buf.is_empty());
950 }
951
952 #[test]
953 fn test_flush_multi_line_entry() {
954 let expected_body = [
955 "[Client GRE] GreToClientEvent",
956 "{",
957 r#" "gameObjects": ["obj1", "obj2"],"#,
958 r#" "actions": []"#,
959 "}",
960 ]
961 .join("\n");
962
963 let mut buf = LineBuffer::new();
964 buf.push_line("[Client GRE] GreToClientEvent");
965 buf.push_line("{");
966 buf.push_line(r#" "gameObjects": ["obj1", "obj2"],"#);
967 buf.push_line(r#" "actions": []"#);
968 let closing = buf.push_line("}");
969
970 #[cfg(feature = "brace_depth_flush")]
971 {
972 // The closing `}` brace-balance flushes the entry; `flush()`
973 // is left with nothing to return.
974 assert_eq!(
975 closing,
976 vec![expected(EntryHeader::ClientGre, &expected_body)],
977 );
978 assert!(buf.flush().is_none());
979 }
980 #[cfg(not(feature = "brace_depth_flush"))]
981 {
982 assert!(closing.is_empty());
983 assert_eq!(
984 buf.flush(),
985 Some(expected(EntryHeader::ClientGre, &expected_body)),
986 );
987 }
988 }
989 }
990
991 // -- LineBuffer: reset --------------------------------------------------
992
993 mod reset {
994 use super::*;
995
996 #[test]
997 fn test_reset_clears_in_progress_entry() {
998 let mut buf = LineBuffer::new();
999 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1000 // Continuation with an open `{` so brace state is non-trivial
1001 // when we reset — depth=1, ever_opened=true, in_string=true.
1002 buf.push_line(r#"{"k": "unfinished"#);
1003 buf.reset();
1004 assert!(buf.is_empty());
1005 assert!(buf.flush().is_none());
1006
1007 // Brace state must also clear so the next accumulation starts
1008 // from a clean slate (otherwise stale `ever_opened` would
1009 // spuriously flush the next entry).
1010 #[cfg(feature = "brace_depth_flush")]
1011 {
1012 assert_eq!(buf.brace_state.depth, 0, "reset() must clear depth");
1013 assert!(!buf.brace_state.in_string, "reset() must clear in_string");
1014 assert!(
1015 !buf.brace_state.escape_pending,
1016 "reset() must clear escape_pending",
1017 );
1018 assert!(
1019 !buf.brace_state.ever_opened,
1020 "reset() must clear ever_opened",
1021 );
1022 }
1023 }
1024
1025 #[test]
1026 fn test_reset_allows_fresh_accumulation() {
1027 let mut buf = LineBuffer::new();
1028 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Old");
1029 buf.reset();
1030 buf.push_line("[Client GRE] New");
1031 assert_eq!(
1032 buf.flush(),
1033 Some(expected(EntryHeader::ClientGre, "[Client GRE] New")),
1034 );
1035 }
1036 }
1037
1038 // -- LineBuffer: is_empty -----------------------------------------------
1039
1040 mod is_empty {
1041 use super::*;
1042
1043 #[test]
1044 fn test_is_empty_on_new_buffer() {
1045 let buf = LineBuffer::new();
1046 assert!(buf.is_empty());
1047 }
1048
1049 #[test]
1050 fn test_is_empty_false_after_multi_line_header() {
1051 let mut buf = LineBuffer::new();
1052 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1053 assert!(!buf.is_empty());
1054 }
1055
1056 /// Single-line entries leave the buffer idle — invariant relied on
1057 /// by Phase 2 (#161).
1058 #[test]
1059 fn test_is_empty_true_after_single_line_flush() {
1060 let mut buf = LineBuffer::new();
1061 buf.push_line("[UnityCrossThreadLogger]STATE CHANGED");
1062 assert!(buf.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_is_empty_true_after_flush() {
1067 let mut buf = LineBuffer::new();
1068 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1069 buf.flush();
1070 assert!(buf.is_empty());
1071 }
1072
1073 #[test]
1074 fn test_is_empty_true_after_headerless_lines() {
1075 let mut buf = LineBuffer::new();
1076 buf.push_line("orphan line");
1077 assert!(buf.is_empty());
1078 }
1079 }
1080
1081 // -- LineBuffer: default ------------------------------------------------
1082
1083 mod default_impl {
1084 use super::*;
1085
1086 #[test]
1087 fn test_default_creates_functional_buffer() {
1088 let mut buf = LineBuffer::default();
1089 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1090 assert_eq!(
1091 buf.flush(),
1092 Some(expected(
1093 EntryHeader::UnityCrossThreadLogger,
1094 "[UnityCrossThreadLogger]1/1/2025 Event",
1095 )),
1096 );
1097 }
1098 }
1099
1100 // -- Header detection edge cases ----------------------------------------
1101
1102 mod header_detection {
1103 use super::*;
1104
1105 #[test]
1106 fn test_header_not_at_start_of_line_is_continuation() {
1107 let mut buf = LineBuffer::new();
1108 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1109 // Header pattern in the middle of a line is NOT a boundary.
1110 buf.push_line("some text [UnityCrossThreadLogger] not a header");
1111 assert_eq!(
1112 buf.flush(),
1113 Some(expected(
1114 EntryHeader::UnityCrossThreadLogger,
1115 "[UnityCrossThreadLogger]1/1/2025 Event\n\
1116 some text [UnityCrossThreadLogger] not a header",
1117 )),
1118 );
1119 }
1120
1121 #[test]
1122 fn test_similar_but_wrong_header_is_continuation() {
1123 let mut buf = LineBuffer::new();
1124 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1125 buf.push_line("[UnityMainThreadLogger] not a valid header");
1126 let result = buf.flush();
1127 assert!(result.is_some());
1128 if let Some(e) = result {
1129 assert!(e.body.contains("[UnityMainThreadLogger]"));
1130 }
1131 }
1132
1133 #[test]
1134 fn test_bracket_only_is_not_header() {
1135 let mut buf = LineBuffer::new();
1136 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1137 buf.push_line("[]");
1138 assert_eq!(
1139 buf.flush(),
1140 Some(expected(
1141 EntryHeader::UnityCrossThreadLogger,
1142 "[UnityCrossThreadLogger]1/1/2025 Event\n[]",
1143 )),
1144 );
1145 }
1146
1147 #[test]
1148 fn test_header_with_nothing_after_bracket() {
1149 let mut buf = LineBuffer::new();
1150 // `[UnityCrossThreadLogger]` with no trailing content classifies
1151 // as single-line (no leading digit) — emit and go idle.
1152 let entries = buf.push_line("[UnityCrossThreadLogger]");
1153 assert_eq!(
1154 entries,
1155 vec![expected(
1156 EntryHeader::UnityCrossThreadLogger,
1157 "[UnityCrossThreadLogger]",
1158 )],
1159 );
1160 assert!(buf.is_empty());
1161 }
1162 }
1163
1164 // -- Realistic multi-line entry -----------------------------------------
1165
1166 mod realistic_entries {
1167 use super::*;
1168
1169 #[test]
1170 fn test_realistic_game_state_message() {
1171 let mut buf = LineBuffer::new();
1172 buf.push_line(
1173 "[UnityCrossThreadLogger]1/15/2025 3:42:17 PM \
1174 greToClientEvent",
1175 );
1176 buf.push_line("{");
1177 buf.push_line(r#" "greToClientMessages": ["#);
1178 buf.push_line(r" {");
1179 buf.push_line(r#" "type": "GREMessageType_GameStateMessage","#);
1180 buf.push_line(r#" "gameStateMessage": {"#);
1181 buf.push_line(r#" "gameObjects": []"#);
1182 buf.push_line(r" }");
1183 buf.push_line(r" }");
1184 buf.push_line(r" ]");
1185 let final_brace = buf.push_line("}");
1186
1187 #[cfg(feature = "brace_depth_flush")]
1188 {
1189 // The matching final `}` brace-balance flushes the UCTL
1190 // entry inside the same `push_line` that received it.
1191 assert_eq!(final_brace.len(), 1);
1192 assert_eq!(final_brace[0].header, EntryHeader::UnityCrossThreadLogger);
1193 assert!(final_brace[0].body.contains("greToClientMessages"));
1194 assert!(final_brace[0].body.contains("GameStateMessage"));
1195
1196 // `[Client GRE] Next event` now begins a new accumulation
1197 // — nothing else to flush.
1198 assert!(buf.push_line("[Client GRE] Next event").is_empty());
1199
1200 // The Client-GRE body has no `{`, so it falls through to
1201 // the legacy "flush on next header" path.
1202 assert_eq!(
1203 buf.push_line("[UnityCrossThreadLogger]1/15/2025 After"),
1204 vec![expected(EntryHeader::ClientGre, "[Client GRE] Next event")],
1205 );
1206 }
1207 #[cfg(not(feature = "brace_depth_flush"))]
1208 {
1209 assert!(final_brace.is_empty());
1210
1211 // [Client GRE] (multi-line) flushes the UCTL entry.
1212 let unity_entries = buf.push_line("[Client GRE] Next event");
1213 assert_eq!(unity_entries.len(), 1);
1214 assert_eq!(unity_entries[0].header, EntryHeader::UnityCrossThreadLogger);
1215 assert!(unity_entries[0].body.contains("greToClientMessages"));
1216 assert!(unity_entries[0].body.contains("GameStateMessage"));
1217
1218 // The next header flushes the Client GRE entry.
1219 assert_eq!(
1220 buf.push_line("[UnityCrossThreadLogger]1/15/2025 After"),
1221 vec![expected(EntryHeader::ClientGre, "[Client GRE] Next event")],
1222 );
1223 }
1224 }
1225
1226 #[test]
1227 fn test_many_single_line_entries_in_sequence() {
1228 let mut buf = LineBuffer::new();
1229 let mut entries = Vec::new();
1230
1231 for i in 0..5 {
1232 // Single-line UCTL alpha labels — each flushes immediately.
1233 entries.extend(buf.push_line(&format!("[UnityCrossThreadLogger]Event{i}")));
1234 }
1235 entries.extend(buf.flush());
1236
1237 assert_eq!(entries.len(), 5);
1238 for (i, e) in entries.iter().enumerate() {
1239 assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
1240 assert_eq!(e.body, format!("[UnityCrossThreadLogger]Event{i}"));
1241 }
1242 }
1243 }
1244
1245 // -- Metadata line detection -----------------------------------------------
1246
1247 mod metadata_lines {
1248 use super::*;
1249
1250 #[test]
1251 fn test_push_line_detailed_logs_enabled_as_first_line() {
1252 let mut buf = LineBuffer::new();
1253 let result = buf.push_line("DETAILED LOGS: ENABLED");
1254
1255 assert_eq!(
1256 result,
1257 vec![expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")],
1258 );
1259 // Buffer should be empty after — metadata is self-contained.
1260 assert!(buf.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_push_line_detailed_logs_disabled_as_first_line() {
1265 let mut buf = LineBuffer::new();
1266 let result = buf.push_line("DETAILED LOGS: DISABLED");
1267
1268 assert_eq!(
1269 result,
1270 vec![expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")],
1271 );
1272 assert!(buf.is_empty());
1273 }
1274
1275 /// Metadata after an in-progress multi-line entry flushes the prior
1276 /// entry AND emits the metadata entry — both in one call.
1277 #[test]
1278 fn test_push_line_metadata_flushes_buffered_entry() {
1279 let mut buf = LineBuffer::new();
1280 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1281
1282 let entries = buf.push_line("DETAILED LOGS: ENABLED");
1283 assert_eq!(
1284 entries,
1285 vec![
1286 expected(
1287 EntryHeader::UnityCrossThreadLogger,
1288 "[UnityCrossThreadLogger]1/1/2025 Event1",
1289 ),
1290 expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED"),
1291 ],
1292 );
1293 // Buffer is idle after — metadata is self-contained.
1294 assert!(buf.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_push_line_metadata_then_header_flushes_metadata() {
1299 let mut buf = LineBuffer::new();
1300 buf.push_line("DETAILED LOGS: ENABLED");
1301
1302 // Next multi-line header — nothing to flush (metadata was emitted
1303 // immediately on its own call).
1304 assert!(buf
1305 .push_line("[UnityCrossThreadLogger]1/1/2025 Event")
1306 .is_empty());
1307 assert_eq!(
1308 buf.flush(),
1309 Some(expected(
1310 EntryHeader::UnityCrossThreadLogger,
1311 "[UnityCrossThreadLogger]1/1/2025 Event",
1312 )),
1313 );
1314 }
1315
1316 #[test]
1317 fn test_push_line_metadata_similar_text_not_matched() {
1318 let mut buf = LineBuffer::new();
1319 // Similar but not exact — should be treated as headerless.
1320 assert!(buf.push_line("DETAILED LOGS: UNKNOWN").is_empty());
1321 assert!(buf.push_line("detailed logs: enabled").is_empty());
1322 assert!(buf.push_line("DETAILED LOGS:ENABLED").is_empty());
1323 }
1324
1325 #[test]
1326 fn test_push_line_metadata_with_leading_trailing_whitespace() {
1327 let mut buf = LineBuffer::new();
1328 // Whitespace around the exact text should still match.
1329 let result = buf.push_line(" DETAILED LOGS: ENABLED ");
1330 assert_eq!(result.len(), 1);
1331 assert_eq!(result[0].header, EntryHeader::Metadata);
1332 }
1333
1334 #[test]
1335 fn test_entry_header_metadata_as_str() {
1336 assert_eq!(EntryHeader::Metadata.as_str(), "METADATA");
1337 }
1338
1339 #[test]
1340 fn test_entry_header_metadata_display() {
1341 assert_eq!(EntryHeader::Metadata.to_string(), "METADATA");
1342 }
1343 }
1344
1345 // -- Phase 2 (#161): orphan-warn gating ---------------------------------
1346
1347 mod orphan_warn_gating {
1348 use super::*;
1349 use std::sync::{Mutex, OnceLock};
1350
1351 /// In-test log capture: records every record's level + message so the
1352 /// gating tests can assert whether a warn fired.
1353 ///
1354 /// `log` only allows one global logger per process. We install it
1355 /// once via `OnceLock` and serialize the gating tests through a mutex
1356 /// so the captured-record buffer can be inspected race-free.
1357 struct CaptureLogger {
1358 records: Mutex<Vec<(::log::Level, String)>>,
1359 }
1360
1361 impl ::log::Log for CaptureLogger {
1362 fn enabled(&self, _metadata: &::log::Metadata<'_>) -> bool {
1363 true
1364 }
1365 fn log(&self, record: &::log::Record<'_>) {
1366 let mut guard = match self.records.lock() {
1367 Ok(g) => g,
1368 Err(poisoned) => poisoned.into_inner(),
1369 };
1370 guard.push((record.level(), record.args().to_string()));
1371 }
1372 fn flush(&self) {}
1373 }
1374
1375 static LOGGER: OnceLock<&'static CaptureLogger> = OnceLock::new();
1376
1377 type RecordsRef = &'static Mutex<Vec<(::log::Level, String)>>;
1378
1379 /// Installs the capture logger (idempotent) and returns a handle to
1380 /// the global capture buffer.
1381 ///
1382 /// The capture buffer accumulates records from every test that runs
1383 /// in this process, so callers MUST filter the captured records by a
1384 /// per-test sentinel marker — see [`warn_count_matching`]. This
1385 /// avoids the parallel-test race that a "clear before each test"
1386 /// strategy would introduce.
1387 fn install_capture() -> RecordsRef {
1388 let logger = LOGGER.get_or_init(|| {
1389 let leaked: &'static CaptureLogger = Box::leak(Box::new(CaptureLogger {
1390 records: Mutex::new(Vec::new()),
1391 }));
1392 // `set_logger` errors if a logger is already installed by
1393 // another test setup; in that case our captures will be
1394 // silently dropped, which is acceptable here because the
1395 // gating logic is also covered by behavioral tests
1396 // (`is_empty`, header round-trips) above.
1397 let _ = ::log::set_logger(leaked);
1398 ::log::set_max_level(::log::LevelFilter::Trace);
1399 leaked
1400 });
1401 &logger.records
1402 }
1403
1404 /// Counts captured warn-level records that contain `marker` in the
1405 /// message body.
1406 ///
1407 /// Tests pass a per-test sentinel string as the orphan input so the
1408 /// captured warning's truncated payload contains that sentinel.
1409 /// Filtering on the sentinel makes the count race-free even though
1410 /// Rust's test harness runs tests in parallel by default and other
1411 /// modules' tests share the same global logger.
1412 fn warn_count_matching(
1413 records: &Mutex<Vec<(::log::Level, String)>>,
1414 marker: &str,
1415 ) -> usize {
1416 let guard = match records.lock() {
1417 Ok(g) => g,
1418 Err(poisoned) => poisoned.into_inner(),
1419 };
1420 guard
1421 .iter()
1422 .filter(|(lvl, msg)| {
1423 *lvl == ::log::Level::Warn
1424 && msg.starts_with("Discarding headerless line at start of input")
1425 && msg.contains(marker)
1426 })
1427 .count()
1428 }
1429
1430 /// Orphan line before any header has been seen still produces the
1431 /// existing warning — this is the file-start anomaly the message was
1432 /// originally meant to flag.
1433 #[test]
1434 fn test_push_line_first_orphan_warns() {
1435 const MARKER: &str = "P2-MARKER-FIRST-ORPHAN-WARNS-zX9q";
1436 let records = install_capture();
1437 let mut buf = LineBuffer::new();
1438
1439 assert!(buf.push_line(MARKER).is_empty());
1440
1441 assert_eq!(
1442 warn_count_matching(records, MARKER),
1443 1,
1444 "first orphan at file start must warn (rotation/file-start anomaly)",
1445 );
1446 }
1447
1448 /// After a single-line entry has flushed, a subsequent headerless
1449 /// line is routine Unity stdout noise — silently discard, no warn.
1450 #[test]
1451 fn test_push_line_post_flush_orphan_silent() {
1452 const MARKER: &str = "P2-MARKER-POST-FLUSH-SILENT-kJ7w";
1453 let records = install_capture();
1454 let mut buf = LineBuffer::new();
1455
1456 // Single-line flush arms the gating flag.
1457 let entries = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
1458 assert_eq!(entries.len(), 1);
1459 assert!(buf.is_empty());
1460
1461 // Unity stdout noise — should be silently dropped.
1462 assert!(buf.push_line(MARKER).is_empty());
1463
1464 assert_eq!(
1465 warn_count_matching(records, MARKER),
1466 0,
1467 "post-flush orphan must be silently discarded (no warn)",
1468 );
1469 }
1470
1471 /// `reset()` re-arms the warning so post-rotation orphans still
1472 /// surface — the rotation case the warn was originally meant to
1473 /// catch.
1474 #[test]
1475 fn test_push_line_orphan_after_reset_warns() {
1476 const MARKER: &str = "P2-MARKER-AFTER-RESET-WARNS-vN2t";
1477 let records = install_capture();
1478 let mut buf = LineBuffer::new();
1479
1480 // Flush an entry to arm the flag.
1481 assert_eq!(
1482 buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {}")
1483 .len(),
1484 1,
1485 );
1486
1487 // Simulate file rotation — flag must drop back to false.
1488 buf.reset();
1489
1490 // First orphan after reset must warn again.
1491 assert!(buf.push_line(MARKER).is_empty());
1492
1493 assert_eq!(
1494 warn_count_matching(records, MARKER),
1495 1,
1496 "first orphan after reset must warn (rotation anomaly)",
1497 );
1498 }
1499
1500 /// A metadata line (`DETAILED LOGS: ENABLED`) is a successfully
1501 /// emitted entry, so subsequent orphan lines are post-flush noise
1502 /// and must be silently discarded.
1503 #[test]
1504 fn test_push_line_orphan_after_metadata_silent() {
1505 const MARKER: &str = "P2-MARKER-AFTER-METADATA-SILENT-bH4r";
1506 let records = install_capture();
1507 let mut buf = LineBuffer::new();
1508
1509 // Metadata arms the flag.
1510 let entries = buf.push_line("DETAILED LOGS: ENABLED");
1511 assert_eq!(entries.len(), 1);
1512 assert_eq!(entries[0].header, EntryHeader::Metadata);
1513
1514 // Subsequent orphan — silent.
1515 assert!(buf.push_line(MARKER).is_empty());
1516
1517 assert_eq!(
1518 warn_count_matching(records, MARKER),
1519 0,
1520 "orphan after metadata must be silently discarded (no warn)",
1521 );
1522 }
1523 }
1524
1525 // -- ConnectionManager / Matchmaking header framing ---------------------
1526
1527 mod connection_and_matchmaking_headers {
1528 use super::*;
1529
1530 #[test]
1531 fn test_as_str_connection_manager() {
1532 assert_eq!(
1533 EntryHeader::ConnectionManager.as_str(),
1534 "[ConnectionManager]"
1535 );
1536 }
1537
1538 #[test]
1539 fn test_as_str_matchmaking() {
1540 // The `Matchmaking:` prefix keeps the colon — this matches how
1541 // the line appears in Arena's actual log.
1542 assert_eq!(EntryHeader::Matchmaking.as_str(), "Matchmaking:");
1543 }
1544
1545 #[test]
1546 fn test_display_connection_manager() {
1547 assert_eq!(
1548 EntryHeader::ConnectionManager.to_string(),
1549 "[ConnectionManager]"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_display_matchmaking() {
1555 assert_eq!(EntryHeader::Matchmaking.to_string(), "Matchmaking:");
1556 }
1557
1558 #[test]
1559 fn test_connection_manager_header_mid_stream_flushes_unity() {
1560 let mut buf = LineBuffer::new();
1561 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1562
1563 let entries = buf.push_line("[ConnectionManager] Reconnect result : Error");
1564 assert_eq!(
1565 entries,
1566 vec![
1567 expected(
1568 EntryHeader::UnityCrossThreadLogger,
1569 "[UnityCrossThreadLogger]1/1/2025 Event1",
1570 ),
1571 expected(
1572 EntryHeader::ConnectionManager,
1573 "[ConnectionManager] Reconnect result : Error",
1574 ),
1575 ],
1576 );
1577 // ConnectionManager is single-line — buffer is idle.
1578 assert!(buf.is_empty());
1579 }
1580
1581 #[test]
1582 fn test_matchmaking_header_mid_stream_flushes_unity() {
1583 let mut buf = LineBuffer::new();
1584 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
1585
1586 let entries = buf.push_line("Matchmaking: GRE connection lost");
1587 assert_eq!(
1588 entries,
1589 vec![
1590 expected(
1591 EntryHeader::UnityCrossThreadLogger,
1592 "[UnityCrossThreadLogger]1/1/2025 Event1",
1593 ),
1594 expected(EntryHeader::Matchmaking, "Matchmaking: GRE connection lost",),
1595 ],
1596 );
1597 assert!(buf.is_empty());
1598 }
1599
1600 #[test]
1601 fn test_connection_manager_as_first_line_emits_immediately() {
1602 // Single-line semantics: the ConnectionManager entry is emitted
1603 // by the same `push_line` call that received it.
1604 let mut buf = LineBuffer::new();
1605 let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
1606 assert_eq!(
1607 entries,
1608 vec![expected(
1609 EntryHeader::ConnectionManager,
1610 "[ConnectionManager] Reconnect succeeded",
1611 )],
1612 );
1613 assert!(buf.is_empty());
1614 }
1615
1616 #[test]
1617 fn test_matchmaking_as_first_line_emits_immediately() {
1618 let mut buf = LineBuffer::new();
1619 let entries = buf.push_line("Matchmaking: GRE connection lost");
1620 assert_eq!(
1621 entries,
1622 vec![expected(
1623 EntryHeader::Matchmaking,
1624 "Matchmaking: GRE connection lost",
1625 )],
1626 );
1627 assert!(buf.is_empty());
1628 }
1629
1630 #[test]
1631 fn test_four_way_interleave_yields_four_entries() {
1632 // Realistic corpus-derived pattern from issues #528/#529:
1633 // Unity STATE CHANGED → Matchmaking: GRE connection lost →
1634 // ConnectionManager Reconnect result → Unity (next event).
1635 // All four are single-line, so each `push_line` returns 1 entry.
1636 let mut buf = LineBuffer::new();
1637 let mut entries = Vec::new();
1638
1639 entries.extend(buf.push_line(
1640 "[UnityCrossThreadLogger]STATE CHANGED \
1641 {\"old\":\"Playing\",\"new\":\"Disconnected\"}",
1642 ));
1643 entries.extend(buf.push_line("Matchmaking: GRE connection lost"));
1644 entries.extend(buf.push_line("[ConnectionManager] Reconnect result : Error"));
1645 entries.extend(buf.push_line("[UnityCrossThreadLogger]Next event"));
1646 entries.extend(buf.flush());
1647
1648 assert_eq!(entries.len(), 4);
1649 assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
1650 assert!(entries[0].body.contains("STATE CHANGED"));
1651 assert_eq!(entries[1].header, EntryHeader::Matchmaking);
1652 assert_eq!(entries[1].body, "Matchmaking: GRE connection lost");
1653 assert_eq!(entries[2].header, EntryHeader::ConnectionManager);
1654 assert_eq!(
1655 entries[2].body,
1656 "[ConnectionManager] Reconnect result : Error"
1657 );
1658 assert_eq!(entries[3].header, EntryHeader::UnityCrossThreadLogger);
1659 assert_eq!(entries[3].body, "[UnityCrossThreadLogger]Next event");
1660 }
1661
1662 #[test]
1663 fn test_matchmaking_without_trailing_space_is_not_header() {
1664 // The starts_with check requires the trailing space ("Matchmaking: ")
1665 // to avoid matching unrelated prefixes that happen to start
1666 // with "Matchmaking:". Without the space it should be a
1667 // headerless line (discarded at start of stream).
1668 let mut buf = LineBuffer::new();
1669 assert!(buf.push_line("Matchmaking:compact-no-space").is_empty());
1670 assert!(buf.is_empty());
1671 }
1672
1673 #[test]
1674 fn test_connection_manager_mid_line_is_continuation() {
1675 let mut buf = LineBuffer::new();
1676 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1677 // ConnectionManager bracket pattern in the middle of a line is
1678 // NOT a boundary — same rule as other bracketed headers.
1679 buf.push_line("some text [ConnectionManager] not a header");
1680 assert_eq!(
1681 buf.flush(),
1682 Some(expected(
1683 EntryHeader::UnityCrossThreadLogger,
1684 "[UnityCrossThreadLogger]1/1/2025 Event\n\
1685 some text [ConnectionManager] not a header",
1686 )),
1687 );
1688 }
1689 }
1690
1691 // -- Brace-depth flush (#193) -------------------------------------------
1692
1693 #[cfg(feature = "brace_depth_flush")]
1694 mod brace_depth_flush {
1695 use super::*;
1696
1697 /// Header + single-line `{...}` body — the closing `}` flushes the
1698 /// entry immediately, no next header required.
1699 #[test]
1700 fn test_single_line_json_body_flushes_immediately() {
1701 let mut buf = LineBuffer::new();
1702 assert!(buf
1703 .push_line("[UnityCrossThreadLogger]1/1/2025 Event")
1704 .is_empty());
1705 let result = buf.push_line(r#"{"key":"value"}"#);
1706 assert_eq!(
1707 result,
1708 vec![expected(
1709 EntryHeader::UnityCrossThreadLogger,
1710 "[UnityCrossThreadLogger]1/1/2025 Event\n{\"key\":\"value\"}",
1711 )],
1712 );
1713 assert!(buf.is_empty(), "buffer must be idle after brace-flush");
1714 }
1715
1716 /// Pretty-printed multi-line JSON: opening `{`, key/value lines,
1717 /// closing `}` on its own line. The closing `}` flushes the entry.
1718 #[test]
1719 fn test_multi_line_pretty_printed_json_flushes_on_closing_brace() {
1720 let mut buf = LineBuffer::new();
1721 buf.push_line("[Client GRE] GreToClientEvent");
1722 buf.push_line("{");
1723 buf.push_line(r#" "key": "val""#);
1724 let result = buf.push_line("}");
1725 assert_eq!(result.len(), 1);
1726 assert_eq!(result[0].header, EntryHeader::ClientGre);
1727 assert_eq!(
1728 result[0].body,
1729 "[Client GRE] GreToClientEvent\n{\n \"key\": \"val\"\n}",
1730 );
1731 assert!(buf.is_empty());
1732 }
1733
1734 /// Header + `<==` response marker continuation + JSON body. The
1735 /// response marker has no `{`; the JSON body line flushes on its
1736 /// closing `}`.
1737 #[test]
1738 fn test_response_marker_then_json_flushes() {
1739 let mut buf = LineBuffer::new();
1740 assert!(buf
1741 .push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 PM")
1742 .is_empty());
1743 assert!(buf.push_line("<== EventGetCoursesV2(abc)").is_empty());
1744 let result = buf.push_line(r#"{"Courses":[]}"#);
1745 assert_eq!(result.len(), 1);
1746 assert_eq!(result[0].header, EntryHeader::UnityCrossThreadLogger);
1747 assert!(result[0].body.contains("<== EventGetCoursesV2(abc)"));
1748 assert!(result[0].body.contains(r#"{"Courses":[]}"#));
1749 assert!(buf.is_empty());
1750 }
1751
1752 /// Non-JSON bodies (no `{` anywhere) fall through to the legacy
1753 /// "flush on next header" path — corresponds to the rare GRE
1754 /// "Message summarized…" markers and `true`-bodied REST responses.
1755 #[test]
1756 fn test_non_json_body_falls_through_to_next_header() {
1757 let mut buf = LineBuffer::new();
1758 buf.push_line("[Client GRE] GreToClientEvent");
1759 assert!(buf.push_line("[Message summarized due to size]").is_empty());
1760 assert!(buf.push_line(":: 12345 entries").is_empty());
1761 assert!(buf.push_line(":: payload elided").is_empty());
1762
1763 // Next header flushes the accumulating Client-GRE entry — the
1764 // entry was never brace-flushed because no `{` appeared.
1765 let entries = buf.push_line("[UnityCrossThreadLogger]1/1/2025 After");
1766 assert_eq!(entries.len(), 1);
1767 assert_eq!(entries[0].header, EntryHeader::ClientGre);
1768 assert!(entries[0].body.contains("[Message summarized"));
1769 assert!(entries[0].body.contains(":: 12345 entries"));
1770 }
1771
1772 /// Brace state must not leak between entries: after a brace-flush,
1773 /// a follow-up entry with no `{` must NOT trigger a stale flush.
1774 #[test]
1775 fn test_brace_state_clears_between_entries() {
1776 let mut buf = LineBuffer::new();
1777
1778 // First entry — brace-balance flushes it.
1779 buf.push_line("[UnityCrossThreadLogger]1/1/2025 First");
1780 let first = buf.push_line(r#"{"a":1}"#);
1781 assert_eq!(first.len(), 1);
1782 assert!(buf.is_empty());
1783
1784 // Brace state should be reset — internal sanity check so a
1785 // regression here surfaces directly rather than through
1786 // downstream behavior.
1787 assert_eq!(buf.brace_state.depth, 0);
1788 assert!(!buf.brace_state.in_string);
1789 assert!(!buf.brace_state.escape_pending);
1790 assert!(!buf.brace_state.ever_opened);
1791
1792 // Second entry has no `{`. Without proper state reset, stale
1793 // `ever_opened=true` would falsely flush this entry's first
1794 // continuation line. With reset, it accumulates normally and
1795 // the next header flushes it.
1796 buf.push_line("[Client GRE] PlainBodyEvent");
1797 assert!(buf.push_line("just text").is_empty());
1798 let entries = buf.push_line("[UnityCrossThreadLogger]1/1/2025 Third");
1799 assert_eq!(entries.len(), 1);
1800 assert_eq!(entries[0].header, EntryHeader::ClientGre);
1801 assert_eq!(entries[0].body, "[Client GRE] PlainBodyEvent\njust text");
1802 }
1803
1804 /// After a brace-flush, subsequent headerless lines must be treated
1805 /// as routine post-flush noise (silently discarded, no warn) — the
1806 /// brace-flush path must arm the same `has_emitted_anything` gate
1807 /// the next-header flush path arms.
1808 #[test]
1809 fn test_brace_flush_arms_orphan_warn_gating() {
1810 let mut buf = LineBuffer::new();
1811
1812 // Brace-flush an entry.
1813 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
1814 let flushed = buf.push_line(r#"{"k":"v"}"#);
1815 assert_eq!(flushed.len(), 1);
1816
1817 // `has_emitted_anything` was armed by the header detection,
1818 // not by the flush itself — verify the gate is still set
1819 // after brace-flush so the next orphan is silenced.
1820 assert!(
1821 buf.has_emitted_anything,
1822 "brace-flush path must leave has_emitted_anything armed",
1823 );
1824
1825 // A subsequent orphan line must be silently discarded.
1826 assert!(buf.push_line("orphan stdout noise").is_empty());
1827 assert!(buf.is_empty());
1828 }
1829 }
1830
1831 // -- Brace-depth string-literal handling (property tests) ---------------
1832
1833 #[cfg(feature = "brace_depth_flush")]
1834 mod brace_depth_property {
1835 use super::*;
1836 use proptest::prelude::*;
1837 use serde_json::Value;
1838
1839 /// Recursive strategy producing arbitrary JSON values. Strings include
1840 /// the `{`, `}`, `"`, and `\` characters specifically because those
1841 /// are the characters the brace-state machine must handle without
1842 /// being fooled by content inside string literals.
1843 fn arb_json_value() -> impl Strategy<Value = Value> {
1844 // Strings sample from a character set that includes every
1845 // character the state machine special-cases.
1846 let arb_string = r#"[a-z0-9 \{\}\"\\]{0,12}"#.prop_map(Value::String);
1847 let leaf = prop_oneof![
1848 Just(Value::Null),
1849 any::<bool>().prop_map(Value::Bool),
1850 any::<i32>().prop_map(|n| Value::Number(n.into())),
1851 arb_string,
1852 ];
1853 leaf.prop_recursive(3, 24, 4, |inner| {
1854 prop_oneof![
1855 prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
1856 prop::collection::vec((r"[a-z]{1,6}", inner), 0..4)
1857 .prop_map(|kvs| { Value::Object(kvs.into_iter().collect()) }),
1858 ]
1859 })
1860 }
1861
1862 proptest! {
1863 /// Any serialized JSON value, when fed as one continuation line
1864 /// after a multi-line header, must brace-balance and flush.
1865 #[test]
1866 fn prop_balanced_json_flushes_exactly_once(value in arb_json_value()) {
1867 // Force the top-level value to be an object so the body
1868 // opens with `{` — the property is about closed JSON
1869 // structures, not bare leaves.
1870 // `serde_json::to_string` only errors on serializers that
1871 // refuse some `Serialize` shape — `Value` always serializes
1872 // cleanly, so the `Err` branch is unreachable in practice.
1873 let body = match serde_json::to_string(&Value::Object(
1874 [("v".to_owned(), value)].into_iter().collect(),
1875 )) {
1876 Ok(s) => s,
1877 Err(e) => unreachable!("serde_json::to_string on Value failed: {e}"),
1878 };
1879 let mut buf = LineBuffer::new();
1880 let header = buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
1881 prop_assert!(header.is_empty());
1882 let out = buf.push_line(&body);
1883 prop_assert_eq!(out.len(), 1, "balanced JSON must brace-flush");
1884 prop_assert!(buf.is_empty());
1885 }
1886
1887 /// An unterminated string literal — `"abc` with no closing `"`
1888 /// — must never appear balanced no matter what comes after the
1889 /// opening `{`.
1890 #[test]
1891 fn prop_unterminated_string_never_balances(
1892 prefix in r"[a-z0-9 ]{0,16}",
1893 trailing in r"[a-z0-9 \{\}]{0,16}",
1894 ) {
1895 let body = format!(r#"{{"k":"{prefix}{trailing}"#);
1896 let mut buf = LineBuffer::new();
1897 buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
1898 let out = buf.push_line(&body);
1899 prop_assert_eq!(
1900 out.len(),
1901 0,
1902 "unterminated string literal must not be reported balanced",
1903 );
1904 prop_assert!(!buf.is_empty(), "entry should remain accumulating");
1905 }
1906
1907 /// `{` and `}` characters embedded in a string literal must not
1908 /// affect the brace-balance counter — a well-formed JSON object
1909 /// containing brace-noise in a string value still flushes.
1910 #[test]
1911 fn prop_braces_in_strings_dont_count(
1912 noise in r"[\{\}]{0,16}",
1913 ) {
1914 let body = format!(r#"{{"junk":"{noise}"}}"#);
1915 let mut buf = LineBuffer::new();
1916 buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
1917 let out = buf.push_line(&body);
1918 prop_assert_eq!(
1919 out.len(),
1920 1,
1921 "braces inside string literals must not affect the counter",
1922 );
1923 }
1924 }
1925
1926 // -- Hand-written regression cases derived from corpus analysis ----
1927
1928 /// `{"request":"{\"foo\":\"bar\"}"}` — a JSON object whose string
1929 /// value contains a nested escaped JSON object. Corpus has 585 such
1930 /// entries; all must brace-balance correctly because the inner
1931 /// braces appear inside a string literal.
1932 #[test]
1933 fn test_regression_nested_json_in_string() {
1934 let mut buf = LineBuffer::new();
1935 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Nested");
1936 let body = r#"{"request":"{\"foo\":\"bar\"}"}"#;
1937 let out = buf.push_line(body);
1938 assert_eq!(out.len(), 1, "nested-string body must brace-balance");
1939 assert_eq!(
1940 out[0].body,
1941 format!("[UnityCrossThreadLogger]1/1/2025 Nested\n{body}")
1942 );
1943 }
1944
1945 /// Escaped quote inside a string literal: the `\"` does NOT close
1946 /// the string, so the next unescaped `"` is the real closer.
1947 #[test]
1948 fn test_regression_escaped_quote_inside_string() {
1949 let mut buf = LineBuffer::new();
1950 buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscQuote");
1951 let body = r#"{"name":"a \"quoted\" name"}"#;
1952 let out = buf.push_line(body);
1953 assert_eq!(out.len(), 1);
1954 assert!(out[0].body.contains(r#""a \"quoted\" name""#));
1955 }
1956
1957 /// Escaped backslashes: `\\` is an escape pair; the next character
1958 /// is NOT escaped, so an `"` immediately after `\\` correctly
1959 /// toggles string state.
1960 #[test]
1961 fn test_regression_escaped_backslash_inside_string() {
1962 let mut buf = LineBuffer::new();
1963 buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscBackslash");
1964 let body = r#"{"path":"C:\\Users\\foo"}"#;
1965 let out = buf.push_line(body);
1966 assert_eq!(out.len(), 1);
1967 assert!(out[0].body.contains(r#""C:\\Users\\foo""#));
1968 }
1969
1970 /// Bare `{` and `}` inside a string literal must not move the
1971 /// counter — the entry balances on the outer `}` alone.
1972 #[test]
1973 fn test_regression_brace_inside_string_literal() {
1974 let mut buf = LineBuffer::new();
1975 buf.push_line("[UnityCrossThreadLogger]1/1/2025 BraceInStr");
1976 let body = r#"{"emoji":"{ :) }"}"#;
1977 let out = buf.push_line(body);
1978 assert_eq!(out.len(), 1);
1979 assert!(out[0].body.contains(r#""{ :) }""#));
1980 }
1981
1982 /// Pathological unbalanced JSON — opens a `{` but never closes
1983 /// it. Depth stays > 0 forever; the entry never brace-flushes
1984 /// and must fall through to the next-header flush path. Defined
1985 /// behavior, no panic.
1986 #[test]
1987 fn test_regression_unbalanced_json_falls_through() {
1988 let mut buf = LineBuffer::new();
1989 buf.push_line("[UnityCrossThreadLogger]1/1/2025 Unbalanced");
1990 assert!(buf.push_line(r#"{"unclosed":"#).is_empty());
1991 assert!(buf.push_line(r#" "more":"data""#).is_empty());
1992
1993 // Next header flushes via the fallback path.
1994 let next = buf.push_line("[UnityCrossThreadLogger]1/1/2025 NextEvent");
1995 assert_eq!(next.len(), 1);
1996 assert_eq!(next[0].header, EntryHeader::UnityCrossThreadLogger);
1997 assert!(next[0].body.contains(r#"{"unclosed":"#));
1998 }
1999
2000 /// A JSON string value containing the `\n` escape sequence (not a
2001 /// real newline) keeps the value on one logical body line —
2002 /// `\\n` is two characters, not a line break.
2003 #[test]
2004 fn test_regression_escaped_newline_in_string() {
2005 let mut buf = LineBuffer::new();
2006 buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscNewline");
2007 // `\n` in the source string is the two-character escape sequence
2008 // `\` followed by `n` — not a real newline.
2009 let body = r#"{"raw":"line1\nline2"}"#;
2010 let out = buf.push_line(body);
2011 assert_eq!(out.len(), 1);
2012 assert!(out[0].body.contains(r#""line1\nline2""#));
2013 }
2014 }
2015}