use regex::Regex;
use crate::util::truncate_for_log;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EntryHeader {
UnityCrossThreadLogger,
ClientGre,
ConnectionManager,
Matchmaking,
Metadata,
}
impl EntryHeader {
pub fn as_str(self) -> &'static str {
match self {
Self::UnityCrossThreadLogger => "[UnityCrossThreadLogger]",
Self::ClientGre => "[Client GRE]",
Self::ConnectionManager => "[ConnectionManager]",
Self::Matchmaking => "Matchmaking:",
Self::Metadata => "METADATA",
}
}
}
impl std::fmt::Display for EntryHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
pub header: EntryHeader,
pub body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HeaderClass {
SingleLine,
MultiLine,
}
pub struct LineBuffer {
header_re: Regex,
current_header: Option<EntryHeader>,
lines: Vec<String>,
has_emitted_anything: bool,
#[cfg(feature = "brace_depth_flush")]
brace_state: BraceState,
}
#[cfg(feature = "brace_depth_flush")]
#[derive(Default)]
struct BraceState {
depth: u32,
in_string: bool,
escape_pending: bool,
ever_opened: bool,
}
impl LineBuffer {
pub fn new() -> Self {
let header_re =
match Regex::new(r"^\[(UnityCrossThreadLogger|Client GRE|ConnectionManager)\]") {
Ok(re) => re,
Err(e) => unreachable!("invalid header regex: {e}"),
};
Self {
header_re,
current_header: None,
lines: Vec::new(),
has_emitted_anything: false,
#[cfg(feature = "brace_depth_flush")]
brace_state: BraceState::default(),
}
}
pub fn push_line(&mut self, line: &str) -> Vec<LogEntry> {
if Self::is_metadata_line(line) {
let mut out = Vec::new();
if let Some(prior) = self.take_entry() {
out.push(prior);
}
out.push(LogEntry {
header: EntryHeader::Metadata,
body: line.to_owned(),
});
self.has_emitted_anything = true;
return out;
}
if let Some(header) = self.detect_header(line) {
let class = Self::classify_header(header, line);
let mut out = Vec::new();
if let Some(prior) = self.take_entry() {
out.push(prior);
}
match class {
HeaderClass::SingleLine => {
out.push(LogEntry {
header,
body: line.to_owned(),
});
}
HeaderClass::MultiLine => {
self.current_header = Some(header);
self.lines.push(line.to_owned());
}
}
self.has_emitted_anything = true;
out
} else if self.current_header.is_some() {
self.lines.push(line.to_owned());
#[cfg(feature = "brace_depth_flush")]
if self.advance_brace_state(line) {
if let Some(entry) = self.take_entry() {
return vec![entry];
}
}
Vec::new()
} else {
if !self.has_emitted_anything {
::log::warn!(
"Discarding headerless line at start of input: {:?}",
truncate_for_log(line, 120),
);
}
Vec::new()
}
}
pub fn flush(&mut self) -> Option<LogEntry> {
self.take_entry()
}
pub fn reset(&mut self) {
self.current_header = None;
self.lines.clear();
self.has_emitted_anything = false;
#[cfg(feature = "brace_depth_flush")]
{
self.brace_state = BraceState::default();
}
}
pub fn is_empty(&self) -> bool {
self.current_header.is_none()
}
fn is_metadata_line(line: &str) -> bool {
let trimmed = line.trim();
trimmed == "DETAILED LOGS: ENABLED" || trimmed == "DETAILED LOGS: DISABLED"
}
fn detect_header(&self, line: &str) -> Option<EntryHeader> {
if let Some(caps) = self.header_re.captures(line) {
let prefix = caps.get(1)?.as_str();
return match prefix {
"UnityCrossThreadLogger" => Some(EntryHeader::UnityCrossThreadLogger),
"Client GRE" => Some(EntryHeader::ClientGre),
"ConnectionManager" => Some(EntryHeader::ConnectionManager),
_ => None,
};
}
if line.starts_with("Matchmaking: ") {
return Some(EntryHeader::Matchmaking);
}
None
}
fn classify_header(header: EntryHeader, line: &str) -> HeaderClass {
match header {
EntryHeader::UnityCrossThreadLogger => {
let after = line
.strip_prefix("[UnityCrossThreadLogger]")
.unwrap_or(line);
if after.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
HeaderClass::MultiLine
} else {
HeaderClass::SingleLine
}
}
EntryHeader::ClientGre => HeaderClass::MultiLine,
EntryHeader::ConnectionManager | EntryHeader::Matchmaking | EntryHeader::Metadata => {
HeaderClass::SingleLine
}
}
}
fn take_entry(&mut self) -> Option<LogEntry> {
let header = self.current_header.take()?;
let body = self.lines.join("\n");
self.lines.clear();
#[cfg(feature = "brace_depth_flush")]
{
self.brace_state = BraceState::default();
}
Some(LogEntry { header, body })
}
#[cfg(feature = "brace_depth_flush")]
fn advance_brace_state(&mut self, line: &str) -> bool {
let state = &mut self.brace_state;
for ch in line.chars() {
if state.escape_pending {
state.escape_pending = false;
continue;
}
if state.in_string {
match ch {
'\\' => state.escape_pending = true,
'"' => state.in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => state.in_string = true,
'{' => {
state.depth = state.depth.saturating_add(1);
state.ever_opened = true;
}
'}' => {
if state.depth == 0 {
::log::warn!(
"brace_depth underflow at unbalanced '}}' in entry body \
(line prefix: {:?})",
truncate_for_log(line, 120),
);
}
state.depth = state.depth.saturating_sub(1);
}
_ => {}
}
}
state.ever_opened && state.depth == 0
}
}
impl Default for LineBuffer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn expected(header: EntryHeader, body: &str) -> LogEntry {
LogEntry {
header,
body: body.to_owned(),
}
}
mod entry_header {
use super::*;
#[test]
fn test_as_str_unity() {
assert_eq!(
EntryHeader::UnityCrossThreadLogger.as_str(),
"[UnityCrossThreadLogger]"
);
}
#[test]
fn test_as_str_client_gre() {
assert_eq!(EntryHeader::ClientGre.as_str(), "[Client GRE]");
}
#[test]
fn test_display_unity() {
assert_eq!(
EntryHeader::UnityCrossThreadLogger.to_string(),
"[UnityCrossThreadLogger]"
);
}
#[test]
fn test_display_client_gre() {
assert_eq!(EntryHeader::ClientGre.to_string(), "[Client GRE]");
}
#[test]
fn test_clone_and_eq() {
let a = EntryHeader::UnityCrossThreadLogger;
let b = a;
assert_eq!(a, b);
}
}
mod push_line {
use super::*;
#[test]
fn test_push_line_first_multi_line_header_returns_empty() {
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 Event")
.is_empty());
}
#[test]
fn test_push_line_second_multi_line_header_flushes_first_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
assert_eq!(
buf.push_line("[Client GRE] 1/1/2025 Event2"),
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1",
)],
);
}
#[test]
fn test_push_line_continuation_appended() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
buf.push_line("plain text continuation one");
buf.push_line("plain text continuation two");
assert_eq!(
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event2"),
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1\n\
plain text continuation one\n\
plain text continuation two",
)],
);
}
#[test]
fn test_push_line_client_gre_header_detected() {
let mut buf = LineBuffer::new();
buf.push_line("[Client GRE] GreMessage");
assert_eq!(
buf.flush(),
Some(expected(EntryHeader::ClientGre, "[Client GRE] GreMessage")),
);
}
#[test]
fn test_push_line_client_gre_header_accumulates() {
let expected_body = "[Client GRE] GreToClientEvent\n{\n \"key\": \"value\"\n}";
let mut buf = LineBuffer::new();
buf.push_line("[Client GRE] GreToClientEvent");
buf.push_line("{");
buf.push_line(r#" "key": "value""#);
let closing = buf.push_line("}");
#[cfg(feature = "brace_depth_flush")]
{
assert_eq!(
closing,
vec![expected(EntryHeader::ClientGre, expected_body)],
"closing brace must flush the entry under brace_depth_flush",
);
assert!(buf.flush().is_none());
}
#[cfg(not(feature = "brace_depth_flush"))]
{
assert!(closing.is_empty());
assert_eq!(
buf.flush(),
Some(expected(EntryHeader::ClientGre, expected_body)),
);
}
}
#[test]
fn test_push_line_alternating_multi_line_headers() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
assert_eq!(
buf.push_line("[Client GRE] Event2"),
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1",
)],
);
assert_eq!(
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event3"),
vec![expected(EntryHeader::ClientGre, "[Client GRE] Event2")],
);
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event3",
)),
);
}
}
mod single_line_flush {
use super::*;
#[test]
fn test_push_line_single_line_uctl_label_flushes_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line(
"[UnityCrossThreadLogger]STATE CHANGED \
{\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
);
assert_eq!(
entries,
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]STATE CHANGED \
{\"old\":\"None\",\"new\":\"ConnectedToMatchDoor\"}",
)],
);
assert!(
buf.is_empty(),
"buffer must be idle after a single-line flush",
);
}
#[test]
fn test_push_line_single_line_uctl_arrow_flushes_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line(
"[UnityCrossThreadLogger]==> GraphGetGraphState \
{\"id\":\"abc\",\"request\":\"{}\"}",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_single_line_uctl_nested_bracket_flushes_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line(
"[UnityCrossThreadLogger]Client.SceneChange \
{\"fromSceneName\":\"Home\",\"toSceneName\":\"Draft\"}",
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_single_line_connection_manager_flushes_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
assert_eq!(
entries,
vec![expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect succeeded",
)],
);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_single_line_matchmaking_flushes_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line("Matchmaking: GRE connection lost");
assert_eq!(
entries,
vec![expected(
EntryHeader::Matchmaking,
"Matchmaking: GRE connection lost",
)],
);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_multi_line_date_header_accumulates() {
let expected_multi_body = "[UnityCrossThreadLogger]3/11/2026 6:08:24 PM\n\
<== EventGetCoursesV2(abc-123)\n\
{\"Courses\":[]}";
let expected_single_body = "[UnityCrossThreadLogger]Client.SceneChange {}";
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM")
.is_empty());
assert!(buf.push_line("<== EventGetCoursesV2(abc-123)").is_empty());
let closing = buf.push_line(r#"{"Courses":[]}"#);
#[cfg(feature = "brace_depth_flush")]
{
assert_eq!(
closing,
vec![expected(
EntryHeader::UnityCrossThreadLogger,
expected_multi_body
)],
);
let entries = buf.push_line("[UnityCrossThreadLogger]Client.SceneChange {}");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert_eq!(entries[0].body, expected_single_body);
}
#[cfg(not(feature = "brace_depth_flush"))]
{
assert!(closing.is_empty());
let entries = buf.push_line("[UnityCrossThreadLogger]Client.SceneChange {}");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert_eq!(entries[0].body, expected_multi_body);
assert_eq!(entries[1].header, EntryHeader::UnityCrossThreadLogger);
assert_eq!(entries[1].body, expected_single_body);
}
}
#[test]
fn test_push_line_post_single_line_orphan_discarded() {
let mut buf = LineBuffer::new();
let first = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
assert_eq!(first.len(), 1);
assert!(buf.is_empty());
let noise = buf.push_line("PreviousPlayBladeVisualState is being set ...");
assert!(noise.is_empty());
assert!(buf.is_empty());
let next = buf.push_line("[UnityCrossThreadLogger]Connecting to matchId abc");
assert_eq!(next.len(), 1);
assert!(!next[0].body.contains("PreviousPlayBladeVisualState"));
}
#[test]
fn test_push_line_multi_line_then_single_line_emits_two() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]3/11/2026 6:08:24 PM");
buf.push_line("<== Foo(123)");
let entries = buf.push_line("[ConnectionManager] Reconnect failed");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(entries[0].body.contains("<== Foo(123)"));
assert_eq!(entries[1].header, EntryHeader::ConnectionManager);
assert_eq!(entries[1].body, "[ConnectionManager] Reconnect failed");
assert!(buf.is_empty());
}
}
mod headerless {
use super::*;
#[test]
fn test_push_line_headerless_before_first_header_returns_empty() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("some random line").is_empty());
assert!(buf.push_line("another orphan").is_empty());
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Real entry");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Real entry",
)),
);
}
#[test]
fn test_push_line_empty_line_as_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line("");
buf.push_line("continuation");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event\n\ncontinuation",
)),
);
}
}
mod flush {
use super::*;
#[test]
fn test_flush_empty_buffer_returns_none() {
let mut buf = LineBuffer::new();
assert!(buf.flush().is_none());
}
#[test]
fn test_flush_returns_buffered_multi_line_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event",
)),
);
}
#[test]
fn test_flush_clears_buffer() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.flush();
assert!(buf.flush().is_none());
assert!(buf.is_empty());
}
#[test]
fn test_flush_multi_line_entry() {
let expected_body = [
"[Client GRE] GreToClientEvent",
"{",
r#" "gameObjects": ["obj1", "obj2"],"#,
r#" "actions": []"#,
"}",
]
.join("\n");
let mut buf = LineBuffer::new();
buf.push_line("[Client GRE] GreToClientEvent");
buf.push_line("{");
buf.push_line(r#" "gameObjects": ["obj1", "obj2"],"#);
buf.push_line(r#" "actions": []"#);
let closing = buf.push_line("}");
#[cfg(feature = "brace_depth_flush")]
{
assert_eq!(
closing,
vec![expected(EntryHeader::ClientGre, &expected_body)],
);
assert!(buf.flush().is_none());
}
#[cfg(not(feature = "brace_depth_flush"))]
{
assert!(closing.is_empty());
assert_eq!(
buf.flush(),
Some(expected(EntryHeader::ClientGre, &expected_body)),
);
}
}
}
mod reset {
use super::*;
#[test]
fn test_reset_clears_in_progress_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line(r#"{"k": "unfinished"#);
buf.reset();
assert!(buf.is_empty());
assert!(buf.flush().is_none());
#[cfg(feature = "brace_depth_flush")]
{
assert_eq!(buf.brace_state.depth, 0, "reset() must clear depth");
assert!(!buf.brace_state.in_string, "reset() must clear in_string");
assert!(
!buf.brace_state.escape_pending,
"reset() must clear escape_pending",
);
assert!(
!buf.brace_state.ever_opened,
"reset() must clear ever_opened",
);
}
}
#[test]
fn test_reset_allows_fresh_accumulation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Old");
buf.reset();
buf.push_line("[Client GRE] New");
assert_eq!(
buf.flush(),
Some(expected(EntryHeader::ClientGre, "[Client GRE] New")),
);
}
}
mod is_empty {
use super::*;
#[test]
fn test_is_empty_on_new_buffer() {
let buf = LineBuffer::new();
assert!(buf.is_empty());
}
#[test]
fn test_is_empty_false_after_multi_line_header() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
assert!(!buf.is_empty());
}
#[test]
fn test_is_empty_true_after_single_line_flush() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]STATE CHANGED");
assert!(buf.is_empty());
}
#[test]
fn test_is_empty_true_after_flush() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.flush();
assert!(buf.is_empty());
}
#[test]
fn test_is_empty_true_after_headerless_lines() {
let mut buf = LineBuffer::new();
buf.push_line("orphan line");
assert!(buf.is_empty());
}
}
mod default_impl {
use super::*;
#[test]
fn test_default_creates_functional_buffer() {
let mut buf = LineBuffer::default();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event",
)),
);
}
}
mod header_detection {
use super::*;
#[test]
fn test_header_not_at_start_of_line_is_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line("some text [UnityCrossThreadLogger] not a header");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event\n\
some text [UnityCrossThreadLogger] not a header",
)),
);
}
#[test]
fn test_similar_but_wrong_header_is_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line("[UnityMainThreadLogger] not a valid header");
let result = buf.flush();
assert!(result.is_some());
if let Some(e) = result {
assert!(e.body.contains("[UnityMainThreadLogger]"));
}
}
#[test]
fn test_bracket_only_is_not_header() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line("[]");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event\n[]",
)),
);
}
#[test]
fn test_header_with_nothing_after_bracket() {
let mut buf = LineBuffer::new();
let entries = buf.push_line("[UnityCrossThreadLogger]");
assert_eq!(
entries,
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]",
)],
);
assert!(buf.is_empty());
}
}
mod realistic_entries {
use super::*;
#[test]
fn test_realistic_game_state_message() {
let mut buf = LineBuffer::new();
buf.push_line(
"[UnityCrossThreadLogger]1/15/2025 3:42:17 PM \
greToClientEvent",
);
buf.push_line("{");
buf.push_line(r#" "greToClientMessages": ["#);
buf.push_line(r" {");
buf.push_line(r#" "type": "GREMessageType_GameStateMessage","#);
buf.push_line(r#" "gameStateMessage": {"#);
buf.push_line(r#" "gameObjects": []"#);
buf.push_line(r" }");
buf.push_line(r" }");
buf.push_line(r" ]");
let final_brace = buf.push_line("}");
#[cfg(feature = "brace_depth_flush")]
{
assert_eq!(final_brace.len(), 1);
assert_eq!(final_brace[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(final_brace[0].body.contains("greToClientMessages"));
assert!(final_brace[0].body.contains("GameStateMessage"));
assert!(buf.push_line("[Client GRE] Next event").is_empty());
assert_eq!(
buf.push_line("[UnityCrossThreadLogger]1/15/2025 After"),
vec![expected(EntryHeader::ClientGre, "[Client GRE] Next event")],
);
}
#[cfg(not(feature = "brace_depth_flush"))]
{
assert!(final_brace.is_empty());
let unity_entries = buf.push_line("[Client GRE] Next event");
assert_eq!(unity_entries.len(), 1);
assert_eq!(unity_entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(unity_entries[0].body.contains("greToClientMessages"));
assert!(unity_entries[0].body.contains("GameStateMessage"));
assert_eq!(
buf.push_line("[UnityCrossThreadLogger]1/15/2025 After"),
vec![expected(EntryHeader::ClientGre, "[Client GRE] Next event")],
);
}
}
#[test]
fn test_many_single_line_entries_in_sequence() {
let mut buf = LineBuffer::new();
let mut entries = Vec::new();
for i in 0..5 {
entries.extend(buf.push_line(&format!("[UnityCrossThreadLogger]Event{i}")));
}
entries.extend(buf.flush());
assert_eq!(entries.len(), 5);
for (i, e) in entries.iter().enumerate() {
assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
assert_eq!(e.body, format!("[UnityCrossThreadLogger]Event{i}"));
}
}
}
mod metadata_lines {
use super::*;
#[test]
fn test_push_line_detailed_logs_enabled_as_first_line() {
let mut buf = LineBuffer::new();
let result = buf.push_line("DETAILED LOGS: ENABLED");
assert_eq!(
result,
vec![expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")],
);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_detailed_logs_disabled_as_first_line() {
let mut buf = LineBuffer::new();
let result = buf.push_line("DETAILED LOGS: DISABLED");
assert_eq!(
result,
vec![expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")],
);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_metadata_flushes_buffered_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
let entries = buf.push_line("DETAILED LOGS: ENABLED");
assert_eq!(
entries,
vec![
expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1",
),
expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED"),
],
);
assert!(buf.is_empty());
}
#[test]
fn test_push_line_metadata_then_header_flushes_metadata() {
let mut buf = LineBuffer::new();
buf.push_line("DETAILED LOGS: ENABLED");
assert!(buf
.push_line("[UnityCrossThreadLogger]1/1/2025 Event")
.is_empty());
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event",
)),
);
}
#[test]
fn test_push_line_metadata_similar_text_not_matched() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("DETAILED LOGS: UNKNOWN").is_empty());
assert!(buf.push_line("detailed logs: enabled").is_empty());
assert!(buf.push_line("DETAILED LOGS:ENABLED").is_empty());
}
#[test]
fn test_push_line_metadata_with_leading_trailing_whitespace() {
let mut buf = LineBuffer::new();
let result = buf.push_line(" DETAILED LOGS: ENABLED ");
assert_eq!(result.len(), 1);
assert_eq!(result[0].header, EntryHeader::Metadata);
}
#[test]
fn test_entry_header_metadata_as_str() {
assert_eq!(EntryHeader::Metadata.as_str(), "METADATA");
}
#[test]
fn test_entry_header_metadata_display() {
assert_eq!(EntryHeader::Metadata.to_string(), "METADATA");
}
}
mod orphan_warn_gating {
use super::*;
use std::sync::{Mutex, OnceLock};
struct CaptureLogger {
records: Mutex<Vec<(::log::Level, String)>>,
}
impl ::log::Log for CaptureLogger {
fn enabled(&self, _metadata: &::log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &::log::Record<'_>) {
let mut guard = match self.records.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.push((record.level(), record.args().to_string()));
}
fn flush(&self) {}
}
static LOGGER: OnceLock<&'static CaptureLogger> = OnceLock::new();
type RecordsRef = &'static Mutex<Vec<(::log::Level, String)>>;
fn install_capture() -> RecordsRef {
let logger = LOGGER.get_or_init(|| {
let leaked: &'static CaptureLogger = Box::leak(Box::new(CaptureLogger {
records: Mutex::new(Vec::new()),
}));
let _ = ::log::set_logger(leaked);
::log::set_max_level(::log::LevelFilter::Trace);
leaked
});
&logger.records
}
fn warn_count_matching(
records: &Mutex<Vec<(::log::Level, String)>>,
marker: &str,
) -> usize {
let guard = match records.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard
.iter()
.filter(|(lvl, msg)| {
*lvl == ::log::Level::Warn
&& msg.starts_with("Discarding headerless line at start of input")
&& msg.contains(marker)
})
.count()
}
#[test]
fn test_push_line_first_orphan_warns() {
const MARKER: &str = "P2-MARKER-FIRST-ORPHAN-WARNS-zX9q";
let records = install_capture();
let mut buf = LineBuffer::new();
assert!(buf.push_line(MARKER).is_empty());
assert_eq!(
warn_count_matching(records, MARKER),
1,
"first orphan at file start must warn (rotation/file-start anomaly)",
);
}
#[test]
fn test_push_line_post_flush_orphan_silent() {
const MARKER: &str = "P2-MARKER-POST-FLUSH-SILENT-kJ7w";
let records = install_capture();
let mut buf = LineBuffer::new();
let entries = buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {\"x\":1}");
assert_eq!(entries.len(), 1);
assert!(buf.is_empty());
assert!(buf.push_line(MARKER).is_empty());
assert_eq!(
warn_count_matching(records, MARKER),
0,
"post-flush orphan must be silently discarded (no warn)",
);
}
#[test]
fn test_push_line_orphan_after_reset_warns() {
const MARKER: &str = "P2-MARKER-AFTER-RESET-WARNS-vN2t";
let records = install_capture();
let mut buf = LineBuffer::new();
assert_eq!(
buf.push_line("[UnityCrossThreadLogger]STATE CHANGED {}")
.len(),
1,
);
buf.reset();
assert!(buf.push_line(MARKER).is_empty());
assert_eq!(
warn_count_matching(records, MARKER),
1,
"first orphan after reset must warn (rotation anomaly)",
);
}
#[test]
fn test_push_line_orphan_after_metadata_silent() {
const MARKER: &str = "P2-MARKER-AFTER-METADATA-SILENT-bH4r";
let records = install_capture();
let mut buf = LineBuffer::new();
let entries = buf.push_line("DETAILED LOGS: ENABLED");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::Metadata);
assert!(buf.push_line(MARKER).is_empty());
assert_eq!(
warn_count_matching(records, MARKER),
0,
"orphan after metadata must be silently discarded (no warn)",
);
}
}
mod connection_and_matchmaking_headers {
use super::*;
#[test]
fn test_as_str_connection_manager() {
assert_eq!(
EntryHeader::ConnectionManager.as_str(),
"[ConnectionManager]"
);
}
#[test]
fn test_as_str_matchmaking() {
assert_eq!(EntryHeader::Matchmaking.as_str(), "Matchmaking:");
}
#[test]
fn test_display_connection_manager() {
assert_eq!(
EntryHeader::ConnectionManager.to_string(),
"[ConnectionManager]"
);
}
#[test]
fn test_display_matchmaking() {
assert_eq!(EntryHeader::Matchmaking.to_string(), "Matchmaking:");
}
#[test]
fn test_connection_manager_header_mid_stream_flushes_unity() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
let entries = buf.push_line("[ConnectionManager] Reconnect result : Error");
assert_eq!(
entries,
vec![
expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1",
),
expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect result : Error",
),
],
);
assert!(buf.is_empty());
}
#[test]
fn test_matchmaking_header_mid_stream_flushes_unity() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event1");
let entries = buf.push_line("Matchmaking: GRE connection lost");
assert_eq!(
entries,
vec![
expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event1",
),
expected(EntryHeader::Matchmaking, "Matchmaking: GRE connection lost",),
],
);
assert!(buf.is_empty());
}
#[test]
fn test_connection_manager_as_first_line_emits_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line("[ConnectionManager] Reconnect succeeded");
assert_eq!(
entries,
vec![expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect succeeded",
)],
);
assert!(buf.is_empty());
}
#[test]
fn test_matchmaking_as_first_line_emits_immediately() {
let mut buf = LineBuffer::new();
let entries = buf.push_line("Matchmaking: GRE connection lost");
assert_eq!(
entries,
vec![expected(
EntryHeader::Matchmaking,
"Matchmaking: GRE connection lost",
)],
);
assert!(buf.is_empty());
}
#[test]
fn test_four_way_interleave_yields_four_entries() {
let mut buf = LineBuffer::new();
let mut entries = Vec::new();
entries.extend(buf.push_line(
"[UnityCrossThreadLogger]STATE CHANGED \
{\"old\":\"Playing\",\"new\":\"Disconnected\"}",
));
entries.extend(buf.push_line("Matchmaking: GRE connection lost"));
entries.extend(buf.push_line("[ConnectionManager] Reconnect result : Error"));
entries.extend(buf.push_line("[UnityCrossThreadLogger]Next event"));
entries.extend(buf.flush());
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(entries[0].body.contains("STATE CHANGED"));
assert_eq!(entries[1].header, EntryHeader::Matchmaking);
assert_eq!(entries[1].body, "Matchmaking: GRE connection lost");
assert_eq!(entries[2].header, EntryHeader::ConnectionManager);
assert_eq!(
entries[2].body,
"[ConnectionManager] Reconnect result : Error"
);
assert_eq!(entries[3].header, EntryHeader::UnityCrossThreadLogger);
assert_eq!(entries[3].body, "[UnityCrossThreadLogger]Next event");
}
#[test]
fn test_matchmaking_without_trailing_space_is_not_header() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("Matchmaking:compact-no-space").is_empty());
assert!(buf.is_empty());
}
#[test]
fn test_connection_manager_mid_line_is_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
buf.push_line("some text [ConnectionManager] not a header");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event\n\
some text [ConnectionManager] not a header",
)),
);
}
}
#[cfg(feature = "brace_depth_flush")]
mod brace_depth_flush {
use super::*;
#[test]
fn test_single_line_json_body_flushes_immediately() {
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[UnityCrossThreadLogger]1/1/2025 Event")
.is_empty());
let result = buf.push_line(r#"{"key":"value"}"#);
assert_eq!(
result,
vec![expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]1/1/2025 Event\n{\"key\":\"value\"}",
)],
);
assert!(buf.is_empty(), "buffer must be idle after brace-flush");
}
#[test]
fn test_multi_line_pretty_printed_json_flushes_on_closing_brace() {
let mut buf = LineBuffer::new();
buf.push_line("[Client GRE] GreToClientEvent");
buf.push_line("{");
buf.push_line(r#" "key": "val""#);
let result = buf.push_line("}");
assert_eq!(result.len(), 1);
assert_eq!(result[0].header, EntryHeader::ClientGre);
assert_eq!(
result[0].body,
"[Client GRE] GreToClientEvent\n{\n \"key\": \"val\"\n}",
);
assert!(buf.is_empty());
}
#[test]
fn test_response_marker_then_json_flushes() {
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[UnityCrossThreadLogger]1/1/2025 12:00:00 PM")
.is_empty());
assert!(buf.push_line("<== EventGetCoursesV2(abc)").is_empty());
let result = buf.push_line(r#"{"Courses":[]}"#);
assert_eq!(result.len(), 1);
assert_eq!(result[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(result[0].body.contains("<== EventGetCoursesV2(abc)"));
assert!(result[0].body.contains(r#"{"Courses":[]}"#));
assert!(buf.is_empty());
}
#[test]
fn test_non_json_body_falls_through_to_next_header() {
let mut buf = LineBuffer::new();
buf.push_line("[Client GRE] GreToClientEvent");
assert!(buf.push_line("[Message summarized due to size]").is_empty());
assert!(buf.push_line(":: 12345 entries").is_empty());
assert!(buf.push_line(":: payload elided").is_empty());
let entries = buf.push_line("[UnityCrossThreadLogger]1/1/2025 After");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::ClientGre);
assert!(entries[0].body.contains("[Message summarized"));
assert!(entries[0].body.contains(":: 12345 entries"));
}
#[test]
fn test_brace_state_clears_between_entries() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 First");
let first = buf.push_line(r#"{"a":1}"#);
assert_eq!(first.len(), 1);
assert!(buf.is_empty());
assert_eq!(buf.brace_state.depth, 0);
assert!(!buf.brace_state.in_string);
assert!(!buf.brace_state.escape_pending);
assert!(!buf.brace_state.ever_opened);
buf.push_line("[Client GRE] PlainBodyEvent");
assert!(buf.push_line("just text").is_empty());
let entries = buf.push_line("[UnityCrossThreadLogger]1/1/2025 Third");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].header, EntryHeader::ClientGre);
assert_eq!(entries[0].body, "[Client GRE] PlainBodyEvent\njust text");
}
#[test]
fn test_brace_flush_arms_orphan_warn_gating() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Event");
let flushed = buf.push_line(r#"{"k":"v"}"#);
assert_eq!(flushed.len(), 1);
assert!(
buf.has_emitted_anything,
"brace-flush path must leave has_emitted_anything armed",
);
assert!(buf.push_line("orphan stdout noise").is_empty());
assert!(buf.is_empty());
}
}
#[cfg(feature = "brace_depth_flush")]
mod brace_depth_property {
use super::*;
use proptest::prelude::*;
use serde_json::Value;
fn arb_json_value() -> impl Strategy<Value = Value> {
let arb_string = r#"[a-z0-9 \{\}\"\\]{0,12}"#.prop_map(Value::String);
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i32>().prop_map(|n| Value::Number(n.into())),
arb_string,
];
leaf.prop_recursive(3, 24, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
prop::collection::vec((r"[a-z]{1,6}", inner), 0..4)
.prop_map(|kvs| { Value::Object(kvs.into_iter().collect()) }),
]
})
}
proptest! {
#[test]
fn prop_balanced_json_flushes_exactly_once(value in arb_json_value()) {
let body = match serde_json::to_string(&Value::Object(
[("v".to_owned(), value)].into_iter().collect(),
)) {
Ok(s) => s,
Err(e) => unreachable!("serde_json::to_string on Value failed: {e}"),
};
let mut buf = LineBuffer::new();
let header = buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
prop_assert!(header.is_empty());
let out = buf.push_line(&body);
prop_assert_eq!(out.len(), 1, "balanced JSON must brace-flush");
prop_assert!(buf.is_empty());
}
#[test]
fn prop_unterminated_string_never_balances(
prefix in r"[a-z0-9 ]{0,16}",
trailing in r"[a-z0-9 \{\}]{0,16}",
) {
let body = format!(r#"{{"k":"{prefix}{trailing}"#);
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
let out = buf.push_line(&body);
prop_assert_eq!(
out.len(),
0,
"unterminated string literal must not be reported balanced",
);
prop_assert!(!buf.is_empty(), "entry should remain accumulating");
}
#[test]
fn prop_braces_in_strings_dont_count(
noise in r"[\{\}]{0,16}",
) {
let body = format!(r#"{{"junk":"{noise}"}}"#);
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 PropTest");
let out = buf.push_line(&body);
prop_assert_eq!(
out.len(),
1,
"braces inside string literals must not affect the counter",
);
}
}
#[test]
fn test_regression_nested_json_in_string() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Nested");
let body = r#"{"request":"{\"foo\":\"bar\"}"}"#;
let out = buf.push_line(body);
assert_eq!(out.len(), 1, "nested-string body must brace-balance");
assert_eq!(
out[0].body,
format!("[UnityCrossThreadLogger]1/1/2025 Nested\n{body}")
);
}
#[test]
fn test_regression_escaped_quote_inside_string() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscQuote");
let body = r#"{"name":"a \"quoted\" name"}"#;
let out = buf.push_line(body);
assert_eq!(out.len(), 1);
assert!(out[0].body.contains(r#""a \"quoted\" name""#));
}
#[test]
fn test_regression_escaped_backslash_inside_string() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscBackslash");
let body = r#"{"path":"C:\\Users\\foo"}"#;
let out = buf.push_line(body);
assert_eq!(out.len(), 1);
assert!(out[0].body.contains(r#""C:\\Users\\foo""#));
}
#[test]
fn test_regression_brace_inside_string_literal() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 BraceInStr");
let body = r#"{"emoji":"{ :) }"}"#;
let out = buf.push_line(body);
assert_eq!(out.len(), 1);
assert!(out[0].body.contains(r#""{ :) }""#));
}
#[test]
fn test_regression_unbalanced_json_falls_through() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 Unbalanced");
assert!(buf.push_line(r#"{"unclosed":"#).is_empty());
assert!(buf.push_line(r#" "more":"data""#).is_empty());
let next = buf.push_line("[UnityCrossThreadLogger]1/1/2025 NextEvent");
assert_eq!(next.len(), 1);
assert_eq!(next[0].header, EntryHeader::UnityCrossThreadLogger);
assert!(next[0].body.contains(r#"{"unclosed":"#));
}
#[test]
fn test_regression_escaped_newline_in_string() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]1/1/2025 EscNewline");
let body = r#"{"raw":"line1\nline2"}"#;
let out = buf.push_line(body);
assert_eq!(out.len(), 1);
assert!(out[0].body.contains(r#""line1\nline2""#));
}
}
}