chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "unityasset"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect Unity assets, references, and extractable objects with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
unitysupport = { path = "../unitysupport" }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
//! Public entry point for the `unityasset` command crate.
#![allow(clippy::multiple_crate_versions)]
mod cli;
pub use cli::main_entry;
+6
View File
@@ -0,0 +1,6 @@
//! Binary entry point for `unityasset`.
#![allow(clippy::multiple_crate_versions)]
fn main() {
std::process::exit(unityasset::main_entry());
}
+852
View File
@@ -0,0 +1,852 @@
//! Integration tests for the `unityasset` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn cargo_command() -> Command {
Command::cargo_bin("unityasset").expect("binary")
}
const ALIGN_BYTES_FLAG: i32 = 0x4000;
#[derive(Clone, Copy)]
struct PPtrValue {
file_id: i32,
path_id: i64,
}
#[derive(Clone)]
enum FixtureObject {
GameObject {
name: String,
},
TextAsset {
name: String,
script: String,
},
MonoScript {
name: String,
class_name: String,
namespace: String,
assembly_name: String,
},
MonoBehaviour {
name: String,
script: PPtrValue,
text_asset: PPtrValue,
},
StrippedMonoBehaviour {
game_object: PPtrValue,
script: PPtrValue,
extra_refs: Vec<PPtrValue>,
},
}
#[derive(Clone)]
struct FixtureEntry {
path_id: i64,
class_id: i32,
object: FixtureObject,
}
fn unique_temp_dir(label: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let path = std::env::temp_dir().join(format!("unityasset-{label}-{suffix}"));
fs::create_dir_all(&path).expect("temp dir");
path
}
fn to_i32(value: usize) -> i32 {
i32::try_from(value).expect("fixture value fits in i32")
}
fn to_u32(value: usize) -> u32 {
u32::try_from(value).expect("fixture value fits in u32")
}
fn to_i64(value: usize) -> i64 {
i64::try_from(value).expect("fixture value fits in i64")
}
fn standalone_entries() -> Vec<FixtureEntry> {
vec![
FixtureEntry {
path_id: 1001,
class_id: 49,
object: FixtureObject::TextAsset {
name: "BinaryText".to_string(),
script: "Hello from binary text asset\n".to_string(),
},
},
FixtureEntry {
path_id: 1002,
class_id: 115,
object: FixtureObject::MonoScript {
name: "SampleBehaviour".to_string(),
class_name: "SampleBehaviour".to_string(),
namespace: "Mercury.Sample".to_string(),
assembly_name: "Assembly-CSharp".to_string(),
},
},
FixtureEntry {
path_id: 1003,
class_id: 114,
object: FixtureObject::MonoBehaviour {
name: "BinaryBehaviour".to_string(),
script: PPtrValue {
file_id: 0,
path_id: 1002,
},
text_asset: PPtrValue {
file_id: 0,
path_id: 1001,
},
},
},
]
}
fn bundle_entries() -> Vec<FixtureEntry> {
vec![
FixtureEntry {
path_id: 2001,
class_id: 49,
object: FixtureObject::TextAsset {
name: "BundledText".to_string(),
script: "Hello from bundled text asset\n".to_string(),
},
},
FixtureEntry {
path_id: 2002,
class_id: 115,
object: FixtureObject::MonoScript {
name: "BundledBehaviour".to_string(),
class_name: "BundledBehaviour".to_string(),
namespace: "Mercury.Bundle".to_string(),
assembly_name: "Assembly-CSharp".to_string(),
},
},
FixtureEntry {
path_id: 2003,
class_id: 114,
object: FixtureObject::MonoBehaviour {
name: "BundledMonoBehaviour".to_string(),
script: PPtrValue {
file_id: 0,
path_id: 2002,
},
text_asset: PPtrValue {
file_id: 0,
path_id: 2001,
},
},
},
]
}
fn stripped_entries() -> Vec<FixtureEntry> {
vec![
FixtureEntry {
path_id: 29,
class_id: 1,
object: FixtureObject::GameObject {
name: "StrippedWindow".to_string(),
},
},
FixtureEntry {
path_id: 30,
class_id: 1,
object: FixtureObject::GameObject {
name: "LinkedChild".to_string(),
},
},
FixtureEntry {
path_id: 1182,
class_id: 115,
object: FixtureObject::MonoScript {
name: "StrippedWindowBehaviour".to_string(),
class_name: "StrippedWindowBehaviour".to_string(),
namespace: "Mercury.Stripped".to_string(),
assembly_name: "Assembly-CSharp".to_string(),
},
},
FixtureEntry {
path_id: 2000,
class_id: 114,
object: FixtureObject::StrippedMonoBehaviour {
game_object: PPtrValue {
file_id: 0,
path_id: 29,
},
script: PPtrValue {
file_id: 0,
path_id: 1182,
},
extra_refs: vec![PPtrValue {
file_id: 0,
path_id: 30,
}],
},
},
]
}
fn write_fixture_workspace(root: &Path) {
fs::write(
root.join("sample.assets"),
build_serialized_file(&standalone_entries()),
)
.expect("standalone asset");
fs::write(
root.join("sample.bundle"),
build_unityfs_bundle("bundled.assets", &build_serialized_file(&bundle_entries())),
)
.expect("bundle");
fs::write(
root.join("stripped.assets"),
build_serialized_file(&stripped_entries()),
)
.expect("stripped asset");
fs::write(root.join("scene.unity"), sample_yaml_fixture()).expect("yaml fixture");
}
fn sample_yaml_fixture() -> String {
r"--- !u!1 &1000
GameObject:
m_Name: SampleYamlGameObject
m_Component:
- component: {fileID: 2000}
--- !u!114 &2000
MonoBehaviour:
m_Name: SampleYamlBehaviour
m_GameObject: {fileID: 1000}
m_Script: {fileID: 11500000, guid: 1234567890abcdef1234567890abcdef, type: 3}
m_TextAsset: {fileID: 3000}
--- !u!49 &3000
TextAsset:
m_Name: YamlText
m_Script: Hello from YAML text asset
"
.to_string()
}
fn build_serialized_file(entries: &[FixtureEntry]) -> Vec<u8> {
let type_descriptors = entries
.iter()
.map(|entry| (entry.class_id, type_tree_blob_for(&entry.object)))
.collect::<Vec<_>>();
let object_payloads = entries
.iter()
.map(|entry| object_bytes(&entry.object))
.collect::<Vec<_>>();
let mut metadata = Vec::new();
metadata.extend_from_slice(b"2019.4.0f1\0");
metadata.extend_from_slice(&0i32.to_le_bytes());
metadata.push(1u8);
metadata.extend_from_slice(&to_i32(type_descriptors.len()).to_le_bytes());
for (class_id, tree_blob) in &type_descriptors {
metadata.extend_from_slice(&class_id.to_le_bytes());
metadata.push(0u8);
metadata.extend_from_slice(&(-1i16).to_le_bytes());
if *class_id == 114 {
metadata.extend_from_slice(&[0xAB; 16]);
}
metadata.extend_from_slice(&[0xCD; 16]);
metadata.extend_from_slice(tree_blob);
}
metadata.extend_from_slice(&to_i32(entries.len()).to_le_bytes());
let mut relative_offset = 0u32;
for (index, entry) in entries.iter().enumerate() {
while metadata.len() % 4 != 0 {
metadata.push(0u8);
}
metadata.extend_from_slice(&entry.path_id.to_le_bytes());
metadata.extend_from_slice(&relative_offset.to_le_bytes());
metadata.extend_from_slice(&to_u32(object_payloads[index].len()).to_le_bytes());
metadata.extend_from_slice(&to_i32(index).to_le_bytes());
relative_offset = relative_offset
.checked_add(to_u32(object_payloads[index].len()))
.expect("relative offset overflow");
}
metadata.extend_from_slice(&0i32.to_le_bytes());
metadata.extend_from_slice(&0i32.to_le_bytes());
metadata.push(0u8);
let header_size = 20u32;
let data_offset = header_size
.checked_add(to_u32(metadata.len()))
.expect("data offset overflow");
let file_size = data_offset
.checked_add(
object_payloads
.iter()
.map(|bytes| to_u32(bytes.len()))
.sum::<u32>(),
)
.expect("file size overflow");
let mut bytes = Vec::new();
bytes.extend_from_slice(&data_offset.to_be_bytes());
bytes.extend_from_slice(&file_size.to_be_bytes());
bytes.extend_from_slice(&19u32.to_be_bytes());
bytes.extend_from_slice(&data_offset.to_be_bytes());
bytes.push(0u8);
bytes.extend_from_slice(&[0u8; 3]);
bytes.extend_from_slice(&metadata);
for payload in object_payloads {
bytes.extend_from_slice(&payload);
}
bytes
}
fn build_unityfs_bundle(entry_name: &str, file_bytes: &[u8]) -> Vec<u8> {
let mut blocks_info = vec![0u8; 16];
blocks_info.extend_from_slice(&1i32.to_be_bytes());
blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes());
blocks_info.extend_from_slice(&to_u32(file_bytes.len()).to_be_bytes());
blocks_info.extend_from_slice(&0u16.to_be_bytes());
blocks_info.extend_from_slice(&1i32.to_be_bytes());
blocks_info.extend_from_slice(&0i64.to_be_bytes());
blocks_info.extend_from_slice(&to_i64(file_bytes.len()).to_be_bytes());
blocks_info.extend_from_slice(&0x4u32.to_be_bytes());
blocks_info.extend_from_slice(entry_name.as_bytes());
blocks_info.push(0u8);
let mut bytes = Vec::new();
bytes.extend_from_slice(b"UnityFS\0");
bytes.extend_from_slice(&7u32.to_be_bytes());
bytes.extend_from_slice(b"2019.4.0f1\0");
bytes.extend_from_slice(b"2019.4.0f1\0");
let size_offset = bytes.len();
bytes.extend_from_slice(&0i64.to_be_bytes());
bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes());
bytes.extend_from_slice(&to_u32(blocks_info.len()).to_be_bytes());
bytes.extend_from_slice(&0u32.to_be_bytes());
while bytes.len() % 16 != 0 {
bytes.push(0u8);
}
bytes.extend_from_slice(&blocks_info);
bytes.extend_from_slice(file_bytes);
let total_size = to_i64(bytes.len());
bytes[size_offset..size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
bytes
}
fn type_tree_blob_for(object: &FixtureObject) -> Vec<u8> {
let nodes = match object {
FixtureObject::GameObject { .. } => vec![
node(0, "GameObject", "Base", -1, 0),
node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG),
],
FixtureObject::TextAsset { .. } => vec![
node(0, "TextAsset", "Base", -1, 0),
node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG),
node(1, "string", "m_Script", -1, ALIGN_BYTES_FLAG),
],
FixtureObject::MonoScript { .. } => vec![
node(0, "MonoScript", "Base", -1, 0),
node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG),
node(1, "string", "m_ClassName", -1, ALIGN_BYTES_FLAG),
node(1, "string", "m_Namespace", -1, ALIGN_BYTES_FLAG),
node(1, "string", "m_AssemblyName", -1, ALIGN_BYTES_FLAG),
],
FixtureObject::MonoBehaviour { .. } => vec![
node(0, "MonoBehaviour", "Base", -1, 0),
node(1, "string", "m_Name", -1, ALIGN_BYTES_FLAG),
node(1, "PPtr<MonoScript>", "m_Script", -1, 0),
node(2, "int", "m_FileID", 4, 0),
node(2, "long long", "m_PathID", 8, 0),
node(1, "PPtr<TextAsset>", "m_TextAsset", -1, 0),
node(2, "int", "m_FileID", 4, 0),
node(2, "long long", "m_PathID", 8, 0),
],
FixtureObject::StrippedMonoBehaviour { .. } => return empty_type_tree_blob(),
};
let mut string_buffer = Vec::new();
let mut records = Vec::new();
for (index, (level, type_name, field_name, byte_size, meta_flags)) in nodes.iter().enumerate() {
let type_offset = push_cstring(&mut string_buffer, type_name);
let name_offset = push_cstring(&mut string_buffer, field_name);
records.push((
*level,
type_offset,
name_offset,
*byte_size,
to_i32(index),
*meta_flags,
));
}
let mut blob = Vec::new();
blob.extend_from_slice(&to_i32(records.len()).to_le_bytes());
blob.extend_from_slice(&to_i32(string_buffer.len()).to_le_bytes());
for (level, type_offset, name_offset, byte_size, index, meta_flags) in records {
blob.extend_from_slice(&1u16.to_le_bytes());
blob.push(level);
blob.push(0u8);
blob.extend_from_slice(&type_offset.to_le_bytes());
blob.extend_from_slice(&name_offset.to_le_bytes());
blob.extend_from_slice(&byte_size.to_le_bytes());
blob.extend_from_slice(&index.to_le_bytes());
blob.extend_from_slice(&meta_flags.to_le_bytes());
blob.extend_from_slice(&0u64.to_le_bytes());
}
blob.extend_from_slice(&string_buffer);
blob
}
fn empty_type_tree_blob() -> Vec<u8> {
let mut blob = Vec::new();
blob.extend_from_slice(&0i32.to_le_bytes());
blob.extend_from_slice(&0i32.to_le_bytes());
blob
}
const fn node(
level: u8,
type_name: &'static str,
field_name: &'static str,
byte_size: i32,
meta_flags: i32,
) -> (u8, &'static str, &'static str, i32, i32) {
(level, type_name, field_name, byte_size, meta_flags)
}
fn push_cstring(buffer: &mut Vec<u8>, value: &str) -> u32 {
let offset = to_u32(buffer.len());
buffer.extend_from_slice(value.as_bytes());
buffer.push(0u8);
offset
}
fn object_bytes(object: &FixtureObject) -> Vec<u8> {
let mut bytes = Vec::new();
match object {
FixtureObject::GameObject { name } => {
push_aligned_string(&mut bytes, name);
}
FixtureObject::TextAsset { name, script } => {
push_aligned_string(&mut bytes, name);
push_aligned_string(&mut bytes, script);
}
FixtureObject::MonoScript {
name,
class_name,
namespace,
assembly_name,
} => {
push_aligned_string(&mut bytes, name);
push_aligned_string(&mut bytes, class_name);
push_aligned_string(&mut bytes, namespace);
push_aligned_string(&mut bytes, assembly_name);
}
FixtureObject::MonoBehaviour {
name,
script,
text_asset,
} => {
push_aligned_string(&mut bytes, name);
push_pptr(&mut bytes, *script);
push_pptr(&mut bytes, *text_asset);
}
FixtureObject::StrippedMonoBehaviour {
game_object,
script,
extra_refs,
} => {
push_pptr(&mut bytes, *game_object);
bytes.extend_from_slice(&1i32.to_le_bytes());
push_pptr(&mut bytes, *script);
for value in extra_refs {
push_pptr(&mut bytes, *value);
}
}
}
bytes
}
fn push_aligned_string(bytes: &mut Vec<u8>, value: &str) {
bytes.extend_from_slice(&to_i32(value.len()).to_le_bytes());
bytes.extend_from_slice(value.as_bytes());
while bytes.len() % 4 != 0 {
bytes.push(0u8);
}
}
fn push_pptr(bytes: &mut Vec<u8>, value: PPtrValue) {
bytes.extend_from_slice(&value.file_id.to_le_bytes());
bytes.extend_from_slice(&value.path_id.to_le_bytes());
}
fn assert_json<T: DeserializeOwned>(assert: &assert_cmd::assert::Assert, label: &str) -> T {
serde_json::from_slice(&assert.get_output().stdout).expect(label)
}
#[test]
fn help_includes_subcommands_and_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("unityasset index"))
.stdout(predicate::str::contains("unityasset refs"))
.stdout(predicate::str::contains("--direction <MODE>"))
.stdout(predicate::str::contains("--max-refs <COUNT>"))
.stdout(predicate::str::contains("unityasset dump"))
.stdout(predicate::str::contains("unityasset extract"));
}
#[test]
fn indexes_serialized_bundle_and_yaml_objects_as_json() {
let root = unique_temp_dir("index");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.arg("--json")
.arg("index")
.arg(&root)
.assert()
.success();
let rows = assert_json::<Vec<Value>>(&assert, "json");
assert!(rows.iter().any(|row| {
row.get("id").and_then(Value::as_str) == Some("sample.assets#1001")
&& row.get("class_name").and_then(Value::as_str) == Some("TextAsset")
}));
assert!(rows.iter().any(|row| {
row.get("id").and_then(Value::as_str) == Some("sample.assets#1002")
&& row.get("script_class").and_then(Value::as_str) == Some("SampleBehaviour")
&& row.get("script_namespace").and_then(Value::as_str) == Some("Mercury.Sample")
&& row.get("script_assembly").and_then(Value::as_str) == Some("Assembly-CSharp")
}));
assert!(rows.iter().any(|row| {
row.get("id").and_then(Value::as_str) == Some("sample.bundle::bundled.assets#2001")
&& row.get("source_kind").and_then(Value::as_str) == Some("asset_bundle")
}));
assert!(rows.iter().any(|row| {
row.get("id").and_then(Value::as_str) == Some("scene.unity#3000")
&& row.get("format").and_then(Value::as_str) == Some("yaml")
}));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn index_filters_by_script_metadata_for_mono_behaviour_instances() {
let root = unique_temp_dir("index-script-filter");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.args([
"--json",
"index",
"--class",
"MonoBehaviour",
"--script",
"SampleBehaviour",
&root.display().to_string(),
])
.assert()
.success();
let rows = assert_json::<Vec<Value>>(&assert, "json");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].get("id").and_then(Value::as_str),
Some("sample.assets#1003")
);
assert_eq!(
rows[0].get("script_class").and_then(Value::as_str),
Some("SampleBehaviour")
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn index_empty_matches_still_exit_success_with_json_output() {
let root = unique_temp_dir("index-empty-success");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.args([
"--json",
"index",
"--class",
"^VideoClip$",
&root.display().to_string(),
])
.assert()
.success();
let rows = assert_json::<Vec<Value>>(&assert, "json");
assert!(rows.is_empty());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn reports_binary_incoming_and_outgoing_references_as_json() {
let root = unique_temp_dir("refs-bin");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.args(["--json", "refs", "sample.assets#1001"])
.arg(&root)
.assert()
.success();
let refs = assert_json::<Value>(&assert, "json");
assert_eq!(
refs.get("target")
.and_then(|target| target.get("id"))
.and_then(Value::as_str),
Some("sample.assets#1001")
);
assert_eq!(
refs.get("incoming")
.and_then(Value::as_array)
.expect("incoming")
.iter()
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec!["sample.assets#1003"]
);
assert!(
refs.get("outgoing")
.and_then(Value::as_array)
.expect("outgoing")
.is_empty()
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn reports_yaml_references_as_json() {
let root = unique_temp_dir("refs-yaml");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.args(["--json", "refs", "scene.unity#1000"])
.arg(&root)
.assert()
.success();
let refs = assert_json::<Value>(&assert, "json");
assert_eq!(
refs.get("outgoing")
.and_then(Value::as_array)
.expect("outgoing")
.iter()
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec!["scene.unity#2000"]
);
assert_eq!(
refs.get("incoming")
.and_then(Value::as_array)
.expect("incoming")
.iter()
.filter_map(|entry| entry.get("id").and_then(Value::as_str))
.collect::<Vec<_>>(),
vec!["scene.unity#2000"]
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn reports_stripped_mono_behaviour_refs_and_metadata_only_dump() {
let root = unique_temp_dir("refs-stripped");
write_fixture_workspace(&root);
let mut refs_command = cargo_command();
let refs_assert = refs_command
.args(["--json", "refs", "stripped.assets#2000"])
.arg(&root)
.assert()
.success();
let refs = assert_json::<Value>(&refs_assert, "json");
let outgoing = refs
.get("outgoing")
.and_then(Value::as_array)
.expect("outgoing");
assert!(outgoing.iter().any(|entry| {
entry.get("path").and_then(Value::as_str) == Some("m_GameObject")
&& entry.get("id").and_then(Value::as_str) == Some("stripped.assets#29")
}));
assert!(outgoing.iter().any(|entry| {
entry.get("path").and_then(Value::as_str) == Some("m_Script")
&& entry.get("id").and_then(Value::as_str) == Some("stripped.assets#1182")
}));
assert!(outgoing.iter().any(|entry| {
entry
.get("path")
.and_then(Value::as_str)
.is_some_and(|path| path.starts_with("_raw_pptr@"))
&& entry.get("id").and_then(Value::as_str) == Some("stripped.assets#30")
}));
let mut dump_command = cargo_command();
let dump_assert = dump_command
.args(["--json", "dump", "stripped.assets#2000"])
.arg(&root)
.assert()
.success();
let dump = assert_json::<Value>(&dump_assert, "json");
assert_eq!(
dump.get("field_mode").and_then(Value::as_str),
Some("metadata_only")
);
assert_eq!(
dump.get("properties")
.and_then(|value| value.get("m_GameObject"))
.and_then(|value| value.get("resolved_id"))
.and_then(Value::as_str),
Some("stripped.assets#29")
);
assert_eq!(
dump.get("properties")
.and_then(|value| value.get("m_Script"))
.and_then(|value| value.get("resolved_id"))
.and_then(Value::as_str),
Some("stripped.assets#1182")
);
assert!(
dump.get("properties")
.and_then(|value| value.get("_raw_resolved_references"))
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty())
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn refs_json_supports_direction_and_max_refs() {
let root = unique_temp_dir("refs-direction");
write_fixture_workspace(&root);
let mut command = cargo_command();
let assert = command
.args([
"--json",
"refs",
"--direction",
"outgoing",
"--max-refs",
"2",
"stripped.assets#2000",
])
.arg(&root)
.assert()
.success();
let refs = assert_json::<Value>(&assert, "json");
assert_eq!(
refs.get("direction").and_then(Value::as_str),
Some("outgoing")
);
assert_eq!(refs.get("max_refs").and_then(Value::as_u64), Some(2));
assert_eq!(refs.get("outgoing_total").and_then(Value::as_u64), Some(3));
assert_eq!(refs.get("outgoing_shown").and_then(Value::as_u64), Some(2));
assert_eq!(
refs.get("outgoing_truncated").and_then(Value::as_bool),
Some(true)
);
assert_eq!(
refs.get("incoming_filter_reason").and_then(Value::as_str),
Some("excluded_by_direction")
);
assert!(
refs.get("incoming")
.and_then(Value::as_array)
.expect("incoming")
.is_empty()
);
assert_eq!(
refs.get("outgoing")
.and_then(Value::as_array)
.expect("outgoing")
.len(),
2
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn dumps_bundle_objects_and_extracts_text_payloads() {
let root = unique_temp_dir("dump-extract");
write_fixture_workspace(&root);
let mut dump_command = cargo_command();
let dump_assert = dump_command
.args(["--json", "dump", "sample.bundle::bundled.assets#2002"])
.arg(&root)
.assert()
.success();
let dump = assert_json::<Value>(&dump_assert, "dump json");
assert_eq!(
dump.get("properties")
.and_then(|properties| properties.get("m_ClassName"))
.and_then(Value::as_str),
Some("BundledBehaviour")
);
let out_dir = root.join("out");
fs::create_dir_all(&out_dir).expect("out dir");
let mut extract_command = cargo_command();
extract_command
.args([
"extract",
"sample.bundle::bundled.assets#2001",
&root.display().to_string(),
"--output-dir",
&out_dir.display().to_string(),
])
.assert()
.success()
.stdout(predicate::str::contains("BundledText"));
let bundled_text = fs::read_to_string(out_dir.join("BundledText.txt")).expect("bundled text");
assert_eq!(bundled_text, "Hello from bundled text asset\n");
let mut yaml_extract = cargo_command();
yaml_extract
.args([
"extract",
"scene.unity#3000",
&root.display().to_string(),
"--output-dir",
&out_dir.display().to_string(),
])
.assert()
.success()
.stdout(predicate::str::contains("YamlText"));
let yaml_text = fs::read_to_string(out_dir.join("YamlText.txt")).expect("yaml text");
assert_eq!(yaml_text, "Hello from YAML text asset");
fs::remove_dir_all(root).expect("cleanup");
}