Files
MercuryToolbox/crates/unityprobe/src/cli.rs
T

1160 lines
39 KiB
Rust

//! The `unityprobe` command talks to a read-only Unity bridge.
#![allow(clippy::multiple_crate_versions)]
use std::cmp::Ordering;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::path::PathBuf;
use common::{
CliError, CommonArgs, ExitCode, RenderMode, expand_input_patterns, map_result_count,
parse_color_choice, parse_format_choice, print_json, print_quick_help_error, print_structured,
print_text, require_exactly_one_input_path,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use unitysupport::probe::ObjectRecord;
use unitysupport::{
BRIDGE_PIPE_NAME, FindData, InspectData, InstallReport, ScenesData, StaticData, StatusReport,
UninstallReport, find_objects, inspect_object, inspect_static, install_bridge, query_scenes,
status_bridge, uninstall_bridge,
};
const HELP: &str = "\
Inspect a running BepInEx Mono Unity game through an explicit read-only bridge.
Windows only: install the bridge into BepInEx\\plugins, then query it over a named pipe JSON protocol.
No implicit injection is performed.
Usage:
unityprobe [OPTIONS] <SUBCOMMAND> [ARGS...]
Subcommands:
install <PATH> Compile the shared bridge source with local csc.exe and install it into BepInEx\\plugins
uninstall <PATH> Remove the explicit bridge install from BepInEx\\plugins
status Report install state and named pipe status
scenes List loaded scenes from the running bridge
find <QUERY> Find runtime objects by type or name
inspect <INSTANCE_ID> Inspect one runtime object by Unity instance id
static <TYPE_NAME> Inspect static fields and properties for a managed type
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
-h, --help Show this help text
-V, --version Show the command version
Subcommand options:
status --game-root <PATH> Inspect explicit install state for one game root
find --limit <COUNT> Maximum runtime matches to return (default: 25)
find --type-only Keep only managed type matches and rank type hits ahead of name hits
Examples:
unityprobe install 'C:\\game'
unityprobe status --game-root 'C:\\game'
unityprobe --json scenes | ConvertFrom-Json
unityprobe --json find 'Game\\.UI\\.Windows' | ConvertFrom-Json
unityprobe find GameManager --type-only
unityprobe inspect 10432
unityprobe inspect -- -938
unityprobe static Manager.GameManager
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
command: Command,
}
#[derive(Debug, Clone)]
enum Command {
Install {
game_root: PathBuf,
},
Uninstall {
game_root: PathBuf,
},
Status {
game_root: Option<PathBuf>,
},
Scenes,
Find {
query: String,
limit: usize,
type_only: bool,
},
Inspect {
instance_id: i32,
},
Static {
type_name: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
/// 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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("unityprobe {}", 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 common = CommonArgs::default();
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 {
common,
command: Command::Status { game_root: None },
},
));
}
Long("version") | Short('V') => {
return Ok((
ParseOutcome::Version,
Cli {
common,
command: Command::Status { game_root: None },
},
));
}
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
ArgValue(value) => {
let subcommand = os_string_to_string(value, "subcommand")?;
let command = parse_subcommand(&subcommand, &mut parser, &mut common)?;
return Ok((ParseOutcome::Run, Cli { common, command }));
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Err(CliError::usage(
"unityprobe requires a subcommand; use --help to see available options",
))
}
fn parse_subcommand(
subcommand: &str,
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
match subcommand {
"install" => parse_game_root_command(parser, common, "install")
.map(|game_root| Command::Install { game_root }),
"uninstall" => parse_game_root_command(parser, common, "uninstall")
.map(|game_root| Command::Uninstall { game_root }),
"status" => parse_status_command(parser, common),
"scenes" => parse_scenes_command(parser, common),
"find" => parse_find_command(parser, common),
"inspect" => parse_inspect_command(parser, common),
"static" => parse_static_command(parser, common),
_ => Err(CliError::usage(
"unsupported subcommand; use --help to see available options",
)),
}
}
fn parse_game_root_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
name: &str,
) -> Result<PathBuf, CliError> {
let mut game_root = None;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("game-root") => {
game_root = Some(parser_value_path(parser, "--game-root")?);
}
ArgValue(value) => {
if game_root.is_some() {
return Err(CliError::usage(format!(
"{name} accepts only one game root path"
)));
}
game_root = Some(PathBuf::from(value));
}
_ => {
return Err(CliError::usage(format!(
"unsupported {name} argument; use --help to see available options"
)));
}
}
}
game_root.ok_or_else(|| CliError::usage(format!("{name} requires a game root path")))
}
fn parse_status_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
let mut game_root = None;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("game-root") => game_root = Some(parser_value_path(parser, "--game-root")?),
_ => {
return Err(CliError::usage(
"unsupported status argument; use --help to see available options",
));
}
}
}
Ok(Command::Status { game_root })
}
fn parse_scenes_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
_ => {
return Err(CliError::usage(
"scenes does not accept positional arguments",
));
}
}
}
Ok(Command::Scenes)
}
fn parse_find_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
let mut query = None;
let mut limit = 25_usize;
let mut type_only = false;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("limit") => {
limit = parse_usize_flag("--limit", &parser_value_string(parser, "--limit")?)?;
}
Long("type-only") => type_only = true,
ArgValue(value) => {
if query.is_some() {
return Err(CliError::usage("find accepts only one query string"));
}
query = Some(os_string_to_string(value, "find query")?);
}
_ => {
return Err(CliError::usage(
"unsupported find argument; use --help to see available options",
));
}
}
}
Ok(Command::Find {
query: query.ok_or_else(|| CliError::usage("find requires a query string"))?,
limit,
type_only,
})
}
fn parse_inspect_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
let mut instance_id = None;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
ArgValue(value) => {
if instance_id.is_some() {
return Err(CliError::usage("inspect accepts only one instance id"));
}
instance_id = Some(os_string_to_string(value, "inspect instance id")?);
}
_ => {
return Err(CliError::usage(
"unsupported inspect argument; use --help to see available options",
));
}
}
}
let text = instance_id.ok_or_else(|| CliError::usage("inspect requires an instance id"))?;
Ok(Command::Inspect {
instance_id: text.parse::<i32>().map_err(|error| {
CliError::usage(format!("inspect expects an integer instance id: {error}"))
})?,
})
}
fn parse_static_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<Command, CliError> {
let mut type_name = None;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
ArgValue(value) => {
if type_name.is_some() {
return Err(CliError::usage("static accepts only one type name"));
}
type_name = Some(os_string_to_string(value, "static type name")?);
}
_ => {
return Err(CliError::usage(
"unsupported static argument; use --help to see available options",
));
}
}
}
Ok(Command::Static {
type_name: type_name
.ok_or_else(|| CliError::usage("static requires a managed type name"))?,
})
}
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()))?;
os_string_to_string(value, flag)
}
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 {
require_exactly_one_input_path(
&expand_input_patterns(&[PathBuf::from(value)], "unityprobe")?,
"unityprobe",
)
}
}
fn os_string_to_string(value: OsString, label: &str) -> Result<String, CliError> {
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{label} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
let parsed = value.parse::<usize>().map_err(|error| {
CliError::usage(format!(
"{flag} expects a positive integer, got '{value}': {error}"
))
})?;
if parsed == 0 {
return Err(CliError::usage(format!(
"{flag} expects a positive integer, got '{value}'"
)));
}
Ok(parsed)
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
match &cli.command {
Command::Install { game_root } => {
let report = install_bridge(game_root)?;
emit_report(
cli.common.render_mode(),
&report,
render_install_text(&report),
)?;
Ok(ExitCode::Success)
}
Command::Uninstall { game_root } => {
let report = uninstall_bridge(game_root)?;
emit_report(
cli.common.render_mode(),
&report,
render_uninstall_text(&report),
)?;
Ok(ExitCode::Success)
}
Command::Status { game_root } => {
let report = status_bridge(game_root.as_deref())?;
emit_report(
cli.common.render_mode(),
&report,
render_status_text(&report),
)?;
Ok(ExitCode::Success)
}
Command::Scenes => {
let response = query_scenes()?;
let data = require_data(response, "scenes")?;
emit_report(cli.common.render_mode(), &data, render_scenes_text(&data))?;
Ok(map_result_count(data.scenes.len()))
}
Command::Find {
query,
limit,
type_only,
} => {
let response = find_objects(query, expanded_probe_limit(*limit, *type_only))?;
let data = rerank_find_data(require_data(response, "find")?, *limit, *type_only);
emit_report(cli.common.render_mode(), &data, render_find_text(&data))?;
Ok(map_result_count(data.matches.len()))
}
Command::Inspect { instance_id } => {
let response = inspect_object(*instance_id)?;
let data = require_data(response, "inspect")?;
emit_report(cli.common.render_mode(), &data, render_inspect_text(&data))?;
Ok(ExitCode::Success)
}
Command::Static { type_name } => {
let response = inspect_static(type_name)?;
let data = require_data(response, "static")?;
emit_report(cli.common.render_mode(), &data, render_static_text(&data))?;
Ok(ExitCode::Success)
}
}
}
fn require_data<T>(envelope: unitysupport::ProbeEnvelope<T>, kind: &str) -> Result<T, CliError> {
envelope.data.ok_or_else(|| {
CliError::runtime(format!(
"{kind} returned an empty bridge payload on pipe {BRIDGE_PIPE_NAME}"
))
})
}
fn expanded_probe_limit(limit: usize, type_only: bool) -> usize {
let safe_limit = limit.max(1);
let multiplier = if type_only { 8 } else { 4 };
safe_limit.saturating_mul(multiplier).clamp(safe_limit, 256)
}
fn rerank_find_data(mut data: FindData, limit: usize, type_only: bool) -> FindData {
let normalized_query = normalize_find_query(&data.query);
data.matches
.sort_by(|left, right| compare_find_matches(left, right, &normalized_query));
if type_only {
data.matches
.retain(|item| find_type_match_score(item, &normalized_query) > 0);
}
data.matches.truncate(limit);
data
}
fn compare_find_matches(
left: &ObjectRecord,
right: &ObjectRecord,
normalized_query: &str,
) -> Ordering {
let left_type_score = find_type_match_score(left, normalized_query);
let right_type_score = find_type_match_score(right, normalized_query);
let left_name_score = find_name_match_score(left, normalized_query);
let right_name_score = find_name_match_score(right, normalized_query);
right_type_score
.cmp(&left_type_score)
.then_with(|| right_name_score.cmp(&left_name_score))
.then_with(|| {
right_name_score
.max(right_type_score)
.cmp(&left_name_score.max(left_type_score))
})
.then_with(|| left.type_name.cmp(&right.type_name))
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.hierarchy_path.cmp(&right.hierarchy_path))
.then_with(|| left.instance_id.cmp(&right.instance_id))
}
fn find_type_match_score(item: &ObjectRecord, normalized_query: &str) -> usize {
let full_type = item.type_name.to_ascii_lowercase();
let type_tail = full_type.rsplit('.').next().unwrap_or(full_type.as_str());
score_text_candidate(&full_type, type_tail, normalized_query, 600, 540, 480)
}
fn find_name_match_score(item: &ObjectRecord, normalized_query: &str) -> usize {
let name = item.name.to_ascii_lowercase();
let hierarchy_tail = item
.hierarchy_path
.rsplit('/')
.find(|segment: &&str| !segment.is_empty())
.map_or_else(String::new, |segment: &str| segment.to_ascii_lowercase());
score_text_candidate(&name, &hierarchy_tail, normalized_query, 320, 260, 180)
}
fn score_text_candidate(
full_text: &str,
tail_text: &str,
normalized_query: &str,
exact_full: usize,
exact_tail: usize,
contains_score: usize,
) -> usize {
if full_text == normalized_query {
exact_full
} else if tail_text == normalized_query {
exact_tail
} else if full_text.contains(normalized_query) || tail_text.contains(normalized_query) {
contains_score
} else {
0
}
}
fn normalize_find_query(query: &str) -> String {
query.trim().to_ascii_lowercase()
}
fn emit_report<T>(mode: RenderMode, value: &T, text: String) -> Result<(), CliError>
where
T: Serialize,
{
match mode {
RenderMode::Json => print_json(value)?,
RenderMode::Toon => print_structured(value, RenderMode::Toon)?,
RenderMode::Text => print_text(text)?,
}
Ok(())
}
fn render_install_text(report: &InstallReport) -> String {
format!(
"installed game_root={} plugin_dir={} dll={} pipe={} compiler={} refs={}",
report.game_root,
report.plugin_dir,
report.dll_path,
report.pipe_name,
report.compiler_path,
report.reference_paths.len()
)
}
fn render_uninstall_text(report: &UninstallReport) -> String {
format!(
"uninstall game_root={} plugin_dir={} removed={}",
report.game_root, report.plugin_dir, report.removed
)
}
fn render_status_text(report: &StatusReport) -> String {
let mut text = String::new();
write!(
text,
"status windows_supported={} installed={} pipe={} pipe_reachable={}",
report.windows_supported, report.installed, report.pipe_name, report.pipe_reachable
)
.expect("writing to a String cannot fail");
if let Some(game_root) = &report.game_root {
write!(text, " game_root={game_root}").expect("writing to a String cannot fail");
}
if let Some(running) = report.game_process_running {
write!(text, " game_process_running={running}").expect("writing to a String cannot fail");
}
if let Some(name) = &report.game_process_name {
write!(text, " game_process={name}").expect("writing to a String cannot fail");
}
if let Some(pid) = report.game_process_id {
write!(text, " game_pid={pid}").expect("writing to a String cannot fail");
}
if let Some(runtime) = &report.runtime_status {
write!(
text,
" unity={} pid={} scenes={}",
runtime.unity_version, runtime.process_id, runtime.scene_count
)
.expect("writing to a String cannot fail");
}
if !report.problems.is_empty() {
write!(text, " problems={}", report.problems.join(" | "))
.expect("writing to a String cannot fail");
}
text
}
fn render_scenes_text(data: &ScenesData) -> String {
if data.scenes.is_empty() {
return "scenes count=0".to_string();
}
let mut text = String::new();
for (index, scene) in data.scenes.iter().enumerate() {
if index > 0 {
text.push('\n');
}
write!(
text,
"scene name={} build_index={} loaded={} roots={} path={}",
scene.name, scene.build_index, scene.loaded, scene.root_count, scene.path
)
.expect("writing to a String cannot fail");
}
text
}
fn render_find_text(data: &FindData) -> String {
if data.matches.is_empty() {
return format!("find query={} matches=0", data.query);
}
let mut text = format!("find query={} matches={}", data.query, data.matches.len());
for item in &data.matches {
write!(
text,
"\nobject id={} type={} name={} scene={} path={} active={}",
item.instance_id,
item.type_name,
item.name,
item.scene_name,
item.hierarchy_path,
item.active
)
.expect("writing to a String cannot fail");
}
text
}
fn render_inspect_text(data: &InspectData) -> String {
let mut text = format!(
"inspect id={} type={} name={} scene={} path={} active={} components={} fields={} properties={}",
data.object.instance_id,
data.object.type_name,
data.object.name,
data.object.scene_name,
data.object.hierarchy_path,
data.object.active,
data.components.len(),
data.fields.len(),
data.properties.len()
);
for component in &data.components {
write!(
text,
"\ncomponent id={} type={}",
component.instance_id, component.type_name
)
.expect("writing to a String cannot fail");
}
for field in &data.fields {
write!(
text,
"\nfield name={} type={} value={}",
field.name, field.declared_type, field.value
)
.expect("writing to a String cannot fail");
}
for property in &data.properties {
write!(
text,
"\nproperty name={} type={} value={}",
property.name, property.declared_type, property.value
)
.expect("writing to a String cannot fail");
}
text
}
fn render_static_text(data: &StaticData) -> String {
let mut text = format!(
"static type={} assembly={} fields={} properties={}",
data.type_name,
data.assembly_name,
data.fields.len(),
data.properties.len()
);
for field in &data.fields {
write!(
text,
"\nfield name={} type={} value={}",
field.name, field.declared_type, field.value
)
.expect("writing to a String cannot fail");
}
for property in &data.properties {
write!(
text,
"\nproperty name={} type={} value={}",
property.name, property.declared_type, property.value
)
.expect("writing to a String cannot fail");
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
fn sample_find_data() -> FindData {
serde_json::from_value(json!({
"query": "GameManager",
"matches": [
{
"instance_id": 42,
"name": "GameManager",
"type_name": "Game.Core.GameManager",
"scene_name": "Main",
"hierarchy_path": "/Root/GameManager",
"active": true
}
]
}))
.expect("find fixture")
}
fn sample_inspect_data() -> InspectData {
serde_json::from_value(json!({
"object": {
"instance_id": 42,
"name": "GameManager",
"type_name": "Game.Core.GameManager",
"scene_name": "Main",
"hierarchy_path": "/Root/GameManager",
"active": true
},
"components": [
{
"instance_id": 77,
"type_name": "UnityEngine.Transform"
}
],
"fields": [
{
"name": "state",
"declared_type": "System.String",
"value": "Ready"
}
],
"properties": [
{
"name": "Enabled",
"declared_type": "System.Boolean",
"value": "True"
}
]
}))
.expect("inspect fixture")
}
fn sample_static_data() -> StaticData {
serde_json::from_value(json!({
"type_name": "Game.Core.Globals",
"assembly_name": "GameAssembly",
"fields": [
{
"name": "Build",
"declared_type": "System.Int32",
"value": "42"
}
],
"properties": [
{
"name": "Version",
"declared_type": "System.String",
"value": "1.2.3"
}
]
}))
.expect("static fixture")
}
#[test]
fn parse_find_supports_limit() {
let (_, cli) = parse_cli_from(["unityprobe", "--json", "find", "--limit", "8", "Player"])
.expect("cli");
assert!(cli.common.json);
let Command::Find {
query,
limit,
type_only,
} = cli.command
else {
panic!("expected find command");
};
assert_eq!(query, "Player");
assert_eq!(limit, 8);
assert!(!type_only);
}
#[test]
fn parse_find_supports_type_only() {
let (_, cli) =
parse_cli_from(["unityprobe", "find", "--type-only", "GameManager"]).expect("cli");
let Command::Find {
query,
limit,
type_only,
} = cli.command
else {
panic!("expected find command");
};
assert_eq!(query, "GameManager");
assert_eq!(limit, 25);
assert!(type_only);
}
#[test]
fn parse_status_supports_optional_game_root() {
let (_, cli) =
parse_cli_from(["unityprobe", "status", "--game-root", "C:\\Games\\Mercury"])
.expect("cli");
let Command::Status { game_root } = cli.command else {
panic!("expected status command");
};
assert_eq!(game_root.as_deref(), Some(Path::new("C:\\Games\\Mercury")));
}
#[test]
fn parse_common_flags_after_subcommand() {
let (_, cli) = parse_cli_from([
"unityprobe",
"status",
"--game-root",
"C:\\Games\\Mercury",
"--json",
])
.expect("cli");
assert!(cli.common.json);
}
#[test]
fn help_text_mentions_windows_only_named_pipe_bridge() {
assert!(HELP.contains("Windows only"));
assert!(HELP.contains("named pipe JSON"));
assert!(HELP.contains("BepInEx\\plugins"));
}
#[test]
fn parse_cli_requires_subcommand() {
let error = parse_cli_from(["unityprobe"]).expect_err("missing subcommand");
assert!(error.to_string().contains("requires a subcommand"));
}
#[test]
fn parse_find_rejects_invalid_limit() {
let error =
parse_cli_from(["unityprobe", "find", "--limit", "abc", "Player"]).expect_err("bad");
assert!(
error
.to_string()
.contains("--limit expects a positive integer")
);
}
#[test]
fn parse_inspect_rejects_non_integer_instance_id() {
let error = parse_cli_from(["unityprobe", "inspect", "abc"]).expect_err("bad inspect");
assert!(
error
.to_string()
.contains("inspect expects an integer instance id")
);
}
#[test]
fn parse_static_rejects_duplicate_type_name() {
let error = parse_cli_from(["unityprobe", "static", "Type.One", "Type.Two"])
.expect_err("duplicate static arg");
assert!(
error
.to_string()
.contains("static accepts only one type name")
);
}
#[test]
fn require_data_returns_runtime_error_when_payload_is_missing() {
let envelope: unitysupport::ProbeEnvelope<unitysupport::ScenesData> =
unitysupport::ProbeEnvelope {
ok: true,
kind: "scenes".to_string(),
data: None,
error: None,
};
let error = require_data(envelope, "scenes").expect_err("missing payload");
assert!(
error
.to_string()
.contains("scenes returned an empty bridge payload")
);
}
#[test]
fn require_data_returns_payload_when_present() {
let expected: ScenesData = serde_json::from_value(json!({
"scenes": [
{
"name": "Main",
"path": "Assets/Scenes/Main.unity",
"build_index": 0,
"loaded": true,
"root_count": 10
}
]
}))
.expect("scenes fixture");
let envelope = unitysupport::ProbeEnvelope {
ok: true,
kind: "scenes".to_string(),
data: Some(expected.clone()),
error: None,
};
let data = require_data(envelope, "scenes").expect("payload");
assert_eq!(data, expected);
}
#[test]
fn render_status_text_includes_optional_runtime_and_problems() {
let report = StatusReport {
windows_supported: true,
game_root: Some("C:\\Game".to_string()),
plugin_dir: Some("C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe".to_string()),
dll_path: Some(
"C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\Mercury.UnityProbe.dll"
.to_string(),
),
manifest_path: Some(
"C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\mercury-unityprobe.install.json"
.to_string(),
),
installed: true,
game_process_running: Some(true),
game_process_id: Some(1234),
game_process_name: Some("Solar Expanse.exe".to_string()),
pipe_name: BRIDGE_PIPE_NAME.to_string(),
pipe_reachable: true,
runtime_status: Some(
serde_json::from_value(json!({
"pipe_name": BRIDGE_PIPE_NAME,
"plugin_version": "1.0.0",
"unity_version": "2021.3.0f1",
"process_id": 1234,
"scene_count": 2,
"loaded_scene_names": ["Main", "Gameplay"]
}))
.expect("runtime fixture"),
),
compiler_path: Some(
"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe".to_string(),
),
managed_dir: Some("C:\\Game\\Example_Data\\Managed".to_string()),
problems: vec!["warning one".to_string(), "warning two".to_string()],
};
let text = render_status_text(&report);
assert!(text.contains("status windows_supported=true installed=true"));
assert!(text.contains("game_process_running=true"));
assert!(text.contains("game_process=Solar Expanse.exe"));
assert!(text.contains("unity=2021.3.0f1 pid=1234 scenes=2"));
assert!(text.contains("problems=warning one | warning two"));
}
#[test]
fn render_helpers_emit_expected_empty_and_populated_forms() {
assert_eq!(
render_scenes_text(&unitysupport::ScenesData { scenes: Vec::new() }),
"scenes count=0"
);
assert_eq!(
render_find_text(&unitysupport::FindData {
query: "Manager".to_string(),
matches: Vec::new(),
}),
"find query=Manager matches=0"
);
let scenes: ScenesData = serde_json::from_value(json!({
"scenes": [
{
"name": "Main",
"path": "Assets/Scenes/Main.unity",
"build_index": 0,
"loaded": true,
"root_count": 12
}
]
}))
.expect("scenes fixture");
let find = sample_find_data();
let inspect = sample_inspect_data();
let static_data = sample_static_data();
let scenes_text = render_scenes_text(&scenes);
assert!(scenes_text.contains("scene name=Main"));
let find_text = render_find_text(&find);
assert!(find_text.contains("find query=GameManager matches=1"));
assert!(find_text.contains("object id=42"));
let inspect_text = render_inspect_text(&inspect);
assert!(inspect_text.contains("inspect id=42"));
assert!(inspect_text.contains("component id=77"));
assert!(inspect_text.contains("field name=state"));
assert!(inspect_text.contains("property name=Enabled"));
let static_text = render_static_text(&static_data);
assert!(static_text.contains("static type=Game.Core.Globals assembly=GameAssembly"));
assert!(static_text.contains("field name=Build"));
assert!(static_text.contains("property name=Version"));
}
#[test]
fn rerank_find_data_prefers_type_matches_and_supports_type_only() {
let mixed: FindData = serde_json::from_value(json!({
"query": "GameManager",
"matches": [
{
"instance_id": 7,
"name": "GameManager",
"type_name": "UnityEngine.TextAsset",
"scene_name": "Main",
"hierarchy_path": "/Root/GameManagerAsset",
"active": true
},
{
"instance_id": 42,
"name": "Bootstrap",
"type_name": "Game.Core.GameManager",
"scene_name": "Main",
"hierarchy_path": "/Root/Managers/GameManager",
"active": true
}
]
}))
.expect("find fixture");
let reranked = rerank_find_data(mixed.clone(), 25, false);
assert_eq!(reranked.matches.len(), 2);
assert_eq!(reranked.matches[0].type_name, "Game.Core.GameManager");
let type_only = rerank_find_data(mixed, 25, true);
assert_eq!(type_only.matches.len(), 1);
assert_eq!(type_only.matches[0].type_name, "Game.Core.GameManager");
}
}