unity-asset-cli 0.2.0

Command-line tools for Unity asset parsing and manipulation
use crate::shared::{AppContext, class_name_for_id, load_environment_input, resolve_loaded_source};
use anyhow::Result;
use serde::Serialize;
use std::path::PathBuf;
use unity_asset::environment::{BinaryObjectKey, BinarySource, BinarySourceKind, Environment};
use unity_asset_binary::asset::SerializedFile;

#[derive(Debug, Serialize)]
struct ListObjectRecord {
    key: String,
    source: String,
    source_kind: String,
    asset_index: Option<usize>,
    path_id: i64,
    class_id: i32,
    class_name: String,
    byte_size: u32,
    name: Option<String>,
    typetree: bool,
}

fn best_effort_class_name(file: &SerializedFile, class_id: i32) -> String {
    if let Some(t) = file.find_type(class_id) {
        if let Some(root) = t.type_tree.nodes.first() {
            if !root.type_name.is_empty() {
                return root.type_name.clone();
            }
        }
        if !t.class_name.is_empty() {
            return t.class_name.clone();
        }
    }
    class_name_for_id(class_id).to_string()
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn run(
    input: PathBuf,
    kind: String,
    source: Option<PathBuf>,
    asset_index: Option<usize>,
    class_id: Vec<i32>,
    class_name: String,
    name: String,
    limit: Option<usize>,
    json: bool,
    ctx: &AppContext,
) -> Result<()> {
    let mut env =
        crate::shared::build_environment(ctx.strict, ctx.show_warnings, ctx.typetree_registries())?;
    load_environment_input(&mut env, &input)?;

    let kind_lc = kind.to_ascii_lowercase();
    let want_bundle = kind_lc == "all" || kind_lc == "bundle";
    let want_serialized = kind_lc == "all" || kind_lc == "serialized";
    if !want_bundle && !want_serialized {
        anyhow::bail!("Unknown --kind: {} (expected: all|bundle|serialized)", kind);
    }

    let name_lc = name.to_ascii_lowercase();
    let class_name_lc = class_name.to_ascii_lowercase();

    let mut printed = 0usize;
    let limit = limit.unwrap_or(usize::MAX);

    if want_serialized {
        list_serialized(
            &env,
            &input,
            source.as_ref(),
            &class_id,
            &class_name_lc,
            &name_lc,
            limit,
            json,
            &mut printed,
        )?;
    }

    if printed < limit && want_bundle {
        list_bundles(
            &env,
            &input,
            source.as_ref(),
            asset_index,
            &class_id,
            &class_name_lc,
            &name_lc,
            limit,
            json,
            &mut printed,
        )?;
    }

    Ok(())
}

fn matches_filters(
    class_id_filter: &[i32],
    class_name_lc: &str,
    name_lc: &str,
    class_id: i32,
    class_name: &str,
    peek_name: Option<&str>,
) -> bool {
    if !class_id_filter.is_empty() && !class_id_filter.contains(&class_id) {
        return false;
    }
    if !class_name_lc.is_empty() && !class_name.to_ascii_lowercase().contains(class_name_lc) {
        return false;
    }
    if name_lc.is_empty() {
        return true;
    }
    peek_name
        .map(|n| n.to_ascii_lowercase().contains(name_lc))
        .unwrap_or(false)
}

#[allow(clippy::too_many_arguments)]
fn list_serialized(
    env: &Environment,
    input: &PathBuf,
    source: Option<&PathBuf>,
    class_id_filter: &[i32],
    class_name_lc: &str,
    name_lc: &str,
    limit: usize,
    json: bool,
    printed: &mut usize,
) -> Result<()> {
    let sources: Vec<BinarySource> = if let Some(source) = source {
        let resolved = resolve_loaded_source(
            env,
            BinarySourceKind::SerializedFile,
            &BinarySource::path(source),
        )?;
        vec![resolved]
    } else {
        env.binary_assets().keys().cloned().collect()
    };

    for src in sources {
        let Some(file) = env.binary_assets().get(&src) else {
            continue;
        };

        for handle in file.object_handles() {
            if *printed >= limit {
                return Ok(());
            }

            let class_id = handle.class_id();
            let class_name = best_effort_class_name(file, class_id);
            let has_typetree = file
                .find_type(class_id)
                .map(|t| t.has_type_tree())
                .unwrap_or(false);
            let peek = handle.peek_name().ok().flatten();

            if !matches_filters(
                class_id_filter,
                class_name_lc,
                name_lc,
                class_id,
                &class_name,
                peek.as_deref(),
            ) {
                continue;
            }

            let key = BinaryObjectKey {
                source: src.clone(),
                source_kind: BinarySourceKind::SerializedFile,
                asset_index: None,
                path_id: handle.path_id(),
            };

            let record = ListObjectRecord {
                key: key.to_string(),
                source: src.to_string(),
                source_kind: "serialized".to_string(),
                asset_index: None,
                path_id: handle.path_id(),
                class_id,
                class_name,
                byte_size: handle.byte_size(),
                name: peek,
                typetree: has_typetree,
            };

            if json {
                println!("{}", serde_json::to_string(&record)?);
            } else {
                println!(
                    "{} class_id={} class={} path_id={} byte_size={} name={}",
                    record.key,
                    record.class_id,
                    record.class_name,
                    record.path_id,
                    record.byte_size,
                    record
                        .name
                        .as_deref()
                        .map(|s| format!("{:?}", s))
                        .unwrap_or_else(|| "null".to_string())
                );
            }

            *printed += 1;
        }
    }

    if *printed == 0
        && !input.is_file()
        && source.is_none()
        && env.binary_assets().is_empty()
        && !json
    {
        println!("⚠ No SerializedFiles found in {:?}", input);
    }

    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn list_bundles(
    env: &Environment,
    input: &PathBuf,
    source: Option<&PathBuf>,
    asset_index: Option<usize>,
    class_id_filter: &[i32],
    class_name_lc: &str,
    name_lc: &str,
    limit: usize,
    json: bool,
    printed: &mut usize,
) -> Result<()> {
    let sources: Vec<BinarySource> = if let Some(source) = source {
        let resolved = resolve_loaded_source(
            env,
            BinarySourceKind::AssetBundle,
            &BinarySource::path(source),
        )?;
        vec![resolved]
    } else {
        env.bundles().keys().cloned().collect()
    };

    for src in sources {
        let Some(bundle) = env.bundles().get(&src) else {
            continue;
        };

        for (idx, asset) in bundle.assets.iter().enumerate() {
            if *printed >= limit {
                return Ok(());
            }
            if let Some(filter_idx) = asset_index {
                if idx != filter_idx {
                    continue;
                }
            }

            for handle in asset.object_handles() {
                if *printed >= limit {
                    return Ok(());
                }

                let class_id = handle.class_id();
                let class_name = best_effort_class_name(asset, class_id);
                let has_typetree = asset
                    .find_type(class_id)
                    .map(|t| t.has_type_tree())
                    .unwrap_or(false);
                let peek = handle.peek_name().ok().flatten();

                if !matches_filters(
                    class_id_filter,
                    class_name_lc,
                    name_lc,
                    class_id,
                    &class_name,
                    peek.as_deref(),
                ) {
                    continue;
                }

                let key = BinaryObjectKey {
                    source: src.clone(),
                    source_kind: BinarySourceKind::AssetBundle,
                    asset_index: Some(idx),
                    path_id: handle.path_id(),
                };

                let record = ListObjectRecord {
                    key: key.to_string(),
                    source: src.to_string(),
                    source_kind: "bundle".to_string(),
                    asset_index: Some(idx),
                    path_id: handle.path_id(),
                    class_id,
                    class_name,
                    byte_size: handle.byte_size(),
                    name: peek,
                    typetree: has_typetree,
                };

                if json {
                    println!("{}", serde_json::to_string(&record)?);
                } else {
                    println!(
                        "{} class_id={} class={} asset_index={} path_id={} byte_size={} name={}",
                        record.key,
                        record.class_id,
                        record.class_name,
                        idx,
                        record.path_id,
                        record.byte_size,
                        record
                            .name
                            .as_deref()
                            .map(|s| format!("{:?}", s))
                            .unwrap_or_else(|| "null".to_string())
                    );
                }

                *printed += 1;
            }
        }
    }

    if *printed == 0 && !input.is_file() && source.is_none() && env.bundles().is_empty() && !json {
        println!("⚠ No AssetBundles found in {:?}", input);
    }

    Ok(())
}