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,
}
pub struct LineBuffer {
header_re: Regex,
current_header: Option<EntryHeader>,
lines: Vec<String>,
}
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(),
}
}
pub fn push_line(&mut self, line: &str) -> Option<LogEntry> {
if Self::is_metadata_line(line) {
let flushed = self.take_entry();
let metadata_entry = LogEntry {
header: EntryHeader::Metadata,
body: line.to_owned(),
};
if flushed.is_some() {
self.current_header = Some(EntryHeader::Metadata);
self.lines.push(line.to_owned());
return flushed;
}
return Some(metadata_entry);
}
if let Some(header) = self.detect_header(line) {
let flushed = self.take_entry();
self.current_header = Some(header);
self.lines.push(line.to_owned());
flushed
} else if self.current_header.is_some() {
self.lines.push(line.to_owned());
None
} else {
::log::warn!(
"Discarding headerless line at start of input: {:?}",
truncate_for_log(line, 120),
);
None
}
}
pub fn flush(&mut self) -> Option<LogEntry> {
self.take_entry()
}
pub fn reset(&mut self) {
self.current_header = None;
self.lines.clear();
}
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 take_entry(&mut self) -> Option<LogEntry> {
let header = self.current_header.take()?;
let body = self.lines.join("\n");
self.lines.clear();
Some(LogEntry { header, body })
}
}
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_header_returns_none() {
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[UnityCrossThreadLogger] 1/1/2025 12:00:00 Event")
.is_none());
}
#[test]
fn test_push_line_second_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"),
Some(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(r#"{"key": "value"}"#);
buf.push_line(r#"{"more": "data"}"#);
assert_eq!(
buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event2"),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 1/1/2025 Event1\n\
{\"key\": \"value\"}\n\
{\"more\": \"data\"}",
)),
);
}
#[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_alternating_headers() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event1");
assert_eq!(
buf.push_line("[Client GRE] Event2"),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event1",
)),
);
assert_eq!(
buf.push_line("[UnityCrossThreadLogger] Event3"),
Some(expected(EntryHeader::ClientGre, "[Client GRE] Event2")),
);
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event3",
)),
);
}
}
mod headerless {
use super::*;
#[test]
fn test_push_line_headerless_before_first_header_returns_none() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("some random line").is_none());
assert!(buf.push_line("another orphan").is_none());
buf.push_line("[UnityCrossThreadLogger] Real entry");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Real entry",
)),
);
}
#[test]
fn test_push_line_empty_line_as_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event");
buf.push_line("");
buf.push_line("continuation");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 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_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event",
)),
);
}
#[test]
fn test_flush_clears_buffer() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event");
buf.flush();
assert!(buf.flush().is_none());
assert!(buf.is_empty());
}
#[test]
fn test_flush_multi_line_entry() {
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": []"#);
buf.push_line("}");
let expected_body = [
"[Client GRE] GreToClientEvent",
"{",
r#" "gameObjects": ["obj1", "obj2"],"#,
r#" "actions": []"#,
"}",
]
.join("\n");
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] Event");
buf.push_line("continuation");
buf.reset();
assert!(buf.is_empty());
assert!(buf.flush().is_none());
}
#[test]
fn test_reset_allows_fresh_accumulation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] 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_header() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event");
assert!(!buf.is_empty());
}
#[test]
fn test_is_empty_true_after_flush() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] 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] Event");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 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] Event");
buf.push_line("some text [UnityCrossThreadLogger] not a header");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 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] 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] Event");
buf.push_line("[]");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event\n[]",
)),
);
}
#[test]
fn test_header_with_nothing_after_bracket() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger]");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger]",
)),
);
}
}
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" ]");
buf.push_line("}");
let unity_entry = buf.push_line("[Client GRE] Next event");
assert!(unity_entry.is_some());
if let Some(e) = unity_entry {
assert_eq!(e.header, EntryHeader::UnityCrossThreadLogger);
assert!(e.body.contains("greToClientMessages"));
assert!(e.body.contains("GameStateMessage"));
}
assert_eq!(
buf.push_line("[UnityCrossThreadLogger] After"),
Some(expected(EntryHeader::ClientGre, "[Client GRE] Next event",)),
);
}
#[test]
fn test_many_entries_in_sequence() {
let mut buf = LineBuffer::new();
let mut entries = Vec::new();
for i in 0..5 {
if let Some(e) = buf.push_line(&format!("[UnityCrossThreadLogger] Event{i}")) {
entries.push(e);
}
}
if let Some(e) = buf.flush() {
entries.push(e);
}
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,
Some(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,
Some(expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")),
);
}
#[test]
fn test_push_line_metadata_flushes_buffered_entry() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event1");
let flushed = buf.push_line("DETAILED LOGS: ENABLED");
assert_eq!(
flushed,
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event1",
)),
);
let metadata = buf.flush();
assert_eq!(
metadata,
Some(expected(EntryHeader::Metadata, "DETAILED LOGS: ENABLED")),
);
}
#[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] Event").is_none());
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event",
)),
);
}
#[test]
fn test_push_line_metadata_buffered_then_next_header_flushes() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event1");
buf.push_line("DETAILED LOGS: DISABLED");
let flushed = buf.push_line("[UnityCrossThreadLogger] Event2");
assert_eq!(
flushed,
Some(expected(EntryHeader::Metadata, "DETAILED LOGS: DISABLED")),
);
}
#[test]
fn test_push_line_metadata_similar_text_not_matched() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("DETAILED LOGS: UNKNOWN").is_none());
assert!(buf.push_line("detailed logs: enabled").is_none());
assert!(buf.push_line("DETAILED LOGS:ENABLED").is_none());
}
#[test]
fn test_push_line_metadata_with_leading_trailing_whitespace() {
let mut buf = LineBuffer::new();
let result = buf.push_line(" DETAILED LOGS: ENABLED ");
assert!(result.is_some());
if let Some(entry) = result {
assert_eq!(entry.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 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 flushed = buf.push_line("[ConnectionManager] Reconnect result : Error");
assert_eq!(
flushed,
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 1/1/2025 Event1",
)),
);
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect result : Error",
)),
);
}
#[test]
fn test_matchmaking_header_mid_stream_flushes_unity() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] 1/1/2025 Event1");
let flushed = buf.push_line("Matchmaking: GRE connection lost");
assert_eq!(
flushed,
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] 1/1/2025 Event1",
)),
);
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::Matchmaking,
"Matchmaking: GRE connection lost",
)),
);
}
#[test]
fn test_connection_manager_as_first_line_no_warning_emitted() {
let mut buf = LineBuffer::new();
assert!(buf
.push_line("[ConnectionManager] Reconnect succeeded")
.is_none());
assert!(!buf.is_empty());
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect succeeded",
)),
);
}
#[test]
fn test_matchmaking_as_first_line_no_warning_emitted() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("Matchmaking: GRE connection lost").is_none());
assert!(!buf.is_empty());
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::Matchmaking,
"Matchmaking: GRE connection lost",
)),
);
}
#[test]
fn test_four_way_interleave_yields_four_entries() {
let mut buf = LineBuffer::new();
let mut entries = Vec::new();
if let Some(e) = buf.push_line(
"[UnityCrossThreadLogger]STATE CHANGED {\"old\":\"Playing\",\"new\":\"Disconnected\"}",
) {
entries.push(e);
}
if let Some(e) = buf.push_line("Matchmaking: GRE connection lost") {
entries.push(e);
}
if let Some(e) = buf.push_line("[ConnectionManager] Reconnect result : Error") {
entries.push(e);
}
if let Some(e) = buf.push_line("[UnityCrossThreadLogger] Next event") {
entries.push(e);
}
if let Some(e) = buf.flush() {
entries.push(e);
}
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_connection_manager_accumulates_continuation_lines() {
let mut buf = LineBuffer::new();
buf.push_line("[ConnectionManager] Reconnect result : Error");
buf.push_line(" extra detail line");
buf.push_line(" another detail line");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::ConnectionManager,
"[ConnectionManager] Reconnect result : Error\n extra detail line\n another detail line",
)),
);
}
#[test]
fn test_matchmaking_accumulates_continuation_lines() {
let mut buf = LineBuffer::new();
buf.push_line("Matchmaking: GRE connection lost");
buf.push_line("extra continuation");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::Matchmaking,
"Matchmaking: GRE connection lost\nextra continuation",
)),
);
}
#[test]
fn test_matchmaking_without_trailing_space_is_not_header() {
let mut buf = LineBuffer::new();
assert!(buf.push_line("Matchmaking:compact-no-space").is_none());
assert!(buf.is_empty());
}
#[test]
fn test_connection_manager_mid_line_is_continuation() {
let mut buf = LineBuffer::new();
buf.push_line("[UnityCrossThreadLogger] Event");
buf.push_line("some text [ConnectionManager] not a header");
assert_eq!(
buf.flush(),
Some(expected(
EntryHeader::UnityCrossThreadLogger,
"[UnityCrossThreadLogger] Event\n\
some text [ConnectionManager] not a header",
)),
);
}
}
}