//! Unity GUID resolution for MonoBehaviour scripts //! //! This module resolves Unity GUIDs to their corresponding MonoBehaviour class names //! by scanning .meta files and parsing C# scripts. //! //! # How Unity GUID Resolution Works //! //! 1. Every asset has a `.meta` file with a unique GUID //! 2. MonoBehaviour components reference scripts via GUID in `m_Script` //! 3. We scan `.cs.meta` files to build a GUID → Script Path mapping //! 4. We parse the `.cs` files to extract the class name //! 5. Result: GUID → Class Name mapping for component registration //! //! # Example //! //! ```no_run //! use unity_parser::parser::GuidResolver; //! use std::path::Path; //! //! // Build resolver from Unity project directory //! let project_path = Path::new("path/to/UnityProject"); //! let resolver = GuidResolver::from_project(project_path)?; //! //! // Resolve a GUID to class name //! let guid = "091c537484687e9419460cdcd7038234"; //! if let Some(class_name) = resolver.resolve_class_name(guid) { //! println!("GUID {} → {}", guid, class_name); //! } //! # Ok::<(), unity_parser::Error>(()) //! ``` use crate::parser::meta::MetaFile; use crate::types::Guid; use crate::{Error, Result}; use regex::Regex; use std::collections::HashMap; use std::path::{Path, PathBuf}; use walkdir::WalkDir; /// Resolves Unity GUIDs to MonoBehaviour class names /// /// This struct builds a mapping from GUID to class name by scanning /// a Unity project's `.cs.meta` files and parsing the corresponding /// C# scripts to extract class names. /// /// Uses a 128-bit `Guid` type for efficient storage and fast comparisons. #[derive(Debug, Clone)] pub struct GuidResolver { /// Map from GUID to MonoBehaviour class name guid_to_class: HashMap, } impl GuidResolver { /// Create a new empty GuidResolver pub fn new() -> Self { Self { guid_to_class: HashMap::new(), } } /// Build a GuidResolver by scanning a Unity project directory /// /// This scans for all `.cs.meta` files (MonoBehaviour scripts), /// parses them to extract GUIDs, then parses the corresponding /// C# files to extract class names. /// /// # Arguments /// /// * `project_path` - Path to the Unity project root (containing Assets/ folder) /// /// # Examples /// /// ```no_run /// use unity_parser::parser::GuidResolver; /// use std::path::Path; /// /// let resolver = GuidResolver::from_project(Path::new("MyUnityProject"))?; /// # Ok::<(), unity_parser::Error>(()) /// ``` pub fn from_project(project_path: impl AsRef) -> Result { let project_path = project_path.as_ref(); // Verify this looks like a Unity project - check for Assets/ or _GameAssets/ let assets_dir = if project_path.join("Assets").exists() { project_path.join("Assets") } else if project_path.join("_GameAssets").exists() { project_path.join("_GameAssets") } else { return Err(Error::invalid_format(format!( "Not a Unity project: missing Assets/ or _GameAssets/ directory at {}", project_path.display() ))); }; let mut resolver = Self::new(); // Walk the Assets directory looking for .cs.meta files for entry in WalkDir::new(&assets_dir) .follow_links(false) .into_iter() .filter_map(|e| e.ok()) { let path = entry.path(); // Only process .cs.meta files (MonoBehaviour scripts) if !is_cs_meta_file(path) { continue; } // Parse the .meta file to get the GUID let meta = match MetaFile::from_path(path) { Ok(meta) => meta, Err(e) => { eprintln!("Warning: Failed to parse {}: {}", path.display(), e); continue; } }; // Get the corresponding .cs file path let cs_path = path.with_file_name( path.file_name() .and_then(|s| s.to_str()) .and_then(|s| s.strip_suffix(".meta")) .unwrap_or(""), ); // Extract the class name from the .cs file let class_name = match extract_class_name(&cs_path) { Ok(Some(name)) => name, Ok(None) => { // No MonoBehaviour class found in this file continue; } Err(e) => { eprintln!( "Warning: Failed to extract class name from {}: {}", cs_path.display(), e ); continue; } }; // Parse the GUID string to Guid type let guid = match Guid::from_hex(meta.guid()) { Ok(g) => g, Err(e) => { eprintln!( "Warning: Invalid GUID in {}: {}", path.display(), e ); continue; } }; // Store the mapping resolver.guid_to_class.insert(guid, class_name); } Ok(resolver) } /// Resolve a GUID to its class name /// /// Accepts either a `&Guid` or a string that can be parsed as a GUID. /// /// # Arguments /// /// * `guid` - The Unity GUID (e.g., "091c537484687e9419460cdcd7038234" or a `Guid`) /// /// # Returns /// /// The class name if the GUID is found, otherwise None /// /// # Examples /// /// ```no_run /// # use unity_parser::{GuidResolver, Guid}; /// # use std::path::Path; /// # let resolver = GuidResolver::from_project(Path::new("."))?; /// // Resolve by string /// if let Some(class_name) = resolver.resolve_class_name("091c537484687e9419460cdcd7038234") { /// println!("Found class: {}", class_name); /// } /// /// // Resolve by Guid /// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234")?; /// if let Some(class_name) = resolver.resolve_class_name(&guid) { /// println!("Found class: {}", class_name); /// } /// # Ok::<(), unity_parser::Error>(()) /// ``` pub fn resolve_class_name(&self, guid: G) -> Option<&str> { guid.as_guid() .and_then(|g| self.guid_to_class.get(&g)) .map(|s| s.as_str()) } /// Get the number of GUID mappings pub fn len(&self) -> usize { self.guid_to_class.len() } /// Check if the resolver is empty pub fn is_empty(&self) -> bool { self.guid_to_class.is_empty() } /// Insert a GUID → class name mapping manually /// /// Useful for testing or adding custom mappings /// /// # Examples /// /// ``` /// use unity_parser::{GuidResolver, Guid}; /// /// let mut resolver = GuidResolver::new(); /// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap(); /// resolver.insert(guid, "PlaySFX".to_string()); /// ``` pub fn insert(&mut self, guid: Guid, class_name: String) { self.guid_to_class.insert(guid, class_name); } /// Get all resolved GUIDs /// /// Returns an iterator over all GUIDs in the resolver. pub fn guids(&self) -> impl Iterator + '_ { self.guid_to_class.keys().copied() } } impl Default for GuidResolver { fn default() -> Self { Self::new() } } /// Trait for types that can be converted to a GUID for lookup /// /// This allows the `resolve_class_name` method to accept both `&Guid` and `&str`. pub trait AsGuid { /// Convert to a GUID, returning None if conversion fails fn as_guid(&self) -> Option; } impl AsGuid for Guid { fn as_guid(&self) -> Option { Some(*self) } } impl AsGuid for &Guid { fn as_guid(&self) -> Option { Some(**self) } } impl AsGuid for str { fn as_guid(&self) -> Option { Guid::from_hex(self).ok() } } impl AsGuid for &str { fn as_guid(&self) -> Option { Guid::from_hex(self).ok() } } impl AsGuid for String { fn as_guid(&self) -> Option { Guid::from_hex(self).ok() } } /// Check if a path points to a C# .meta file fn is_cs_meta_file(path: &Path) -> bool { path.extension() .and_then(|s| s.to_str()) .map(|ext| ext == "meta") .unwrap_or(false) && path .file_name() .and_then(|s| s.to_str()) .map(|name| name.ends_with(".cs.meta")) .unwrap_or(false) } /// Extract the MonoBehaviour class name from a C# script file /// /// This looks for patterns like: /// - `public class ClassName : MonoBehaviour` /// - `class ClassName : MonoBehaviour` /// - `public class ClassName:MonoBehaviour` /// /// # Arguments /// /// * `cs_path` - Path to the .cs file /// /// # Returns /// /// Ok(Some(class_name)) if a MonoBehaviour class was found /// Ok(None) if no MonoBehaviour class was found /// Err if the file couldn't be read fn extract_class_name(cs_path: &Path) -> Result> { let content = std::fs::read_to_string(cs_path)?; // Regex to match MonoBehaviour class declarations (direct or indirect inheritance) // Matches: public class ClassName : MonoBehaviour // Also matches: public class ClassName : SomeBaseClass (which may inherit from MonoBehaviour) // Captures the class name in group 1 // // We match any class with inheritance (: BaseClass) because in Unity, // scripts can inherit from MonoBehaviour indirectly through base classes. // The component registration system will filter for actual MonoBehaviours. let class_regex = Regex::new( r"(?:public\s+)?class\s+(\w+)\s*:\s*\w+" ).unwrap(); // Find the first class with inheritance in the file // Unity typically has one main class per script file if let Some(captures) = class_regex.captures(&content) { if let Some(class_name) = captures.get(1) { return Ok(Some(class_name.as_str().to_string())); } } Ok(None) } /// Find the Unity project root from any path within the project /// /// Searches upward from the given path until it finds a directory /// containing an "Assets" folder. /// /// # Arguments /// /// * `path` - Any path within the Unity project (file or directory) /// /// # Returns /// /// The project root path if found, otherwise an error /// /// # Examples /// /// ```no_run /// use unity_parser::parser::find_project_root; /// use std::path::Path; /// /// let scene_path = Path::new("MyProject/Assets/Scenes/Main.unity"); /// let project_root = find_project_root(scene_path)?; /// assert_eq!(project_root.file_name().unwrap(), "MyProject"); /// # Ok::<(), unity_parser::Error>(()) /// ``` pub fn find_project_root(path: impl AsRef) -> Result { let path = path.as_ref(); // Start from the file's directory (or the directory itself) let mut current = if path.is_file() { path.parent().ok_or_else(|| { Error::invalid_format("Cannot get parent directory") })? } else { path }; // Search upward for Assets/ or _GameAssets/ directory loop { let assets_dir = current.join("Assets"); let game_assets_dir = current.join("_GameAssets"); if (assets_dir.exists() && assets_dir.is_dir()) || (game_assets_dir.exists() && game_assets_dir.is_dir()) { return Ok(current.to_path_buf()); } // Move up one directory current = current.parent().ok_or_else(|| { Error::invalid_format(format!( "Could not find Unity project root from {}", path.display() )) })?; } } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_cs_meta_file() { assert!(is_cs_meta_file(Path::new("PlaySFX.cs.meta"))); assert!(is_cs_meta_file(Path::new("Assets/Scripts/PlaySFX.cs.meta"))); assert!(!is_cs_meta_file(Path::new("PlaySFX.cs"))); assert!(!is_cs_meta_file(Path::new("Scene.unity.meta"))); assert!(!is_cs_meta_file(Path::new("texture.png.meta"))); } #[test] fn test_extract_class_name() { // Test standard MonoBehaviour class let content = r#" using UnityEngine; public class PlaySFX : MonoBehaviour { public float volume = 1.0f; } "#; let temp_file = std::env::temp_dir().join("test_playsfx.cs"); std::fs::write(&temp_file, content).unwrap(); let result = extract_class_name(&temp_file).unwrap(); assert_eq!(result, Some("PlaySFX".to_string())); std::fs::remove_file(&temp_file).ok(); } #[test] fn test_extract_class_name_no_public() { // Test without public modifier let content = r#" using UnityEngine; class MyBehaviour : MonoBehaviour { void Start() {} } "#; let temp_file = std::env::temp_dir().join("test_mybehaviour.cs"); std::fs::write(&temp_file, content).unwrap(); let result = extract_class_name(&temp_file).unwrap(); assert_eq!(result, Some("MyBehaviour".to_string())); std::fs::remove_file(&temp_file).ok(); } #[test] fn test_extract_class_name_no_space() { // Test with no space after colon let content = r#" using UnityEngine; public class TestScript:MonoBehaviour { void Update() {} } "#; let temp_file = std::env::temp_dir().join("test_testscript.cs"); std::fs::write(&temp_file, content).unwrap(); let result = extract_class_name(&temp_file).unwrap(); assert_eq!(result, Some("TestScript".to_string())); std::fs::remove_file(&temp_file).ok(); } #[test] fn test_extract_class_name_not_monobehaviour() { // Test class that doesn't inherit from MonoBehaviour let content = r#" using UnityEngine; public class HelperClass { public int value; } "#; let temp_file = std::env::temp_dir().join("test_helper.cs"); std::fs::write(&temp_file, content).unwrap(); let result = extract_class_name(&temp_file).unwrap(); assert_eq!(result, None); std::fs::remove_file(&temp_file).ok(); } #[test] fn test_guid_resolver_manual() { let mut resolver = GuidResolver::new(); assert!(resolver.is_empty()); let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap(); resolver.insert(guid, "PlaySFX".to_string()); assert_eq!(resolver.len(), 1); // Test resolving by string assert_eq!( resolver.resolve_class_name("091c537484687e9419460cdcd7038234"), Some("PlaySFX") ); // Test resolving by Guid assert_eq!( resolver.resolve_class_name(&guid), Some("PlaySFX") ); // Test nonexistent GUID assert_eq!( resolver.resolve_class_name("00000000000000000000000000000000"), None ); } }