chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "unitydiag"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Extract high-signal Unity and BepInEx incidents from runtime logs."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
unitysupport = { path = "../unitysupport" }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
filetime.workspace = true
|
||||
predicates.workspace = true
|
||||
@@ -0,0 +1,700 @@
|
||||
//! The `unitydiag` command summarizes Unity and `BepInEx` logs.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
|
||||
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
||||
print_text, read_existing_stdin_paths,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use serde_json::Value;
|
||||
use unitysupport::{
|
||||
IncidentGroupMode, StackMode, UnityDiagOptions, UnityDiagReport, UnityLogKind,
|
||||
analyze_log_paths, analyze_log_text, discover_log_paths, parse_incident_group_mode,
|
||||
parse_stack_mode,
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
Extract high-signal incidents from Unity Player.log and BepInEx logs.
|
||||
|
||||
Usage:
|
||||
unitydiag [OPTIONS] [PATH...]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--game-root <PATH> Auto-include BepInEx logs plus matching Player.log files for that game
|
||||
--latest Search common Unity and BepInEx log locations for the newest log
|
||||
--top <COUNT> Maximum incidents to render
|
||||
--group-by <MODE> incident, message, or frame
|
||||
--stack <MODE> none, top, or full
|
||||
--include-warnings Include warning incidents
|
||||
--include-info Include info incidents
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
unitydiag 'C:\\Users\\example\\AppData\\LocalLow\\Studio\\Game\\Player.log'
|
||||
unitydiag --game-root 'C:\\game' --latest
|
||||
unitydiag 'C:\\game\\BepInEx\\LogOutput.log' --include-info --top 10
|
||||
unitydiag --game-root 'C:\\game' --json | ConvertFrom-Json
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
game_root: Option<PathBuf>,
|
||||
latest: bool,
|
||||
top: usize,
|
||||
group_by: IncidentGroupMode,
|
||||
stack_mode: StackMode,
|
||||
include_warnings: bool,
|
||||
include_info: bool,
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
enum InputSource {
|
||||
Paths(Vec<PathBuf>),
|
||||
Text { label: String, content: String },
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => match print_text(HELP) {
|
||||
Ok(()) => ExitCode::Success.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("unitydiag {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
game_root: None,
|
||||
latest: false,
|
||||
top: 20,
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
stack_mode: StackMode::Top,
|
||||
include_warnings: false,
|
||||
include_info: false,
|
||||
paths: Vec::new(),
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("input-format") => {
|
||||
cli.common.input_format =
|
||||
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
|
||||
}
|
||||
Long("color") => {
|
||||
cli.common.color =
|
||||
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("game-root") => {
|
||||
cli.game_root = Some(parser_value_path(&mut parser, "--game-root")?);
|
||||
}
|
||||
Long("latest") => cli.latest = true,
|
||||
Long("top") => {
|
||||
cli.top = parse_usize_flag("--top", &parser_value_string(&mut parser, "--top")?)?;
|
||||
}
|
||||
Long("group-by") => {
|
||||
cli.group_by =
|
||||
parse_incident_group_mode(&parser_value_string(&mut parser, "--group-by")?)?;
|
||||
}
|
||||
Long("stack") => {
|
||||
cli.stack_mode = parse_stack_mode(&parser_value_string(&mut parser, "--stack")?)?;
|
||||
}
|
||||
Long("include-warnings") => cli.include_warnings = true,
|
||||
Long("include-info") => cli.include_info = true,
|
||||
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parser_value_path(parser: &mut lexopt::Parser, flag: &str) -> Result<PathBuf, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
if value.is_empty() {
|
||||
Err(CliError::usage(format!("{flag} requires a path value")))
|
||||
} else {
|
||||
Ok(PathBuf::from(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
if cli.top == 0 {
|
||||
return Err(CliError::usage("--top must be greater than 0"));
|
||||
}
|
||||
|
||||
let options = UnityDiagOptions {
|
||||
top: cli.top,
|
||||
group_by: cli.group_by,
|
||||
stack_mode: cli.stack_mode,
|
||||
include_warnings: cli.include_warnings,
|
||||
include_info: cli.include_info,
|
||||
};
|
||||
|
||||
let report = match collect_input(cli)? {
|
||||
InputSource::Paths(paths) => analyze_log_paths(&paths, &options)?,
|
||||
InputSource::Text { label, content } => {
|
||||
analyze_log_text(&label, &content, infer_stdin_kind(&content), &options)
|
||||
}
|
||||
};
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&report)?,
|
||||
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
|
||||
RenderMode::Text => {
|
||||
let rendered = render_text(&report, cli);
|
||||
if !rendered.is_empty() {
|
||||
print_text(&rendered)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if report.logs.is_empty() {
|
||||
map_result_count(report.incidents.len())
|
||||
} else {
|
||||
ExitCode::Success
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_input(cli: &Cli) -> Result<InputSource, CliError> {
|
||||
if !cli.paths.is_empty() || cli.game_root.is_some() || cli.latest {
|
||||
let paths = discover_log_paths(&cli.paths, cli.game_root.as_deref(), cli.latest)?;
|
||||
if !paths.is_empty() {
|
||||
return Ok(InputSource::Paths(paths));
|
||||
}
|
||||
}
|
||||
|
||||
if !cli.common.stdin_is_terminal() {
|
||||
let mut buffer = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
if !buffer.trim().is_empty() {
|
||||
return parse_stdin_input(&buffer, cli.common.input_format);
|
||||
}
|
||||
}
|
||||
|
||||
Err(CliError::usage(
|
||||
"provide at least one log path, --latest, --game-root, or pipe log text into stdin",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_stdin_input(buffer: &str, input_format: InputFormat) -> Result<InputSource, CliError> {
|
||||
let buffer = buffer.strip_prefix('\u{feff}').unwrap_or(buffer);
|
||||
match input_format {
|
||||
InputFormat::Jsonl => {
|
||||
let mut content = String::new();
|
||||
for (index, line) in buffer.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(trimmed).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"invalid JSONL path record at line {}: {error}",
|
||||
index + 1
|
||||
))
|
||||
})?;
|
||||
match value {
|
||||
Value::String(line_text) => {
|
||||
let _ = writeln!(content, "{line_text}");
|
||||
}
|
||||
Value::Object(object) => {
|
||||
let line_text = object
|
||||
.get("line")
|
||||
.or_else(|| object.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
CliError::usage(format!(
|
||||
"JSONL log record at line {} must contain a string field named line or message",
|
||||
index + 1
|
||||
))
|
||||
})?;
|
||||
let _ = writeln!(content, "{line_text}");
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(format!(
|
||||
"JSONL log record at line {} must be a string or object with line/message",
|
||||
index + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"stdin JSONL did not contain any log lines".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(InputSource::Text {
|
||||
label: "stdin".to_string(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
InputFormat::Auto | InputFormat::Lines => {
|
||||
match read_existing_stdin_paths(buffer, input_format, "unitydiag") {
|
||||
Ok(Some(paths)) => Ok(InputSource::Paths(paths)),
|
||||
Ok(None) | Err(CliError::Usage(_)) => Ok(InputSource::Text {
|
||||
label: "stdin".to_string(),
|
||||
content: buffer.to_string(),
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_stdin_kind(content: &str) -> UnityLogKind {
|
||||
if content.contains("BepInEx") || content.contains("[Info :") || content.contains("[Error :")
|
||||
{
|
||||
UnityLogKind::Bepinex
|
||||
} else if content.contains("Initialize engine version") || content.contains("Player.log") {
|
||||
UnityLogKind::Player
|
||||
} else {
|
||||
UnityLogKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text(report: &UnityDiagReport, cli: &Cli) -> String {
|
||||
let mut rendered = String::new();
|
||||
let raw_warning_count = report
|
||||
.logs
|
||||
.iter()
|
||||
.map(|log| log.warning_count)
|
||||
.sum::<usize>();
|
||||
let raw_info_count = report.logs.iter().map(|log| log.info_count).sum::<usize>();
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"summary logs={} incidents={} errors={} warnings={} infos={} group_by={}",
|
||||
report.summary.log_count,
|
||||
report.summary.incident_count,
|
||||
report.summary.error_count,
|
||||
report.summary.warning_count,
|
||||
report.summary.info_count,
|
||||
group_by_label(report.summary.group_by)
|
||||
);
|
||||
|
||||
for log in &report.logs {
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"log path={} kind={} events={} included={} errors={} warnings={} infos={}",
|
||||
log.path,
|
||||
log_kind_label(log.kind),
|
||||
log.event_count,
|
||||
log.included_event_count,
|
||||
log.error_count,
|
||||
log.warning_count,
|
||||
log.info_count
|
||||
);
|
||||
}
|
||||
|
||||
if report.incidents.is_empty() {
|
||||
if !cli.include_warnings && raw_warning_count > 0 {
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"hint warnings were present but filtered; rerun with --include-warnings to inspect {raw_warning_count} warning events"
|
||||
);
|
||||
}
|
||||
if !cli.include_info && raw_info_count > 0 {
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"hint infos were present but filtered; rerun with --include-info to inspect {raw_info_count} informational events"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for incident in &report.incidents {
|
||||
let noise = if incident.likely_shutdown_noise {
|
||||
" noise=likely_shutdown"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"count={} severity={} domain={} type={} message={} frame={}{}",
|
||||
incident.count,
|
||||
severity_label(incident.severity),
|
||||
incident.domain,
|
||||
incident.exception_type.as_deref().unwrap_or("-"),
|
||||
incident.message,
|
||||
incident.primary_frame.as_deref().map_or("-", compact_frame),
|
||||
noise
|
||||
);
|
||||
if cli.stack_mode == StackMode::Full {
|
||||
for frame in &incident.stack {
|
||||
let _ = writeln!(rendered, "stack={}", compact_frame(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn compact_frame(frame: &str) -> &str {
|
||||
frame
|
||||
.split(" [")
|
||||
.next()
|
||||
.unwrap_or(frame)
|
||||
.split(" (")
|
||||
.next()
|
||||
.unwrap_or(frame)
|
||||
}
|
||||
|
||||
const fn group_by_label(mode: IncidentGroupMode) -> &'static str {
|
||||
match mode {
|
||||
IncidentGroupMode::Incident => "incident",
|
||||
IncidentGroupMode::Message => "message",
|
||||
IncidentGroupMode::Frame => "frame",
|
||||
}
|
||||
}
|
||||
|
||||
const fn severity_label(severity: unitysupport::UnitySeverity) -> &'static str {
|
||||
match severity {
|
||||
unitysupport::UnitySeverity::Error => "error",
|
||||
unitysupport::UnitySeverity::Warning => "warning",
|
||||
unitysupport::UnitySeverity::Info => "info",
|
||||
}
|
||||
}
|
||||
|
||||
const fn log_kind_label(kind: UnityLogKind) -> &'static str {
|
||||
match kind {
|
||||
UnityLogKind::Player => "player",
|
||||
UnityLogKind::Bepinex => "bepinex",
|
||||
UnityLogKind::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("epoch")
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
|
||||
fs::create_dir_all(&path).expect("temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_supports_top_group_by_and_stack_flags() {
|
||||
let (outcome, cli) = parse_cli_from([
|
||||
"unitydiag",
|
||||
"--json",
|
||||
"--top",
|
||||
"7",
|
||||
"--group-by",
|
||||
"frame",
|
||||
"--stack",
|
||||
"full",
|
||||
"--include-warnings",
|
||||
"--include-info",
|
||||
"Player.log",
|
||||
])
|
||||
.expect("cli");
|
||||
|
||||
assert_eq!(outcome, ParseOutcome::Run);
|
||||
assert!(cli.common.json);
|
||||
assert_eq!(cli.top, 7);
|
||||
assert_eq!(cli.group_by, IncidentGroupMode::Frame);
|
||||
assert_eq!(cli.stack_mode, StackMode::Full);
|
||||
assert!(cli.include_warnings);
|
||||
assert!(cli.include_info);
|
||||
assert_eq!(cli.paths, vec![PathBuf::from("Player.log")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_exposes_help_and_version_outcomes() {
|
||||
let (help_outcome, _) = parse_cli_from(["unitydiag", "--help"]).expect("help");
|
||||
assert_eq!(help_outcome, ParseOutcome::Help);
|
||||
|
||||
let (version_outcome, _) = parse_cli_from(["unitydiag", "-V"]).expect("version");
|
||||
assert_eq!(version_outcome, ParseOutcome::Version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_rejects_invalid_top_value() {
|
||||
let error = parse_cli_from(["unitydiag", "--top", "NaN"]).expect_err("invalid top");
|
||||
let text = error.to_string();
|
||||
assert!(text.contains("invalid --top value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stdin_lines_mode_promotes_existing_paths() {
|
||||
let root = unique_temp_dir("unitydiag-paths");
|
||||
let first = root.join("Player.log");
|
||||
let second = root.join("LogOutput.log");
|
||||
fs::write(&first, "a").expect("first");
|
||||
fs::write(&second, "b").expect("second");
|
||||
|
||||
let input = format!("{}\n{}\n", first.display(), second.display());
|
||||
let parsed = parse_stdin_input(&input, InputFormat::Lines).expect("stdin");
|
||||
let InputSource::Paths(paths) = parsed else {
|
||||
panic!("expected path input");
|
||||
};
|
||||
assert_eq!(paths, vec![second, first]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stdin_auto_mode_keeps_non_path_text() {
|
||||
let parsed = parse_stdin_input("Error: nope\n", InputFormat::Auto).expect("stdin");
|
||||
let InputSource::Text { label, content } = parsed else {
|
||||
panic!("expected text input");
|
||||
};
|
||||
assert_eq!(label, "stdin");
|
||||
assert_eq!(content, "Error: nope\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stdin_strips_utf8_bom_before_text_and_jsonl_modes() {
|
||||
let parsed = parse_stdin_input("\u{feff}Error: nope\n", InputFormat::Auto).expect("stdin");
|
||||
let InputSource::Text { content, .. } = parsed else {
|
||||
panic!("expected text input");
|
||||
};
|
||||
assert_eq!(content, "Error: nope\n");
|
||||
|
||||
let parsed =
|
||||
parse_stdin_input("\u{feff}\"line one\"\n", InputFormat::Jsonl).expect("jsonl");
|
||||
let InputSource::Text { content, .. } = parsed else {
|
||||
panic!("expected text input");
|
||||
};
|
||||
assert_eq!(content, "line one\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stdin_jsonl_supports_string_and_object_lines() {
|
||||
let buffer = "\"line one\"\n{\"line\":\"line two\"}\n{\"message\":\"line three\"}\n";
|
||||
let parsed = parse_stdin_input(buffer, InputFormat::Jsonl).expect("jsonl");
|
||||
let InputSource::Text { label, content } = parsed else {
|
||||
panic!("expected text");
|
||||
};
|
||||
assert_eq!(label, "stdin");
|
||||
assert_eq!(content, "line one\nline two\nline three\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stdin_jsonl_rejects_invalid_shape() {
|
||||
let error = parse_stdin_input("{\"x\":1}\n", InputFormat::Jsonl)
|
||||
.err()
|
||||
.expect("jsonl error");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("must contain a string field named line or message")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_stdin_kind_recognizes_bepinex_player_and_other() {
|
||||
assert_eq!(
|
||||
infer_stdin_kind("[Info : BepInEx] loaded"),
|
||||
UnityLogKind::Bepinex
|
||||
);
|
||||
assert_eq!(
|
||||
infer_stdin_kind("Initialize engine version: 2021.3.0f1"),
|
||||
UnityLogKind::Player
|
||||
);
|
||||
assert_eq!(infer_stdin_kind("plain text"), UnityLogKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_frame_strips_location_suffixes() {
|
||||
assert_eq!(
|
||||
compact_frame("Game.AI.Tick() [0x00001] in file.cs:1"),
|
||||
"Game.AI.Tick()"
|
||||
);
|
||||
assert_eq!(
|
||||
compact_frame("Game.AI.Tick() (at Assets/Scripts/Tick.cs:7)"),
|
||||
"Game.AI.Tick()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_text_includes_stack_lines_in_full_mode() {
|
||||
let report = UnityDiagReport {
|
||||
logs: vec![unitysupport::UnityLogSummary {
|
||||
path: "stdin".to_string(),
|
||||
kind: UnityLogKind::Other,
|
||||
event_count: 1,
|
||||
included_event_count: 1,
|
||||
error_count: 1,
|
||||
warning_count: 0,
|
||||
info_count: 0,
|
||||
}],
|
||||
incidents: vec![unitysupport::UnityIncident {
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
count: 1,
|
||||
severity: unitysupport::UnitySeverity::Error,
|
||||
domain: "game".to_string(),
|
||||
exception_type: Some("ArgumentNullException".to_string()),
|
||||
message: "boom".to_string(),
|
||||
normalized_message: "boom".to_string(),
|
||||
primary_frame: Some("Game.AI.Tick()".to_string()),
|
||||
stack: vec![
|
||||
"Game.AI.Tick()".to_string(),
|
||||
"System.Threading.Task.Run()".to_string(),
|
||||
],
|
||||
channels: vec!["Default".to_string()],
|
||||
paths: vec!["stdin".to_string()],
|
||||
first_path: "stdin".to_string(),
|
||||
first_line: 1,
|
||||
likely_shutdown_noise: false,
|
||||
}],
|
||||
summary: unitysupport::UnityDiagSummary {
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
log_count: 1,
|
||||
event_count: 1,
|
||||
incident_count: 1,
|
||||
error_count: 1,
|
||||
warning_count: 0,
|
||||
info_count: 0,
|
||||
domain_counts: vec![unitysupport::CountEntry {
|
||||
name: "game".to_string(),
|
||||
count: 1,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
game_root: None,
|
||||
latest: false,
|
||||
top: 20,
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
stack_mode: StackMode::Full,
|
||||
include_warnings: false,
|
||||
include_info: false,
|
||||
paths: Vec::new(),
|
||||
};
|
||||
|
||||
let rendered = render_text(&report, &cli);
|
||||
assert!(rendered.contains("summary logs=1 incidents=1"));
|
||||
assert!(rendered.contains("count=1 severity=error domain=game"));
|
||||
assert!(rendered.contains("stack=Game.AI.Tick()"));
|
||||
assert!(rendered.contains("stack=System.Threading.Task.Run()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_text_hints_when_only_filtered_warnings_or_infos_exist() {
|
||||
let report = UnityDiagReport {
|
||||
logs: vec![unitysupport::UnityLogSummary {
|
||||
path: "stdin".to_string(),
|
||||
kind: UnityLogKind::Bepinex,
|
||||
event_count: 3,
|
||||
included_event_count: 0,
|
||||
error_count: 0,
|
||||
warning_count: 1,
|
||||
info_count: 2,
|
||||
}],
|
||||
incidents: Vec::new(),
|
||||
summary: unitysupport::UnityDiagSummary {
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
log_count: 1,
|
||||
event_count: 0,
|
||||
incident_count: 0,
|
||||
error_count: 0,
|
||||
warning_count: 0,
|
||||
info_count: 0,
|
||||
domain_counts: Vec::new(),
|
||||
},
|
||||
};
|
||||
let cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
game_root: None,
|
||||
latest: false,
|
||||
top: 20,
|
||||
group_by: IncidentGroupMode::Incident,
|
||||
stack_mode: StackMode::Top,
|
||||
include_warnings: false,
|
||||
include_info: false,
|
||||
paths: Vec::new(),
|
||||
};
|
||||
|
||||
let rendered = render_text(&report, &cli);
|
||||
assert!(rendered.contains("rerun with --include-warnings"));
|
||||
assert!(rendered.contains("rerun with --include-info"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Public entry point for the `unitydiag` command crate.
|
||||
#![allow(clippy::multiple_crate_versions)]
|
||||
|
||||
mod cli;
|
||||
|
||||
pub use cli::main_entry;
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Binary entry point for `unitydiag`.
|
||||
#![allow(clippy::multiple_crate_versions)]
|
||||
|
||||
fn main() {
|
||||
std::process::exit(unitydiag::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Message: BepInEx] BepInEx 5.4.23.5 - Solar Expanse (2026/4/19 11:22:45)
|
||||
[Info :Solar Expanse Trainer] Pipe client connected (1).
|
||||
[Info :Solar Expanse Trainer] Pipe client connected (2).
|
||||
[Warning: HarmonyX] AccessTools.Method: Could not find method for type Game.UI.Screen.
|
||||
[Error :Solar Expanse Trainer] Pipe server loop failed: System.Threading.ThreadAbortException: Thread was being aborted.
|
||||
at SolarExpanseTrainer.SolarExpanseTrainerPlugin.HandleCommand (SolarExpanseTrainer.PipeCommand command) [0x000b8] in <414166dfdcae444f95d8ea05ca6715f0>:0
|
||||
at SolarExpanseTrainer.SolarExpanseTrainerPlugin.PipeServerMain () [0x0007b] in <414166dfdcae444f95d8ea05ca6715f0>:0
|
||||
[Error : BepInEx] Chainloader startup failed: System.MissingMethodException: Method not found: 'Void Demo.Run(Int32)'.
|
||||
at BepInEx.Bootstrap.Chainloader.Start () [0x0007b] in <414166dfdcae444f95d8ea05ca6715f0>:0
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
Initialize engine version: 2022.3.22f1 (887be4894c44)
|
||||
ArgumentNullException: Value cannot be null.
|
||||
Parameter name: source
|
||||
at Game.AI.ContractHasObjective.OnUpdate () [0x0000d] in <70471b9615aa4ecfa3ed1abb95b73832>:0
|
||||
at UnityEngine.MonoBehaviour:Update()
|
||||
ArgumentNullException: Value cannot be null.
|
||||
Parameter name: source
|
||||
at Game.AI.ContractHasObjective.OnUpdate () [0x0000d] in <70471b9615aa4ecfa3ed1abb95b73832>:0
|
||||
at UnityEngine.MonoBehaviour:Update()
|
||||
The referenced script on this Behaviour (Game Object 'CycleMissionEditWindow') is missing!
|
||||
A formatter has been created for the UnityEngine.Object type Sprite - this is *strongly* discouraged.
|
||||
|
||||
Stacktrace: at Sirenix.Serialization.BaseFormatter`1[T]..cctor () [0x00000] in <c90407e701154d5ea8a975ee4e10d3a4>:0
|
||||
at System.Object.__icall_wrapper_mono_generic_class_init (System.IntPtr) [0x00000] in <27124aa0e30a41659b903b822b959bc7>:0
|
||||
Amplitude Event Sent Successfully
|
||||
Amplitude Event Sent Successfully
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Integration tests for the `unitydiag` command.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use filetime::{FileTime, set_file_mtime};
|
||||
use predicates::prelude::*;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("unitydiag").expect("binary")
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn fixture(path: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(path)
|
||||
}
|
||||
|
||||
fn create_temp_dir(label: &str) -> PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_nanos();
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("unitydiag-{label}-{}-{unique}", std::process::id()));
|
||||
fs::create_dir_all(&path).expect("temp dir");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_fixture(target: &Path, source_name: &str) {
|
||||
let content = fs::read(fixture(source_name)).expect("read fixture");
|
||||
fs::write(target, content).expect("write fixture");
|
||||
}
|
||||
|
||||
fn set_mtime(target: &Path, unix_seconds: i64) {
|
||||
set_file_mtime(target, FileTime::from_unix_time(unix_seconds, 0)).expect("set file mtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_grouped_text_output_by_incident() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg(fixture("player.log"))
|
||||
.arg(fixture("LogOutput.log"))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("summary logs=2"))
|
||||
.stdout(predicate::str::contains("domain=game"))
|
||||
.stdout(predicate::str::contains("domain=mod"))
|
||||
.stdout(predicate::str::contains("domain=bepinex"))
|
||||
.stdout(predicate::str::contains("count=2"))
|
||||
.stdout(predicate::str::contains(
|
||||
"message=ArgumentNullException: Value cannot be null. | Parameter name: source",
|
||||
))
|
||||
.stdout(predicate::str::contains(
|
||||
"frame=Game.AI.ContractHasObjective.OnUpdate",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_stable_json_object_with_logs_incidents_and_summary() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--json")
|
||||
.arg("--include-warnings")
|
||||
.arg("--include-info")
|
||||
.arg("--stack")
|
||||
.arg("top")
|
||||
.arg(fixture("player.log"))
|
||||
.arg(fixture("LogOutput.log"))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"logs\":["))
|
||||
.stdout(predicate::str::contains("\"incidents\":["))
|
||||
.stdout(predicate::str::contains("\"summary\":{"))
|
||||
.stdout(predicate::str::contains("\"group_by\":\"incident\""))
|
||||
.stdout(predicate::str::contains("\"domain\":\"telemetry\""))
|
||||
.stdout(predicate::str::contains("\"domain\":\"harmony\""))
|
||||
.stdout(predicate::str::contains("\"kind\":\"player\""))
|
||||
.stdout(predicate::str::contains("\"kind\":\"bepinex\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_powershell_pipeline_input() {
|
||||
let binary = assert_cmd::cargo::cargo_bin("unitydiag");
|
||||
let input = fixture("player.log");
|
||||
let script = format!(
|
||||
"Get-Content '{}' | & '{}' --include-warnings --json",
|
||||
input.display(),
|
||||
binary.display()
|
||||
);
|
||||
|
||||
let mut command = Command::new("pwsh");
|
||||
command
|
||||
.current_dir(workspace_root())
|
||||
.arg("-NoProfile")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"incidents\":["))
|
||||
.stdout(predicate::str::contains("\"domain\":\"game\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_logs_from_game_root_and_latest() {
|
||||
let root = create_temp_dir("discover");
|
||||
let game_root = root.join("Solar Expanse");
|
||||
let bepinex_dir = game_root.join("BepInEx");
|
||||
let locallow_dir = root
|
||||
.join("AppData")
|
||||
.join("LocalLow")
|
||||
.join("SpaceOps")
|
||||
.join("Solar Expanse");
|
||||
fs::create_dir_all(&bepinex_dir).expect("bepinex dir");
|
||||
fs::create_dir_all(&locallow_dir).expect("locallow dir");
|
||||
|
||||
write_fixture(&locallow_dir.join("Player.log"), "player.log");
|
||||
write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log");
|
||||
set_mtime(&locallow_dir.join("Player.log"), 1_700_000_000);
|
||||
set_mtime(&bepinex_dir.join("LogOutput.log"), 1_700_000_002);
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.env("USERPROFILE", &root)
|
||||
.arg("--json")
|
||||
.arg("--include-info")
|
||||
.arg("--game-root")
|
||||
.arg(&game_root)
|
||||
.arg("--latest")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"logs\":["))
|
||||
.stdout(predicate::str::contains("LogOutput.log"))
|
||||
.stdout(predicate::str::contains("\"log_count\":1"));
|
||||
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_root_without_latest_includes_bepinex_and_matching_player_log() {
|
||||
let root = create_temp_dir("discover-both");
|
||||
let game_root = root.join("Solar Expanse");
|
||||
let bepinex_dir = game_root.join("BepInEx");
|
||||
let locallow_dir = root
|
||||
.join("AppData")
|
||||
.join("LocalLow")
|
||||
.join("SpaceOps")
|
||||
.join("Solar Expanse");
|
||||
fs::create_dir_all(&bepinex_dir).expect("bepinex dir");
|
||||
fs::create_dir_all(&locallow_dir).expect("locallow dir");
|
||||
|
||||
write_fixture(&locallow_dir.join("Player.log"), "player.log");
|
||||
write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log");
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.env("USERPROFILE", &root)
|
||||
.arg("--json")
|
||||
.arg("--include-info")
|
||||
.arg("--game-root")
|
||||
.arg(&game_root)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"log_count\":2"))
|
||||
.stdout(predicate::str::contains("LogOutput.log"))
|
||||
.stdout(predicate::str::contains("Player.log"));
|
||||
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_logs_from_relative_dot_game_root() {
|
||||
let root = create_temp_dir("discover-dot");
|
||||
let game_root = root.join("Solar Expanse");
|
||||
let locallow_root = root.join("AppData").join("LocalLow");
|
||||
let target_locallow = locallow_root.join("SpaceOps").join("Solar Expanse");
|
||||
let unrelated_locallow = locallow_root.join("OtherStudio").join("Another Game");
|
||||
fs::create_dir_all(&game_root).expect("game root");
|
||||
fs::create_dir_all(&target_locallow).expect("target locallow");
|
||||
fs::create_dir_all(&unrelated_locallow).expect("unrelated locallow");
|
||||
|
||||
write_fixture(&target_locallow.join("Player.log"), "player.log");
|
||||
write_fixture(&unrelated_locallow.join("Player.log"), "player.log");
|
||||
set_mtime(&target_locallow.join("Player.log"), 1_700_000_000);
|
||||
set_mtime(&unrelated_locallow.join("Player.log"), 1_700_000_002);
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.current_dir(&game_root)
|
||||
.env("USERPROFILE", &root)
|
||||
.arg("--json")
|
||||
.arg("--include-info")
|
||||
.arg("--game-root")
|
||||
.arg(".")
|
||||
.arg("--latest")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"logs\":["))
|
||||
.stdout(predicate::str::contains("SpaceOps"))
|
||||
.stdout(predicate::str::contains("Solar Expanse"))
|
||||
.stdout(predicate::str::contains("\"log_count\":1"))
|
||||
.stdout(predicate::str::contains("\"kind\":\"player\""));
|
||||
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_includes_examples_and_grouping_flags() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--group-by"))
|
||||
.stdout(predicate::str::contains("--latest"))
|
||||
.stdout(predicate::str::contains("ConvertFrom-Json"))
|
||||
.stdout(predicate::str::contains(
|
||||
"unitydiag --game-root 'C:\\game' --latest",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_log_file_is_parsed_without_losing_incidents() {
|
||||
let root = create_temp_dir("bom");
|
||||
let log_path = root.join("Player.log");
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend(fs::read(fixture("player.log")).expect("fixture"));
|
||||
fs::write(&log_path, bytes).expect("bom fixture");
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--json")
|
||||
.arg(&log_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"log_count\":1"))
|
||||
.stdout(predicate::str::contains("\"domain\":\"game\""));
|
||||
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_log_file_reports_read_error_instead_of_summarizing() {
|
||||
let root = create_temp_dir("invalid-utf8");
|
||||
let log_path = root.join("Player.log");
|
||||
fs::write(&log_path, b"Initialize engine version\n\xff\xfe\xfd\n").expect("invalid fixture");
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg(&log_path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("failed to read"))
|
||||
.stderr(predicate::str::contains("valid UTF-8"));
|
||||
|
||||
fs::remove_dir_all(&root).expect("cleanup");
|
||||
}
|
||||
Reference in New Issue
Block a user