// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Utils { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Utils; using Object = UnityEngine.Object; internal static class ScriptableObjectSingletonMetadataUtility { internal static ScriptableObjectSingletonMetadata LoadOrCreateMetadataAsset() { // Try loading from current path first ScriptableObjectSingletonMetadata metadata = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.AssetPath ); if (metadata != null) { return metadata; } // Check for legacy path and migrate if found ScriptableObjectSingletonMetadata legacyMetadata = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.LegacyAssetPath ); if (legacyMetadata != null) { // Skip migration during test runs to avoid Unity's internal modal dialogs // unless explicitly allowed if ( EditorUi.Suppress && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression ) { return legacyMetadata; } return MigrateLegacyMetadata(legacyMetadata); } // Skip creating new assets during test runs to avoid Unity's internal modal dialogs // when asset operations fail, unless explicitly allowed. if ( EditorUi.Suppress && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression ) { return null; } // Create new asset at current path if (!EnsureResourcesFolder()) { Debug.LogWarning( "ScriptableObjectSingletonMetadataUtility: Could not ensure Resources folder exists. Skipping metadata asset creation." ); return null; } ScriptableObjectSingletonMetadata created = ScriptableObject.CreateInstance(); // If we're inside a batch scope, temporarily exit to allow asset creation using (AssetDatabaseBatchHelper.PauseBatch()) { try { AssetDatabase.CreateAsset(created, ScriptableObjectSingletonMetadata.AssetPath); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset(ScriptableObjectSingletonMetadata.AssetPath); return created; } catch (Exception ex) { Debug.LogWarning( $"ScriptableObjectSingletonMetadataUtility: Failed to create metadata asset: {ex.Message}" ); if (created != null) { Object.DestroyImmediate(created); } return null; } } } private static ScriptableObjectSingletonMetadata MigrateLegacyMetadata( ScriptableObjectSingletonMetadata legacyMetadata ) { // If we're inside a batch scope, temporarily exit to allow asset operations using (AssetDatabaseBatchHelper.PauseBatch()) { if (!EnsureResourcesFolder()) { Debug.LogWarning( "ScriptableObjectSingletonMetadataUtility: Could not ensure Resources folder exists. Keeping legacy metadata asset." ); return legacyMetadata; } string legacyPath = ScriptableObjectSingletonMetadata.LegacyAssetPath; string targetPath = ScriptableObjectSingletonMetadata.AssetPath; string moveResult = AssetDatabase.MoveAsset(legacyPath, targetPath); if (string.IsNullOrEmpty(moveResult)) { // Move succeeded - try to delete empty parent folders TryDeleteEmptyParentFolders(legacyPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return AssetDatabase.LoadAssetAtPath( targetPath ); } // Move failed - create new and copy data Debug.LogWarning( $"Failed to move ScriptableObjectSingletonMetadata from {legacyPath} to {targetPath}: {moveResult}. Creating new asset." ); ScriptableObjectSingletonMetadata created = ScriptableObject.CreateInstance(); try { AssetDatabase.CreateAsset(created, targetPath); } catch (Exception ex) { Debug.LogWarning( $"ScriptableObjectSingletonMetadataUtility: Failed to create new metadata asset during migration: {ex.Message}. Keeping legacy metadata." ); if (created != null) { Object.DestroyImmediate(created); } return legacyMetadata; } // Delete legacy asset if (AssetDatabase.DeleteAsset(legacyPath)) { TryDeleteEmptyParentFolders(legacyPath); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); return created; } } private static void TryDeleteEmptyParentFolders(string assetPath) { try { string folder = Path.GetDirectoryName(assetPath); if (string.IsNullOrWhiteSpace(folder)) { return; } folder = folder.SanitizePath(); while ( !string.IsNullOrWhiteSpace(folder) && !string.Equals(folder, "Assets", StringComparison.OrdinalIgnoreCase) && !string.Equals( folder, "Assets/Resources", StringComparison.OrdinalIgnoreCase ) ) { if (!AssetDatabase.IsValidFolder(folder)) { folder = Path.GetDirectoryName(folder); if (folder != null) { folder = folder.SanitizePath(); } continue; } string[] contents = AssetDatabase.FindAssets(string.Empty, new[] { folder }); if (contents == null || contents.Length == 0) { if (AssetDatabase.DeleteAsset(folder)) { string parent = Path.GetDirectoryName(folder); if (parent != null) { folder = parent.SanitizePath(); } else { break; } continue; } } break; } } catch (Exception e) { Debug.LogWarning($"Failed to clean up empty folders after migration: {e.Message}"); } } internal static bool UpdateEntry( Type type, string resourcesLoadPath, string resourcesPath, string assetGuid ) { // Skip creating new metadata asset during test runs to avoid Unity's internal modal dialogs // when asset operations fail, unless explicitly allowed. Only update if the asset already exists. if ( EditorUi.Suppress && !ScriptableObjectSingletonCreator.AllowAssetCreationDuringSuppression ) { ScriptableObjectSingletonMetadata existing = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.AssetPath ); if (existing == null) { // Also check legacy path existing = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.LegacyAssetPath ); } if (existing == null) { return false; } } ScriptableObjectSingletonMetadata metadata = LoadOrCreateMetadataAsset(); if (metadata == null) { return false; } ScriptableObjectSingletonMetadata.Entry entry = new() { assemblyQualifiedTypeName = type.AssemblyQualifiedName, resourcesLoadPath = resourcesLoadPath, resourcesPath = resourcesPath, assetGuid = assetGuid, }; if (!metadata.SetOrUpdateEntry(entry)) { return false; } EditorUtility.SetDirty(metadata); return true; } /// /// Removes metadata entries that point to non-existent assets. /// This cleans up stale entries that may have been left behind when assets were deleted. /// /// The number of stale entries removed. internal static int CleanupStaleEntries() { ScriptableObjectSingletonMetadata metadata = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.AssetPath ); if (metadata == null) { // Also check legacy path metadata = AssetDatabase.LoadAssetAtPath( ScriptableObjectSingletonMetadata.LegacyAssetPath ); } if (metadata == null) { return 0; } IReadOnlyList entries = metadata.GetAllEntries(); if (entries == null || entries.Count == 0) { return 0; } using PooledResource> staleEntryResource = Buffers.List.Get( out List staleEntries ); foreach (ScriptableObjectSingletonMetadata.Entry entry in entries) { if (string.IsNullOrEmpty(entry.resourcesLoadPath)) { continue; } // Check if the asset actually exists string assetPath = $"Assets/Resources/{entry.resourcesLoadPath}.asset"; Object asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null) { // Also verify by GUID if available if (!string.IsNullOrEmpty(entry.assetGuid)) { string guidPath = AssetDatabase.GUIDToAssetPath(entry.assetGuid); if (!string.IsNullOrEmpty(guidPath)) { asset = AssetDatabase.LoadAssetAtPath(guidPath); } } if (asset == null) { staleEntries.Add(entry.assemblyQualifiedTypeName); } } } if (staleEntries.Count == 0) { return 0; } foreach (string typeName in staleEntries) { metadata.RemoveEntry(typeName); } EditorUtility.SetDirty(metadata); return staleEntries.Count; } private static bool EnsureResourcesFolder() { string assetPath = ScriptableObjectSingletonMetadata.AssetPath; string directory = Path.GetDirectoryName(assetPath); if (string.IsNullOrEmpty(directory)) { return false; } directory = directory.SanitizePath(); // First, ensure the folder exists on disk. This prevents Unity's internal // "Moving file failed" modal dialog when CreateAsset tries to move a temp file // to a destination folder that doesn't exist. string projectRoot = Path.GetDirectoryName(Application.dataPath); if (!string.IsNullOrEmpty(projectRoot)) { string absoluteDirectory = Path.Combine(projectRoot, directory); try { if (!Directory.Exists(absoluteDirectory)) { Directory.CreateDirectory(absoluteDirectory); } } catch (Exception ex) { Debug.LogWarning( $"ScriptableObjectSingletonMetadataUtility: Failed to create directory on disk '{absoluteDirectory}': {ex.Message}" ); return false; } } if (AssetDatabase.IsValidFolder(directory)) { return true; } // If we're inside a batch scope, temporarily exit to ensure folder creation is immediate using (AssetDatabaseBatchHelper.PauseBatch()) { string[] segments = directory.Split('/', StringSplitOptions.RemoveEmptyEntries); string current = segments[0]; for (int i = 1; i < segments.Length; i++) { string next = $"{current}/{segments[i]}"; if (!AssetDatabase.IsValidFolder(next)) { string result = AssetDatabase.CreateFolder(current, segments[i]); if (string.IsNullOrEmpty(result)) { Debug.LogError( $"ScriptableObjectSingletonMetadataUtility: Failed to create folder '{next}'" ); return false; } } current = next; } // Note: Do NOT call AssetDatabase.Refresh here - it causes issues during // Unity initialization and can trigger "Unable to import newly created asset" errors. // The folders are immediately available after CreateFolder. return true; } } /// /// Resets legacy state for testing. AssetDatabase batch cleanup is now handled /// by the unified . /// /// /// This method is kept for backward compatibility with test cleanup code. /// The actual AssetDatabase state cleanup is handled by AssetDatabaseBatchHelper.ResetBatchDepth(). /// internal static void ResetAssetEditingDepthForTesting() { // AssetDatabase batch cleanup is now handled by AssetDatabaseBatchHelper.ResetBatchDepth() // which is called by CommonTestBase in setUp/tearDown. // This method is a no-op kept for backward compatibility. } /// /// Registers the sync implementation with the Runtime metadata class. /// Called automatically via InitializeOnLoadMethod. /// [InitializeOnLoadMethod] private static void RegisterSyncImplementation() { ScriptableObjectSingletonMetadata.SyncImplementation = SyncAllSingletonMetadata; } /// /// Re-scans all assemblies for ScriptableObjectSingleton types and updates their metadata entries. /// This removes stale entries and adds/updates metadata for all existing singleton assets. /// /// The metadata asset to sync. If null, loads or creates the metadata asset. internal static void SyncAllSingletonMetadata(ScriptableObjectSingletonMetadata metadata) { metadata ??= LoadOrCreateMetadataAsset(); if (metadata == null) { Debug.LogWarning( "ScriptableObjectSingletonMetadataUtility.SyncAllSingletonMetadata: " + "Could not load or create metadata asset." ); return; } int added = 0; int updated = 0; int removed = 0; // Build a set of existing entries for comparison IReadOnlyList existingEntries = metadata.GetAllEntries(); Dictionary existingByTypeName = new( StringComparer.Ordinal ); foreach (ScriptableObjectSingletonMetadata.Entry entry in existingEntries) { if (!string.IsNullOrEmpty(entry.assemblyQualifiedTypeName)) { existingByTypeName[entry.assemblyQualifiedTypeName] = entry; } } // Track which types we find during scanning HashSet foundTypeNames = new(StringComparer.Ordinal); // Scan for all singleton types foreach ( Type derivedType in ReflectionHelpers.GetTypesDerivedFrom( typeof(ScriptableObjectSingleton<>), includeAbstract: false ) ) { if (derivedType.IsGenericType) { continue; } // Skip test types unless explicitly included if (TestAssemblyHelper.IsTestType(derivedType)) { continue; } string assemblyQualifiedName = derivedType.AssemblyQualifiedName; if (string.IsNullOrEmpty(assemblyQualifiedName)) { continue; } foundTypeNames.Add(assemblyQualifiedName); // Find the asset for this type string assetPath = FindSingletonAssetPath(derivedType); if (string.IsNullOrEmpty(assetPath)) { // No asset exists - skip (don't create assets, just sync metadata for existing ones) continue; } string loadPath = ToResourcesLoadPath(assetPath); if (string.IsNullOrEmpty(loadPath)) { continue; } string resourcesFolder = GetResourcesFolderFromLoadPath(loadPath); string guid = AssetDatabase.AssetPathToGUID(assetPath) ?? string.Empty; ScriptableObjectSingletonMetadata.Entry newEntry = new() { assemblyQualifiedTypeName = assemblyQualifiedName, resourcesLoadPath = loadPath, resourcesPath = resourcesFolder, assetGuid = guid, }; // Check if entry exists and needs updating if ( existingByTypeName.TryGetValue( assemblyQualifiedName, out ScriptableObjectSingletonMetadata.Entry existingEntry ) ) { bool needsUpdate = !string.Equals( existingEntry.resourcesLoadPath, newEntry.resourcesLoadPath, StringComparison.Ordinal ) || !string.Equals( existingEntry.resourcesPath, newEntry.resourcesPath, StringComparison.Ordinal ) || !string.Equals( existingEntry.assetGuid, newEntry.assetGuid, StringComparison.Ordinal ); if (needsUpdate) { metadata.SetOrUpdateEntry(newEntry); updated++; } } else { metadata.SetOrUpdateEntry(newEntry); added++; } } // Remove stale entries (types that no longer exist or have no assets) foreach (string existingTypeName in existingByTypeName.Keys) { if (!foundTypeNames.Contains(existingTypeName)) { // Type was not found during scan - could be deleted or renamed // Also check if the asset still exists ScriptableObjectSingletonMetadata.Entry staleEntry = existingByTypeName[ existingTypeName ]; if (!string.IsNullOrEmpty(staleEntry.resourcesLoadPath)) { string assetPath = $"Assets/Resources/{staleEntry.resourcesLoadPath}.asset"; Object asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null && !string.IsNullOrEmpty(staleEntry.assetGuid)) { string guidPath = AssetDatabase.GUIDToAssetPath(staleEntry.assetGuid); if (!string.IsNullOrEmpty(guidPath)) { asset = AssetDatabase.LoadAssetAtPath(guidPath); } } if (asset == null) { metadata.RemoveEntry(existingTypeName); removed++; } } else { metadata.RemoveEntry(existingTypeName); removed++; } } } if (added > 0 || updated > 0 || removed > 0) { EditorUtility.SetDirty(metadata); AssetDatabase.SaveAssets(); Debug.Log( $"ScriptableObjectSingletonMetadata.Sync: Added {added}, updated {updated}, removed {removed} entries." ); } else { Debug.Log( "ScriptableObjectSingletonMetadata.Sync: Metadata is already up to date." ); } } private static string FindSingletonAssetPath(Type type) { // First try to find by type name in Resources string[] guids = AssetDatabase.FindAssets( $"t:{type.Name}", new[] { "Assets/Resources" } ); if (guids != null && guids.Length > 0) { foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(path)) { continue; } Object asset = AssetDatabase.LoadAssetAtPath(path, type); if (asset != null) { return path; } } } // Try loading from Resources as a fallback Object[] instances = Resources.LoadAll(string.Empty, type); if (instances != null && instances.Length > 0) { foreach (Object instance in instances) { if (instance == null) { continue; } string path = AssetDatabase.GetAssetPath(instance); if (!string.IsNullOrEmpty(path)) { return path; } } } return null; } private static string ToResourcesLoadPath(string assetPath) { if (string.IsNullOrWhiteSpace(assetPath)) { return null; } const string resourcesRoot = "Assets/Resources"; string normalized = assetPath.SanitizePath(); if (!normalized.StartsWith(resourcesRoot, StringComparison.OrdinalIgnoreCase)) { return null; } string relative = normalized.Substring(resourcesRoot.Length).TrimStart('/'); if (string.IsNullOrWhiteSpace(relative)) { return null; } if (relative.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)) { relative = relative.Substring(0, relative.Length - ".asset".Length); } return relative.Replace("\\", "/"); } private static string GetResourcesFolderFromLoadPath(string loadPath) { if (string.IsNullOrWhiteSpace(loadPath)) { return string.Empty; } int lastSlash = loadPath.LastIndexOf('/'); if (lastSlash <= 0) { return string.Empty; } return loadPath.Substring(0, lastSlash); } } #endif }