Skip to main content

manasight_parser/log/
entry.rs

1//! Log entry prefix identification and multi-line JSON accumulation.
2//!
3//! Detects log entry boundaries using the `[UnityCrossThreadLogger]`,
4//! `[Client GRE]`, `[ConnectionManager]`, and `Matchmaking:` header patterns,
5//! then accumulates subsequent lines until the 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}