diff --git a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs index 173e878f8..aaa781cae 100644 --- a/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs +++ b/Assets/Tests/Editor/OnionAssemblyDependencyTests.cs @@ -851,6 +851,17 @@ public void SetupWizardStartup_WhenLoaded_SchedulesVersionCheckInsteadOfReadingS Assert.That(setupWizardSource, Does.Not.Contain("\n TryShowOnVersionChange();")); } + [Test] + public void MigrationWizardStartup_WhenLoaded_SchedulesTargetCheckInsteadOfPreviewingSynchronously() + { + // Tests that migration target detection does not block the synchronous Editor startup hook. + string migrationWizardSource = ReadProductionSource( + "Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs"); + + Assert.That(migrationWizardSource, Does.Contain("EditorApplication.delayCall += TryShowOnMigrationTargets;")); + Assert.That(migrationWizardSource, Does.Not.Contain("\n TryShowOnMigrationTargets();")); + } + [Test] public void ProjectAsmdefs_WhenLoaded_DoNotReferenceRemovedSharedAssemblyGuid() { diff --git a/Assets/Tests/Editor/SetupWizardWindowTests.cs b/Assets/Tests/Editor/SetupWizardWindowTests.cs index 68a5eaa98..b47a3b80a 100644 --- a/Assets/Tests/Editor/SetupWizardWindowTests.cs +++ b/Assets/Tests/Editor/SetupWizardWindowTests.cs @@ -65,7 +65,10 @@ public void ShouldAutoShowForVersion_ReturnsExpectedValue( bool expected) { bool shouldAutoShow = - SetupWizardWindow.ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow); + SetupWizardWindow.ShouldAutoShowForVersion( + currentVersion, + lastSeenVersion, + suppressAutoShow); Assert.That(shouldAutoShow, Is.EqualTo(expected)); } diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs new file mode 100644 index 000000000..38650afdf --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs @@ -0,0 +1,2375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies project-wide V3 third-party tool migration file behavior. + /// + public sealed class ThirdPartyToolMigrationFileServiceTests + { + [Test] + public void ThirdPartyToolMigrationPreview_WhenInputFilePathsMutate_KeepsSnapshot() + { + // Verifies that preview file paths cannot be changed by mutating the constructor input array. + string[] filePaths = + { + "Assets/VendorTools/HelloTool.cs" + }; + ThirdPartyToolMigrationPreview preview = new(1, 1, filePaths); + + filePaths[0] = "Assets/Changed.cs"; + preview.FilePaths[0] = "Assets/ChangedAgain.cs"; + + Assert.That(preview.FilePaths, Is.EqualTo(new[] { "Assets/VendorTools/HelloTool.cs" })); + } + + [Test] + public void ThirdPartyToolMigrationResult_WhenInputFilePathsMutate_KeepsSnapshot() + { + // Verifies that result file paths cannot be changed by mutating the constructor input array. + string[] filePaths = + { + "Assets/VendorTools/HelloTool.cs" + }; + ThirdPartyToolMigrationResult result = new(1, 1, filePaths); + + filePaths[0] = "Assets/Changed.cs"; + result.FilePaths[0] = "Assets/ChangedAgain.cs"; + + Assert.That(result.FilePaths, Is.EqualTo(new[] { "Assets/VendorTools/HelloTool.cs" })); + } + + [Test] + public void TryReadJsonObjectForMigration_WhenReadThrowsIOException_ReturnsFalse() + { + // Verifies that migration scans skip unreadable assembly JSON files. + bool success = ThirdPartyToolMigrationFileService.TryReadJsonObjectForMigration( + "Assets/VendorTools/VendorTools.Editor.asmdef", + _ => throw new IOException("locked"), + out JObject jsonObject); + + Assert.That(success, Is.False); + Assert.That(jsonObject, Is.Null); + } + + [Test] + public void TryReadJsonObjectForMigration_WhenReadThrowsUnauthorizedAccessException_ReturnsFalse() + { + // Verifies that migration scans skip assembly JSON files blocked by file permissions. + bool success = ThirdPartyToolMigrationFileService.TryReadJsonObjectForMigration( + "Assets/VendorTools/VendorTools.Editor.asmdef", + _ => throw new UnauthorizedAccessException("denied"), + out JObject jsonObject); + + Assert.That(success, Is.False); + Assert.That(jsonObject, Is.Null); + } + + [Test] + public void ApplyMigration_WhenLegacyToolAssemblyExists_RewritesSourceAndAsmdef() + { + // Verifies that project-wide migration rewrites both custom tool source and its asmdef reference. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(preview.FileCount, Is.EqualTo(2)); + Assert.That(preview.FilePaths.Contains(toolPath), Is.True); + Assert.That(preview.FilePaths.Contains(asmdefPath), Is.True); + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyToolAsmdefHasNoReferencesArray_AddsAsmdefReference() + { + // Verifies that valid minimal asmdefs compile after project-wide migration. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"" +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain(@"""references"": [")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenManualRegistrationExists_AddsApplicationReference() + { + // Verifies that migrated manual registration source keeps access to the V3 registrar assembly. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "ManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +public static class ManualToolRegistration +{ + public static void Register(IUnityTool tool) + { + CustomToolManager.RegisterCustomTool(tool); + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.RegisterCustomTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentManualRegistrationExists_PreservesApplicationReference() + { + // Verifies that mixed assemblies keep current registrar dependencies while legacy tools migrate. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(registrationPath, @"using io.github.hatayama.UnityCliLoop.Application; + +public static class CurrentManualToolRegistration +{ + public static void Register(object tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(registrationPath), Does.Contain("UnityCliLoopToolRegistrar")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentManualRegistrationExistsWithLegacyAsmdefName_AddsApplicationReference() + { + // Verifies that partially migrated registrar code receives the asmdef refs it already requires. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application; + +public static class CurrentManualToolRegistration +{ + public static void Register(object tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"; + File.WriteAllText(registrationPath, registrationSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""uLoopMCP.Editor"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentManualRegistrationExistsWithLegacyAsmdefGuid_AddsContractReferences() + { + // Verifies that partially migrated GUID refs are expanded to the assemblies current registrar APIs expose. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +public static class CurrentManualToolRegistration +{ + public static void Register(IUnityCliLoopTool tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"; + File.WriteAllText(registrationPath, registrationSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentToolContractsExistsWithLegacyAsmdefGuid_RewritesToToolContractsReference() + { + // Verifies that partially migrated tool implementations receive the ToolContracts asmdef reference. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string toolSource = @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"; + File.WriteAllText(toolPath, toolSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(toolPath), Is.EqualTo(toolSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentRegistrarExistsWithToolContractsAsmdefReference_AddsApplicationReference() + { + // Verifies that already-replaced asmdef refs still receive missing current assembly dependencies. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +public static class CurrentManualToolRegistration +{ + public static void Register(IUnityCliLoopTool tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"; + File.WriteAllText(registrationPath, registrationSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:fc3fd32eddbee40e39c2d76dc184957b"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyRegistrarReturnIsUsedWithoutExplicitToolInfo_AddsDomainReference() + { + // Verifies that registrar return types add the Domain dependency even without explicit ToolInfo usage. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string registrationPath = Path.Combine(toolDirectory, "ToolCountLabel.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(registrationPath, @"using io.github.hatayama.uLoopMCP; + +public static class ToolCountLabel +{ + public static string GetLabel() + { + return $""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}""; + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(registrationPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenDomainMetadataExistsWithoutManualRegistration_AddsDomainReference() + { + // Verifies that ToolInfo-only metadata helpers migrate their asmdef dependency. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(metadataPath, @"using io.github.hatayama.uLoopMCP; + +public static class ToolMetadataProvider +{ + public static ToolInfo[] GetTools() + { + return new ToolInfo[0]; + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(metadataPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyDomainHelperExists_AddsDomainReference() + { + // Verifies that helpers moved to Domain migrate their source and asmdef dependency together. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string helperPath = Path.Combine(toolDirectory, "ToolHelper.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(helperPath, @"using io.github.hatayama.uLoopMCP; + +public static class ToolHelper +{ + public static ServiceResult CreateResult() + { + return ServiceResult.SuccessResult(1); + } + + public static ToolSettingsCatalogItem[] GetCatalog() + { + return new ToolSettingsCatalogItem[0]; + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(helperPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult")); + Assert.That(File.ReadAllText(helperPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenCurrentDomainMetadataExistsWithLegacyAsmdefGuid_AddsDomainReference() + { + // Verifies that partially migrated metadata helpers receive the asmdef refs they already require. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string metadataSource = @"using io.github.hatayama.UnityCliLoop.Domain; + +public static class ToolMetadataProvider +{ + public static ToolInfo[] GetTools() + { + return new ToolInfo[0]; + } +}"; + File.WriteAllText(metadataPath, metadataSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(metadataPath), Is.EqualTo(metadataSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesCurrentDomainGlobalUsingAndSplitMetadata_AddsDomainReference() + { + // Verifies that split V3 Domain metadata files receive their required asmdef references. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string globalUsingSource = "global using io.github.hatayama.UnityCliLoop.Domain;"; + string metadataSource = @"public static class ToolMetadataProvider +{ + public static ToolInfo[] GetTools() + { + return new ToolInfo[0]; + } +}"; + File.WriteAllText(globalUsingPath, globalUsingSource); + File.WriteAllText(metadataPath, metadataSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(globalUsingPath), Is.EqualTo(globalUsingSource)); + Assert.That(File.ReadAllText(metadataPath), Is.EqualTo(metadataSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_AppliesAsmdefRepair() + { + // Verifies that unrelated malformed asmdefs do not block applying repairable asmdef changes. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenUnrelatedAsmdefJsonIsMalformedAndAsmrefExists_AppliesAsmdefRepair() + { + // Verifies that malformed asmdefs do not block asmref-aware migration scans. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{"); + + string asmrefDirectory = Path.Combine(projectRoot, "Assets", "VendorToolParts"); + Directory.CreateDirectory(asmrefDirectory); + File.WriteAllText(Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref"), @"{ + ""reference"": ""VendorTools.Editor"" +}"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenUnrelatedAsmrefJsonIsMalformed_AppliesAsmdefRepair() + { + // Verifies that malformed asmrefs do not block migration scans for valid assemblies. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmref"), "{"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "CurrentHelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitContractFiles() + { + // Verifies that schema files relying on global legacy usings migrate with their assembly. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string schemaPath = Path.Combine(toolDirectory, "HelloSchema.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(schemaPath, "public sealed class HelloSchema : BaseToolSchema {}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(schemaPath), Does.Contain("UnityCliLoopToolSchema")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_KeepsUnrelatedFiles() + { + // Verifies that assembly-level migration does not rename unrelated project types. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string unrelatedPath = Path.Combine(toolDirectory, "BaseToolSchema.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string unrelatedSource = "public sealed class BaseToolSchema {}"; + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(unrelatedPath, unrelatedSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesFileScopedLegacyUsing_KeepsUnrelatedBareTypes() + { + // Verifies that file-scoped imports do not grant legacy type context to sibling files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string unrelatedPath = Path.Combine(toolDirectory, "UnrelatedMetadata.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string unrelatedSource = + "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }"; + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(unrelatedPath, unrelatedSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource)); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesGenericSplitContractFiles() + { + // Verifies that collection-shaped legacy type references migrate with their assembly. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string listPath = Path.Combine(toolDirectory, "ToolList.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + listPath, + "public sealed class ToolList { public System.Collections.Generic.List Tools; }"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(listPath), Does.Contain( + "System.Collections.Generic.List")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitDomainHelpers() + { + // Verifies that split Domain helper files receive source and asmdef migration through global usings. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string helperPath = Path.Combine(toolDirectory, "ToolHelper.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + helperPath, + "public static class ToolHelper { public static ServiceResult Create() => null; }"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(helperPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult Create")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitReturnContractTypes() + { + // Verifies that helper return types relying on global legacy usings migrate with their assembly. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string responseFactoryPath = Path.Combine(toolDirectory, "ResponseFactory.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + responseFactoryPath, + "public static class ResponseFactory { public static BaseToolResponse Create() => null; }"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(responseFactoryPath), Does.Contain("UnityCliLoopToolResponse Create")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsing_RewritesSplitBareToolAttributes() + { + // Verifies that attribute-only files relying on global legacy usings migrate with their assembly. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string toolAttributePath = Path.Combine(toolDirectory, "HelloTool.Attribute.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + toolAttributePath, + "[McpTool]\npublic sealed partial class HelloTool\n{\n}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(toolAttributePath), Does.Contain("[UnityCliLoopTool]")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyAlias_RewritesSplitAliasQualifiedFiles() + { + // Verifies that global namespace aliases provide legacy context to sibling files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string toolAttributePath = Path.Combine(toolDirectory, "HelloTool.Attribute.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using Old = io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + toolAttributePath, + "[Old.McpTool]\npublic sealed partial class HelloTool\n{\n private Old.IUnityTool tool;\n}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(toolAttributePath), Does.Contain( + "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool")); + Assert.That(File.ReadAllText(toolAttributePath), Does.Contain("Old.IUnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyAlias_RewritesSplitAliasQualifiedContractTypes() + { + // Verifies that alias-qualified legacy contract types migrate in sibling files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string responseFactoryPath = Path.Combine(toolDirectory, "ResponseFactory.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using Old = io.github.hatayama.uLoopMCP;"); + File.WriteAllText( + responseFactoryPath, + "public static class ResponseFactory { public static Old.BaseToolResponse Create() => null; }"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(responseFactoryPath), Does.Contain("Old.UnityCliLoopToolResponse Create")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyToolInfoAlias_RewritesSplitAliasConstructors() + { + // Verifies that global type aliases provide constructor migration context to sibling files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string metadataPath = Path.Combine(toolDirectory, "ToolMetadataProvider.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText( + globalUsingPath, + "global using LegacyToolInfo = io.github.hatayama.uLoopMCP.ToolInfo;"); + File.WriteAllText( + metadataPath, + @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +public static class ToolMetadataProvider +{ + public static LegacyToolInfo Create(ToolParameterSchema schema) + { + return new LegacyToolInfo(""hello"", ""description"", schema); + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain( + "global using LegacyToolInfo = io.github.hatayama.UnityCliLoop.Domain.ToolInfo;")); + Assert.That(File.ReadAllText(metadataPath), Does.Contain( + "new io.github.hatayama.UnityCliLoop.Domain.ToolInfo(\"hello\", schema)")); + Assert.That(File.ReadAllText(metadataPath), Does.Not.Contain("\"description\", schema")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyToolInfoAlias_KeepsUnrelatedBareTypes() + { + // Verifies that ToolInfo type aliases do not grant full legacy namespace context to sibling files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string unrelatedPath = Path.Combine(toolDirectory, "UnrelatedMetadata.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string unrelatedSource = "public sealed class UnrelatedMetadata { public BaseToolSchema Schema; }"; + File.WriteAllText( + globalUsingPath, + "global using LegacyToolInfo = io.github.hatayama.uLoopMCP.ToolInfo;"); + File.WriteAllText(unrelatedPath, unrelatedSource); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain( + "global using LegacyToolInfo = io.github.hatayama.UnityCliLoop.Domain.ToolInfo;")); + Assert.That(File.ReadAllText(unrelatedPath), Is.EqualTo(unrelatedSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyToolExistsUnderAsmref_RewritesReferencedAsmdef() + { + // Verifies that asmref folders mark the referenced asmdef as the migrated assembly. + string projectRoot = CreateProjectRoot(); + try + { + string asmdefDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + string asmrefDirectory = Path.Combine(projectRoot, "Assets", "VendorToolParts"); + Directory.CreateDirectory(asmdefDirectory); + Directory.CreateDirectory(asmrefDirectory); + string toolPath = Path.Combine(asmrefDirectory, "HelloTool.cs"); + string asmrefPath = Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref"); + string asmdefPath = Path.Combine(asmdefDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +}"); + File.WriteAllText(asmrefPath, @"{ + ""reference"": ""VendorTools.Editor"" +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenNestedAsmrefOverridesAncestorAsmdef_RewritesReferencedAsmdef() + { + // Verifies that nested asmref folders use their referenced assembly instead of an ancestor asmdef. + string projectRoot = CreateProjectRoot(); + try + { + string ancestorDirectory = Path.Combine(projectRoot, "Assets", "OuterAssembly"); + string asmrefDirectory = Path.Combine(ancestorDirectory, "VendorToolParts"); + string targetDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(asmrefDirectory); + Directory.CreateDirectory(targetDirectory); + string ancestorAsmdefPath = Path.Combine(ancestorDirectory, "OuterAssembly.Editor.asmdef"); + string toolPath = Path.Combine(asmrefDirectory, "HelloTool.cs"); + string asmrefPath = Path.Combine(asmrefDirectory, "VendorTools.Editor.asmref"); + string targetAsmdefPath = Path.Combine(targetDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(ancestorAsmdefPath, @"{ + ""name"": ""OuterAssembly.Editor"", + ""references"": [] +}"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +}"); + File.WriteAllText(asmrefPath, @"{ + ""reference"": ""VendorTools.Editor"" +}"); + File.WriteAllText(targetAsmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(targetAsmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(ancestorAsmdefPath), Does.Not.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenNoAsmdefAssemblyUsesGlobalLegacyUsing_RewritesSplitContractFiles() + { + // Verifies that predefined assemblies get the same assembly-level migration as asmdef assemblies. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "Editor", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string schemaPath = Path.Combine(toolDirectory, "HelloSchema.cs"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(schemaPath, "public sealed class HelloSchema : BaseToolSchema {}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(schemaPath), Does.Contain("UnityCliLoopToolSchema")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenNoAsmdefEditorAssemblyUsesGlobalLegacyUsing_KeepsRuntimeFiles() + { + // Verifies that predefined editor migration does not rewrite the separate runtime assembly. + string projectRoot = CreateProjectRoot(); + try + { + string editorDirectory = Path.Combine(projectRoot, "Assets", "Editor", "VendorTools"); + string runtimeDirectory = Path.Combine(projectRoot, "Assets", "Scripts"); + Directory.CreateDirectory(editorDirectory); + Directory.CreateDirectory(runtimeDirectory); + string globalUsingPath = Path.Combine(editorDirectory, "GlobalUsings.cs"); + string runtimePath = Path.Combine(runtimeDirectory, "BaseToolSchema.cs"); + string runtimeSource = "public sealed class BaseToolSchema {}"; + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(runtimePath, runtimeSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(runtimePath), Is.EqualTo(runtimeSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenNoAsmdefFirstPassRuntimeUsesGlobalLegacyUsing_KeepsRegularRuntimeFiles() + { + // Verifies that Unity predefined firstpass runtime scripts do not migrate regular runtime siblings. + string projectRoot = CreateProjectRoot(); + try + { + string firstPassDirectory = Path.Combine(projectRoot, "Assets", "Plugins"); + string runtimeDirectory = Path.Combine(projectRoot, "Assets", "Scripts"); + Directory.CreateDirectory(firstPassDirectory); + Directory.CreateDirectory(runtimeDirectory); + string globalUsingPath = Path.Combine(firstPassDirectory, "GlobalUsings.cs"); + string runtimePath = Path.Combine(runtimeDirectory, "UnrelatedMetadata.cs"); + string runtimeSource = + "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }"; + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(runtimePath, runtimeSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(runtimePath), Is.EqualTo(runtimeSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenNoAsmdefFirstPassEditorUsesGlobalLegacyUsing_KeepsRegularEditorFiles() + { + // Verifies that Unity predefined firstpass editor scripts do not migrate regular editor siblings. + string projectRoot = CreateProjectRoot(); + try + { + string firstPassEditorDirectory = Path.Combine(projectRoot, "Assets", "Plugins", "Editor"); + string editorDirectory = Path.Combine(projectRoot, "Assets", "Editor"); + Directory.CreateDirectory(firstPassEditorDirectory); + Directory.CreateDirectory(editorDirectory); + string globalUsingPath = Path.Combine(firstPassEditorDirectory, "GlobalUsings.cs"); + string editorPath = Path.Combine(editorDirectory, "UnrelatedMetadata.cs"); + string editorSource = + "public sealed class UnrelatedMetadata { public BaseToolResponse Response; }"; + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(editorPath, editorSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(globalUsingPath), Does.Contain("io.github.hatayama.UnityCliLoop.ToolContracts")); + Assert.That(File.ReadAllText(editorPath), Is.EqualTo(editorSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenAssemblyUsesGlobalLegacyUsingAndSplitManualRegistration_AddsApplicationReference() + { + // Verifies that manual registration files relying on assembly-level legacy detection get required refs. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string globalUsingPath = Path.Combine(toolDirectory, "GlobalUsings.cs"); + string registrationPath = Path.Combine(toolDirectory, "ManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(globalUsingPath, "global using io.github.hatayama.uLoopMCP;"); + File.WriteAllText(registrationPath, @"public static class ManualToolRegistration +{ + public static void Register(IUnityTool tool) + { + CustomToolManager.RegisterCustomTool(tool); + } +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(3)); + Assert.That(File.ReadAllText(registrationPath), Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.RegisterCustomTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(File.ReadAllText(asmdefPath), Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenUnrelatedCustomToolManagerExists_KeepsAsmdef() + { + // Verifies that project-owned type names do not trigger migration dependencies by themselves. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string sourcePath = Path.Combine(toolDirectory, "CustomToolManager.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string source = "public sealed class CustomToolManager {}"; + string asmdefSource = @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [] +}"; + File.WriteAllText(sourcePath, source); + File.WriteAllText(asmdefPath, asmdefSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + Assert.That(result.FileCount, Is.EqualTo(0)); + Assert.That(File.ReadAllText(sourcePath), Is.EqualTo(source)); + Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenUserSidecarFilesExist_PreservesSidecarFiles() + { + // Verifies that project-wide source rewrites do not treat user sidecars as migration scratch files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string tempSidecarPath = toolPath + ".tmp"; + string backupSidecarPath = toolPath + ".bak"; + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +}"); + File.WriteAllText(tempSidecarPath, "user temp sidecar"); + File.WriteAllText(backupSidecarPath, "user backup sidecar"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(1)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(tempSidecarPath), Is.EqualTo("user temp sidecar")); + Assert.That(File.ReadAllText(backupSidecarPath), Is.EqualTo("user backup sidecar")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenLegacyToolExistsUnderExcludedDirectory_IgnoresFile() + { + // Verifies that generated Unity folders are not scanned for third-party tools. + string projectRoot = CreateProjectRoot(); + try + { + string generatedDirectory = Path.Combine(projectRoot, "Library"); + Directory.CreateDirectory(generatedDirectory); + File.WriteAllText( + Path.Combine(generatedDirectory, "GeneratedTool.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class GeneratedTool {}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenLegacyToolExistsOutsideAssets_IgnoresFile() + { + // Verifies that repository tooling outside Unity source roots is not migrated. + string projectRoot = CreateProjectRoot(); + try + { + string toolsDirectory = Path.Combine(projectRoot, "tools"); + Directory.CreateDirectory(toolsDirectory); + File.WriteAllText( + Path.Combine(toolsDirectory, "LegacyToolFixture.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class LegacyToolFixture {}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyToolExistsUnderAssetsPackagesDirectory_RewritesAssetsFiles() + { + // Verifies that only Unity's project-root Packages directory is excluded from migration scans. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "Packages", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(result.FileCount, Is.EqualTo(2)); + Assert.That(File.ReadAllText(toolPath), Does.Contain("UnityCliLoopTool")); + Assert.That(File.ReadAllText(asmdefPath), Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void ApplyMigration_WhenLegacyToolExistsUnderPackagesDirectory_KeepsPackageFiles() + { + // Verifies that Package Manager and embedded package contents are not rewritten in place. + string projectRoot = CreateProjectRoot(); + try + { + string packageDirectory = Path.Combine( + projectRoot, + "Packages", + "com.example.legacy-tool", + "Editor"); + Directory.CreateDirectory(packageDirectory); + string toolPath = Path.Combine(packageDirectory, "PackageTool.cs"); + string asmdefPath = Path.Combine(packageDirectory, "LegacyPackageTool.Editor.asmdef"); + string toolSource = @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class PackageTool : AbstractUnityTool +{ +} + +public sealed class PackageToolSchema : BaseToolSchema +{ +} + +public sealed class PackageToolResponse : BaseToolResponse +{ +}"; + string asmdefSource = @"{ + ""name"": ""LegacyPackageTool.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + File.WriteAllText(toolPath, toolSource); + File.WriteAllText(asmdefPath, asmdefSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + ThirdPartyToolMigrationResult result = service.ApplyMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + Assert.That(result.FileCount, Is.EqualTo(0)); + Assert.That(File.ReadAllText(toolPath), Is.EqualTo(toolSource)); + Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenLegacyToolExistsUnderAssets_ReturnsTrue() + { + // Verifies that startup detection can find V2 custom tools without building a migration preview. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "HelloTool.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class HelloTool {}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenLegacyAsmdefNameExistsUnderAssets_ReturnsTrue() + { + // Verifies that startup detection catches old assembly names without relying on source files. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""uLoopMCP.Editor"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenCurrentToolContractsExistsWithLegacyAsmdefGuid_ReturnsTrue() + { + // Verifies that startup detection catches partially migrated tools that still need asmdef repair. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "CurrentHelloTool.cs"), + @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenUnrelatedAsmdefJsonIsMalformed_StillDetectsAsmdefRepair() + { + // Verifies that unrelated malformed asmdefs do not block startup detection for repairable tools. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "CurrentHelloTool.cs"), + @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenAssemblyUsesCurrentDomainGlobalUsingAndSplitMetadata_ReturnsTrue() + { + // Verifies that startup detection treats current Domain global usings as assembly-scoped. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "GlobalUsings.cs"), + "global using io.github.hatayama.UnityCliLoop.Domain;"); + File.WriteAllText( + Path.Combine(toolDirectory, "ToolMetadataProvider.cs"), + @"public static class ToolMetadataProvider +{ + public static ToolInfo[] GetTools() + { + return new ToolInfo[0]; + } +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenCurrentApplicationGuidReferenceExists_ReturnsFalse() + { + // Verifies that current V3 Application references do not trigger the startup migration prompt. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"), + @"using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +public static class CurrentManualToolRegistration +{ + public static void Register(IUnityCliLoopTool tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"", + ""GUID:fc3fd32eddbee40e39c2d76dc184957b"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenCurrentRegistrarReturnMissingDomainReference_ReturnsTrue() + { + // Verifies that startup detection catches registrar return calls that still need Domain asmdef refs. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "ToolCountLabel.cs"), + @"using io.github.hatayama.UnityCliLoop.Application; + +public static class ToolCountLabel +{ + public static string GetLabel() + { + return $""Tools: {UnityCliLoopToolRegistrar.GetRegisteredCustomTools().Length}""; + } +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"", + ""GUID:fc3fd32eddbee40e39c2d76dc184957b"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.True); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task HasMigrationTargetsAsync_WhenLegacyToolExistsUnderPackagesDirectory_ReturnsFalse() + { + // Verifies that startup detection keeps Package Manager contents outside migration scope. + string projectRoot = CreateProjectRoot(); + try + { + string packageDirectory = Path.Combine( + projectRoot, + "Packages", + "com.example.legacy-tool", + "Editor"); + Directory.CreateDirectory(packageDirectory); + File.WriteAllText( + Path.Combine(packageDirectory, "PackageTool.cs"), + "using io.github.hatayama.uLoopMCP; [McpTool] public sealed class PackageTool {}"); + + ThirdPartyToolMigrationFileService service = new(); + + bool hasTargets = await service.HasMigrationTargetsAsync(projectRoot, CancellationToken.None); + + Assert.That(hasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenCacheIsInvalidated_RefreshesChangedProject() + { + // Verifies that repeated setup wizard previews can reuse scans and refresh after invalidation. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview firstPreview = service.PreviewMigration(projectRoot); + File.WriteAllText(toolPath, "public sealed class HelloTool {}"); + ThirdPartyToolMigrationPreview cachedPreview = service.PreviewMigration(projectRoot); + service.InvalidatePreviewCache(); + ThirdPartyToolMigrationPreview refreshedPreview = service.PreviewMigration(projectRoot); + + Assert.That(firstPreview.HasTargets, Is.True); + Assert.That(cachedPreview.FileCount, Is.EqualTo(firstPreview.FileCount)); + Assert.That(refreshedPreview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationAsync_WhenProjectChangesAfterCachedPreview_RefreshesCurrentProject() + { + // Verifies that migration wizard Refresh reads the current files instead of reusing a stale preview. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + Progress progress = new(); + ThirdPartyToolMigrationPreview firstPreview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + File.WriteAllText(toolPath, "public sealed class HelloTool {}"); + + ThirdPartyToolMigrationPreview refreshedPreview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(firstPreview.HasTargets, Is.True); + Assert.That(refreshedPreview.HasTargets, Is.False); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationAsync_WhenUnrelatedAsmdefJsonIsMalformedAndNoAsmrefs_PreviewsAsmdefRepair() + { + // Verifies that unrelated malformed asmdefs do not block previewing repairable asmdef changes. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmdef"), "{"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "CurrentHelloTool.cs"), + @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + Progress progress = new(); + + ThirdPartyToolMigrationPreview preview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(preview.FileCount, Is.EqualTo(1)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationAsync_WhenUnrelatedAsmrefJsonIsMalformed_PreviewsAsmdefRepair() + { + // Verifies that malformed asmrefs do not block async preview scans for repairable assemblies. + string projectRoot = CreateProjectRoot(); + try + { + string unrelatedDirectory = Path.Combine(projectRoot, "Assets", "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + File.WriteAllText(Path.Combine(unrelatedDirectory, "Broken.asmref"), "{"); + + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + File.WriteAllText( + Path.Combine(toolDirectory, "CurrentHelloTool.cs"), + @"using io.github.hatayama.UnityCliLoop.ToolContracts; + +[UnityCliLoopTool] +public sealed class CurrentHelloTool : UnityCliLoopTool +{ +} + +public sealed class HelloSchema : UnityCliLoopToolSchema +{ +} + +public sealed class HelloResponse : UnityCliLoopToolResponse +{ +}"); + File.WriteAllText( + Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"), + @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + ThirdPartyToolMigrationFileService service = new(); + Progress progress = new(); + + ThirdPartyToolMigrationPreview preview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(preview.FileCount, Is.EqualTo(1)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public void PreviewMigration_WhenCurrentApplicationGuidReferenceExists_KeepsAsmdef() + { + // Verifies that V3 Application references are not reported as legacy asmdef migration. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + string registrationPath = Path.Combine(toolDirectory, "CurrentManualToolRegistration.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + string registrationSource = @"using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +public static class CurrentManualToolRegistration +{ + public static void Register(IUnityCliLoopTool tool) + { + UnityCliLoopToolRegistrar.RegisterCustomTool(tool); + } +}"; + string asmdefSource = @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"", + ""GUID:fc3fd32eddbee40e39c2d76dc184957b"" + ] +}"; + File.WriteAllText(registrationPath, registrationSource); + File.WriteAllText(asmdefPath, asmdefSource); + + ThirdPartyToolMigrationFileService service = new(); + ThirdPartyToolMigrationPreview preview = service.PreviewMigration(projectRoot); + + Assert.That(preview.HasTargets, Is.False); + Assert.That(File.ReadAllText(registrationPath), Is.EqualTo(registrationSource)); + Assert.That(File.ReadAllText(asmdefPath), Is.EqualTo(asmdefSource)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + [Test] + public async Task PreviewMigrationAsync_WhenProjectContainsManyFiles_ReportsIncrementalProgress() + { + // Verifies that setup wizard previews report progress before the full scan finishes. + string projectRoot = CreateProjectRoot(); + try + { + string toolDirectory = Path.Combine(projectRoot, "Assets", "VendorTools"); + Directory.CreateDirectory(toolDirectory); + for (int i = 0; i < 48; i++) + { + File.WriteAllText( + Path.Combine(toolDirectory, $"Plain{i}.cs"), + $"public sealed class Plain{i} {{}}"); + } + + string toolPath = Path.Combine(toolDirectory, "HelloTool.cs"); + string asmdefPath = Path.Combine(toolDirectory, "VendorTools.Editor.asmdef"); + File.WriteAllText(toolPath, @"using io.github.hatayama.uLoopMCP; + +[McpTool] +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ +} + +public sealed class HelloResponse : BaseToolResponse +{ +}"); + File.WriteAllText(asmdefPath, @"{ + ""name"": ""VendorTools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"); + + List reports = new(); + RecordingMigrationProgress progress = new(reports); + ThirdPartyToolMigrationFileService service = new(); + + ThirdPartyToolMigrationPreview preview = + await service.PreviewMigrationAsync(projectRoot, progress, CancellationToken.None); + + Assert.That(preview.HasTargets, Is.True); + Assert.That(reports.Count, Is.GreaterThan(1)); + Assert.That( + reports.Any(report => + report.TotalItemCount > 0 && + report.ProcessedItemCount > 0 && + report.ProcessedItemCount < report.TotalItemCount), + Is.True); + ThirdPartyToolMigrationProgress lastReport = reports[reports.Count - 1]; + Assert.That(lastReport.ProcessedItemCount, Is.EqualTo(lastReport.TotalItemCount)); + } + finally + { + Directory.Delete(projectRoot, recursive: true); + } + } + + private static string CreateProjectRoot() + { + string projectRoot = Path.Combine( + Path.GetTempPath(), + "UnityCliLoopTests", + "ThirdPartyToolMigration", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "ProjectSettings")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Assets")); + return projectRoot; + } + + private sealed class RecordingMigrationProgress : IProgress + { + private readonly List _reports; + + public RecordingMigrationProgress(List reports) + { + Assert.That(reports, Is.Not.Null); + + _reports = reports; + } + + public void Report(ThirdPartyToolMigrationProgress value) + { + _reports.Add(value); + } + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta new file mode 100644 index 000000000..6579d5119 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationFileServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a4d3c9261bd7430ea2876e1cc2881dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs new file mode 100644 index 000000000..812c905e4 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs @@ -0,0 +1,1494 @@ +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies V3 third-party tool migration rewrite rules. + /// + public sealed class ThirdPartyToolMigrationRulesTests + { + [Test] + public void MigrateCSharpSource_WhenLegacyToolApiIsUsed_RewritesToV3Contracts() + { + // Verifies that V2 custom tool source is rewritten to the V3 public contract names. + string source = @"using io.github.hatayama.uLoopMCP; + +namespace Samples +{ + [McpTool(Description = ""hello"")] + public sealed class HelloTool : AbstractUnityTool + { + } + + public sealed class HelloSchema : BaseToolSchema + { + } + + public sealed class HelloResponse : BaseToolResponse + { + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("using io.github.hatayama.UnityCliLoop.ToolContracts;")); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]")); + Assert.That(result.Content, Does.Contain("UnityCliLoopTool")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolResponse")); + Assert.That(result.Content, Does.Not.Contain("uLoopMCP")); + Assert.That(result.ReplacementCount, Is.EqualTo(5)); + } + + [Test] + public void MigrateCSharpSource_WhenNoLegacyToolApiIsUsed_KeepsContent() + { + // Verifies that unrelated C# files are not rewritten. + string source = "public sealed class PlainClass {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + Assert.That(result.ReplacementCount, Is.EqualTo(0)); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeSuffixHasArguments_DropsUnsupportedArguments() + { + // Verifies that old attribute suffix syntax does not keep removed V3 attribute arguments. + string source = + "using io.github.hatayama.uLoopMCP;\n" + + "[McpToolAttribute(Description = \"hello\")] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]")); + Assert.That(result.Content, Does.Not.Contain("Description")); + Assert.That(result.Content, Does.Not.Contain("UnityCliLoopToolAttribute(")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeHasSupportedArguments_PreservesSupportedArguments() + { + // Verifies that migration drops removed metadata without changing supported tool visibility metadata. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool(Description = \"hello\", DisplayDevelopmentOnly = true)] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool(DisplayDevelopmentOnly = true)]")); + Assert.That(result.Content, Does.Not.Contain("Description")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeDescriptionIsRawStringWithComma_DropsDescriptionArgument() + { + // Verifies that commas inside raw string literals do not split legacy attribute arguments. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool(Description = \"\"\"\"say \"hi\", world\"\"\"\", DisplayDevelopmentOnly = true)] " + + "public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool(DisplayDevelopmentOnly = true)]")); + Assert.That(result.Content, Does.Not.Contain("Description")); + Assert.That(result.Content, Does.Not.Contain("\"\"\"\"say \"hi\", world\"\"\"\"")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeHasSecurityArgument_RewritesSecurityArgument() + { + // Verifies that supported security metadata keeps compiling after the enum rename. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool(RequiredSecuritySetting = SecuritySettings.None)] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "[UnityCliLoopTool(RequiredSecuritySetting = UnityCliLoopSecuritySetting.None)]")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeUsesSecurityAlias_KeepsAliasIdentifier() + { + // Verifies that aliases containing the old enum name are not rewritten as partial identifiers. + string source = @"using LegacySecuritySettings = io.github.hatayama.uLoopMCP.SecuritySettings; +using io.github.hatayama.uLoopMCP; + +[McpTool(RequiredSecuritySetting = LegacySecuritySettings.None)] +public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "using LegacySecuritySettings = io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopSecuritySetting;")); + Assert.That(result.Content, Does.Contain( + "[UnityCliLoopTool(RequiredSecuritySetting = LegacySecuritySettings.None)]")); + Assert.That(result.Content, Does.Not.Contain("LegacyUnityCliLoopSecuritySetting")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeSharesAttributeList_RewritesOnlyLegacyToolEntry() + { + // Verifies that valid C# attribute lists migrate the tool attribute without dropping sibling attributes. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool(Description = \"hello\"), System.Obsolete] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool, System.Obsolete]")); + Assert.That(result.Content, Does.Not.Contain("McpTool")); + Assert.That(result.Content, Does.Not.Contain("Description")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolDescriptionContainsBracket_RewritesAttribute() + { + // Verifies that description text cannot terminate the attribute-list scan early. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool(Description = \"Use [foo]\")] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("[UnityCliLoopTool]")); + Assert.That(result.Content, Does.Not.Contain("McpTool")); + Assert.That(result.Content, Does.Not.Contain("Description")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeIsQualified_RewritesQualifiedAttribute() + { + // Verifies that tools without a namespace import still receive a compilable V3 attribute. + string source = + "[io.github.hatayama.uLoopMCP.McpToolAttribute(Description = \"hello\")] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool]")); + Assert.That(result.Content, Does.Not.Contain("McpTool")); + Assert.That(result.Content, Does.Not.Contain("Description")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeIsGlobalQualified_RewritesGlobalQualifiedAttribute() + { + // Verifies that global-qualified V2 attributes do not bypass argument cleanup. + string source = + "[global::io.github.hatayama.uLoopMCP.McpTool(Description = \"hello\")] public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool]")); + Assert.That(result.Content, Does.Not.Contain("McpTool")); + Assert.That(result.Content, Does.Not.Contain("Description")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolAttributeIsAliasQualified_RewritesAliasQualifiedAttribute() + { + // Verifies that namespace alias attribute shorthand migrates to a resolvable V3 attribute. + string source = @"using Old = io.github.hatayama.uLoopMCP; + +[Old.McpTool(DisplayDevelopmentOnly = true)] +public sealed class HelloTool {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "[io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool(DisplayDevelopmentOnly = true)]")); + Assert.That(result.Content, Does.Not.Contain("Old.McpTool")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyPublicHelpersAreUsed_RewritesHelperTypes() + { + // Verifies that migrated custom tools keep compiling when they override helper-driven schema behavior. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class HelloTool +{ + public ToolParameterSchema ParameterSchema => ToolParameterSchemaGenerator.FromDto(); + + private void Fail() + { + throw new ParameterValidationException(""bad""); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolParameterSchemaGenerator")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolParameterValidationException")); + Assert.That(result.Content, Does.Not.Match(@"\bToolParameterSchemaGenerator\b")); + Assert.That(result.Content, Does.Not.Match(@"\bParameterValidationException\b")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyMcpConstantsAreUsed_RewritesConstantsType() + { + // Verifies that legacy public constants references resolve to the V3 constants type. + string source = + "using io.github.hatayama.uLoopMCP;\n" + + "\n" + + "public static class ToolConstants\n" + + "{\n" + + " public const string Name = McpConstants.PROJECT_NAME;\n" + + " public static string QualifiedName => io.github.hatayama.uLoopMCP.McpConstants.PROJECT_NAME;\n" + + "}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("UnityCliLoopConstants.PROJECT_NAME")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopConstants.PROJECT_NAME")); + Assert.That(result.Content, Does.Not.Contain("McpConstants")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyRegistrarMetadataIsUsed_RewritesDomainMetadataType() + { + // Verifies that explicit registrar metadata declarations keep compiling after namespace migration. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ManualToolRegistration +{ + public static ToolInfo[] GetTools() + { + return CustomToolManager.GetRegisteredCustomTools(); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyDomainHelpersAreUsed_RewritesDomainHelperTypes() + { + // Verifies that legacy helpers moved to Domain keep compiling after namespace migration. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ToolHelper +{ + public static ServiceResult CreateResult() + { + return ServiceResult.SuccessResult(1); + } + + public static ToolSettingsCatalogItem[] GetCatalog() + { + return new ToolSettingsCatalogItem[0]; + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult.SuccessResult")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog")); + Assert.That(result.Content, Does.Contain( + "new io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[0]")); + Assert.That(result.Content, Does.Not.Contain("ToolContracts.ServiceResult")); + Assert.That(result.Content, Does.Not.Contain("ToolContracts.ToolSettingsCatalogItem")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyToolSettingsCatalogItemConstructorHasDescription_RemovesDescriptionArgument() + { + // Verifies that old settings catalog metadata keeps compiling after the V3 constructor signature change. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ToolSettingsCatalogProvider +{ + public static ToolSettingsCatalogItem Create(bool displayDevelopmentOnly, bool isThirdParty) + { + return new ToolSettingsCatalogItem(""hello"", ""description"", displayDevelopmentOnly, isThirdParty); + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "new io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem(\"hello\", displayDevelopmentOnly, isThirdParty)")); + Assert.That(result.Content, Does.Not.Contain("\"description\", displayDevelopmentOnly")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyDomainHelpersUseNamespaceAlias_RewritesDomainHelperTypes() + { + // Verifies that namespace aliases targeting V2 helpers do not survive as ToolContracts references. + string source = @"using Old = io.github.hatayama.uLoopMCP; + +public static class ToolHelper +{ + public static Old.ServiceResult CreateResult() + { + return Old.ServiceResult.SuccessResult(1); + } + + public static Old.ToolSettingsCatalogItem[] GetCatalog() + { + return new Old.ToolSettingsCatalogItem[0]; + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult CreateResult")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult.SuccessResult")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ToolSettingsCatalogItem[] GetCatalog")); + Assert.That(result.Content, Does.Not.Contain("Old.ServiceResult")); + Assert.That(result.Content, Does.Not.Contain("Old.ToolSettingsCatalogItem")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedString_RewritesInterpolationCode() + { + // Verifies that code inside interpolation holes migrates while literal text stays inert. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ToolCountLabel +{ + public static string GetLabel() + { + return $""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}""; + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + Assert.That(result.Content, Does.Not.Contain("{CustomToolManager")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedVerbatimString_RewritesInterpolationCode() + { + // Verifies that verbatim interpolation holes are treated as code. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ToolCountLabel +{ + public static string GetLabel() + { + return $@""Tools: {CustomToolManager.GetRegisteredCustomTools().Length}""; + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + Assert.That(result.Content, Does.Not.Contain("{CustomToolManager")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideInterpolatedRawString_RewritesInterpolationCode() + { + // Verifies that raw interpolation holes are treated as code. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "\n" + + "public static class ToolCountLabel\n" + + "{\n" + + " public static string GetLabel()\n" + + " {\n" + + " return $\"\"\"Tools: {CustomToolManager.GetRegisteredCustomTools().Length}\"\"\";\n" + + " }\n" + + "}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + Assert.That(result.Content, Does.Not.Contain("{CustomToolManager")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiIsUsedInsideMultiDollarRawString_RewritesInterpolationCode() + { + // Verifies that literal braces stay inert when multiple dollar signs are used. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "\n" + + "public static class ToolCountLabel\n" + + "{\n" + + " public static string GetLabel()\n" + + " {\n" + + " return $$\"\"\"Literal { braces } tools: {{CustomToolManager.GetRegisteredCustomTools().Length}}\"\"\";\n" + + " }\n" + + "}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("Literal { braces }")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar.GetRegisteredCustomTools")); + Assert.That(result.Content, Does.Not.Contain("{{CustomToolManager")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyTextIsUsedInsideNestedInterpolatedStringLiteral_KeepsNestedLiteral() + { + // Verifies that string literals inside interpolation holes do not get rewritten as executable code. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "\n" + + "public static class ToolLabel\n" + + "{\n" + + " public static string GetLabel()\n" + + " {\n" + + " return $\"{Log(\"CustomToolManager\")}\";\n" + + " }\n" + + "\n" + + " private static string Log(string value)\n" + + " {\n" + + " return value;\n" + + " }\n" + + "}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("Log(\"CustomToolManager\")")); + Assert.That(result.Content, Does.Not.Contain("Log(\"io.github.hatayama.UnityCliLoop.Application")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyDomainMetadataIsUsedWithoutRegistrar_RewritesDomainMetadataType() + { + // Verifies that metadata helpers split away from registration code keep compiling after migration. + string source = @"using io.github.hatayama.uLoopMCP; + +public static class ToolMetadataProvider +{ + public static ToolInfo[] GetTools() + { + return new ToolInfo[0]; + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("io.github.hatayama.UnityCliLoop.Domain.ToolInfo[]")); + Assert.That(result.Content, Does.Not.Match(@"(? +{ + private Other.BaseToolResponse response; + private MyGame.SecuritySettings securitySettings; +} + +public sealed class HelloSchema : BaseToolSchema {} + +public sealed class HelloResponse : BaseToolResponse {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("Other.BaseToolResponse")); + Assert.That(result.Content, Does.Contain("MyGame.SecuritySettings")); + Assert.That(result.Content, Does.Contain("UnityCliLoopTool")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema")); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolResponse")); + Assert.That(result.Content, Does.Not.Contain("Other.UnityCliLoopToolResponse")); + Assert.That(result.Content, Does.Not.Contain("MyGame.UnityCliLoopSecuritySetting")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyFileHasUnrelatedToolInfoProperty_KeepsIdentifier() + { + // Verifies that metadata type migration does not rewrite member names. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class HelloTool : AbstractUnityTool +{ + public string ToolInfo { get; } +} + +public sealed class HelloSchema : BaseToolSchema {} + +public sealed class HelloResponse : BaseToolResponse {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("public string ToolInfo { get; }")); + Assert.That(result.Content, Does.Contain("UnityCliLoopTool")); + Assert.That(result.Content, Does.Not.Contain("public string io.github.hatayama.UnityCliLoop.Domain.ToolInfo")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyFileHasMemberNameMatchingLegacyContractType_KeepsIdentifier() + { + // Verifies that contract type migration does not rewrite member names. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class HelloTool : AbstractUnityTool +{ +} + +public sealed class HelloSchema : BaseToolSchema +{ + public SecuritySettings SecuritySettings { get; set; } +} + +public sealed class HelloResponse : BaseToolResponse {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "public UnityCliLoopSecuritySetting SecuritySettings { get; set; }")); + Assert.That(result.Content, Does.Not.Contain( + "UnityCliLoopSecuritySetting UnityCliLoopSecuritySetting")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyContractTypeIsFullyQualified_RewritesFullyQualifiedType() + { + // Verifies that qualified legacy namespace usage still migrates after qualified unrelated types are ignored. + string source = @"public sealed class HelloTool : + io.github.hatayama.uLoopMCP.AbstractUnityTool +{ +} + +public sealed class HelloResponse : io.github.hatayama.uLoopMCP.BaseToolResponse {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopTool")); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.ToolContracts.UnityCliLoopToolResponse")); + Assert.That(result.Content, Does.Not.Contain("uLoopMCP")); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiExistsOnlyInComment_KeepsContent() + { + // Verifies that migration does not rewrite inert documentation comments inside C# files. + string source = "// AbstractUnityTool should not be rewritten here"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyApiExistsOnlyInStringLiteral_KeepsContent() + { + // Verifies that migration does not rewrite test fixture strings or examples inside C# files. + string source = "public const string Example = \"IUnityTool\";"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSource_WhenGenericLegacyNameExistsWithoutLegacyMarker_KeepsContent() + { + // Verifies that unrelated project types are not migrated just because their names resemble old API names. + string source = "public sealed class CustomToolManager { public SecuritySettings Settings; }"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyFileDeclaresCustomToolManagerIdentifier_KeepsIdentifier() + { + // Verifies that registrar migration does not rewrite unrelated declaration identifiers. + string source = @"using io.github.hatayama.uLoopMCP; + +public sealed class CustomToolManager +{ + public void CustomToolManager() + { + } +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("public sealed class CustomToolManager")); + Assert.That(result.Content, Does.Contain("public void CustomToolManager()")); + Assert.That(result.Content, Does.Not.Contain( + "public sealed class io.github.hatayama.UnityCliLoop.Application.UnityCliLoopToolRegistrar")); + } + + [Test] + public void MigrateCSharpSource_WhenBareMcpToolHasNoLegacyMarker_KeepsContent() + { + // Verifies that unrelated attribute types with the same short name do not trigger migration. + string source = @"using Some.Other.Mcp; + +[McpTool] +public sealed class OtherTool +{ +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSource_WhenUnqualifiedLegacyLikeBaseTypeHasNoLegacyMarker_KeepsContent() + { + // Verifies that unrelated base types with the same short name do not trigger migration. + string source = "public sealed class MyResponse : BaseToolResponse {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyNamespacePrefixExists_KeepsContent() + { + // Verifies that namespace matching does not treat prefixes as the V2 namespace. + string source = "using io.github.hatayama.uLoopMCPExtensions;"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateCSharpSourceForLegacyAssembly_WhenFileReliesOnGlobalUsing_RewritesContractTypes() + { + // Verifies that files split away from a legacy global using still migrate inside the same assembly. + string source = "public sealed class HelloSchema : BaseToolSchema {}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly( + source, + hasLegacyAssemblySource: true, + legacyAssemblyAliases: System.Array.Empty(), + legacyAssemblyToolInfoAliases: System.Array.Empty()); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("UnityCliLoopToolSchema")); + } + + [Test] + public void MigrateCSharpSourceForLegacyAssembly_WhenFileReliesOnGlobalUsing_RewritesDomainHelpers() + { + // Verifies that split helper files relying on a legacy global using migrate to Domain types. + string source = + "public static class ToolHelper { public static ServiceResult Create() => null; }"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly( + source, + hasLegacyAssemblySource: true, + legacyAssemblyAliases: System.Array.Empty(), + legacyAssemblyToolInfoAliases: System.Array.Empty()); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain( + "io.github.hatayama.UnityCliLoop.Domain.ServiceResult Create")); + } + + [Test] + public void ContainsLegacyAssemblyScopedApi_WhenGenericLegacyTypesAreUsed_ReturnsTrue() + { + // Verifies that split files using collection-shaped legacy types are migrated with their assembly. + string source = "public sealed class ToolList { public System.Collections.Generic.List Tools; }"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi( + source, + System.Array.Empty()); + + Assert.That(containsLegacyApi, Is.True); + } + + [Test] + public void ContainsLegacyAssemblyScopedApi_WhenLegacyDomainHelpersAreUsed_ReturnsTrue() + { + // Verifies that split files using Domain helpers are migrated with their legacy assembly. + string source = "public static class ToolHelper { public static ServiceResult Create() => null; }"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi( + source, + System.Array.Empty()); + + Assert.That(containsLegacyApi, Is.True); + } + + [Test] + public void MigrateCSharpSource_WhenLegacyNamespaceLikeTextExists_KeepsContent() + { + // Verifies that namespace migration treats dots as literal characters. + string source = "using ioXgithubYhatayamaZUnityCliLoop;"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSource(source); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateAsmdefSource_WhenLegacyReferenceIsUsed_RewritesToToolContractsGuid() + { + // Verifies that a custom tool asmdef points at the V3 ToolContracts assembly. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""uLoopMCP.Editor"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Not.Contain("uLoopMCP.Editor")); + Assert.That(result.ReplacementCount, Is.EqualTo(1)); + } + + [Test] + public void MigrateAsmdefSource_WhenOnlyLegacyGuidIsUsedWithoutLegacySource_KeepsContent() + { + // Verifies that package-owned Application references are not rewritten by GUID alone. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + } + + [Test] + public void MigrateAsmdefSource_WhenLegacyGuidIsUsedWithLegacySource_RewritesToToolContractsGuid() + { + // Verifies that old custom tool assemblies with GUID references are migrated when source confirms old API usage. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: true, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + + [Test] + public void MigrateAsmdefSource_WhenLegacySourceHasNoReferencesArray_AddsToolContractsReference() + { + // Verifies that valid minimal asmdefs receive references needed by migrated source files. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"" +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: true, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain(@"""references"": [")); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + } + + [Test] + public void MigrateAsmdefSource_WhenCurrentToolContractsUsesApplicationGuid_AddsToolContractsGuid() + { + // Verifies that partially migrated custom tool assemblies keep current Application refs by GUID. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: true, + requiresApplicationReference: false, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + + [Test] + public void MigrateAsmdefSource_WhenCurrentApplicationGuidAlreadyExists_DoesNotAddDomainReference() + { + // Verifies that current V3 Application refs do not look like pending legacy migration. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"", + ""GUID:fc3fd32eddbee40e39c2d76dc184957b"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: true, + requiresApplicationReference: true, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + + [Test] + public void MigrateAsmdefSource_WhenManualRegistrationIsUsed_KeepsApplicationReference() + { + // Verifies that migrated manual registration code can reference the V3 registrar assembly. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: true, + requiresToolContractsReference: false, + requiresApplicationReference: true, + requiresDomainReference: false); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + + [Test] + public void MigrateAsmdefSource_WhenDomainMetadataIsUsed_AddsDomainReference() + { + // Verifies that ToolInfo-only helper assemblies can resolve the V3 Domain metadata type. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""GUID:214998e563c124e8a88199b2dd1f522d"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: true, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: true); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + + [Test] + public void MigrateAsmdefSource_WhenCurrentDomainMetadataRequiresDomainReference_AddsToolContractsReference() + { + // Verifies that direct V3 Domain consumers also receive transitive ToolContracts access. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: false, + requiresApplicationReference: false, + requiresDomainReference: true); + + Assert.That(result.Changed, Is.True); + Assert.That(result.Content, Does.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + Assert.That(result.Content, Does.Not.Contain("GUID:214998e563c124e8a88199b2dd1f522d")); + } + + [Test] + public void MigrateAsmdefSource_WhenCurrentReferencesUseAssemblyNames_DoesNotAddDuplicateGuids() + { + // Verifies that name-based V3 references are treated as the same assemblies as their GUID references. + string source = @"{ + ""name"": ""MyCompany.Tools.Editor"", + ""references"": [ + ""UnityCLILoop.ToolContracts"", + ""UnityCLILoop.Application"", + ""UnityCLILoop.Domain"" + ] +}"; + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource: false, + requiresToolContractsReference: true, + requiresApplicationReference: true, + requiresDomainReference: true); + + Assert.That(result.Changed, Is.False); + Assert.That(result.Content, Is.EqualTo(source)); + Assert.That(result.Content, Does.Not.Contain("GUID:fc3fd32eddbee40e39c2d76dc184957b")); + Assert.That(result.Content, Does.Not.Contain("GUID:5c4588558a3624eacbce0f50007cf1eb")); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenLegacyToolApiExists_ReturnsTrue() + { + // Verifies that migration detection is based on public custom tool API usage. + string source = "using io.github.hatayama.uLoopMCP;\n" + + "[McpTool] public sealed class HelloTool : AbstractUnityTool {}"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.True); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenLegacyToolApiExistsOnlyInStringLiteral_ReturnsFalse() + { + // Verifies that inert fixture text does not trigger project migration UI. + string source = "public const string Example = \"[McpTool]\";"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.False); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenLegacyToolApiExistsOnlyInComment_ReturnsFalse() + { + // Verifies that comments do not trigger project migration UI. + string source = "// CustomToolManager"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.False); + } + + [Test] + public void ContainsMigrationCandidateText_WhenPlainSourceExists_ReturnsFalse() + { + // Verifies that unrelated source files can skip expensive migration parsing. + string source = "public sealed class PlainEditorUtility { public int Count; }"; + + bool containsCandidateText = ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source); + + Assert.That(containsCandidateText, Is.False); + } + + [Test] + public void ContainsMigrationCandidateText_WhenLegacyNamespaceExists_ReturnsTrue() + { + // Verifies that old custom tool API source still enters migration parsing. + string source = "using io.github.hatayama.uLoopMCP;"; + + bool containsCandidateText = ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source); + + Assert.That(containsCandidateText, Is.True); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenGenericLegacyNameExistsWithoutLegacyMarker_ReturnsFalse() + { + // Verifies that unrelated source names do not trigger project migration UI. + string source = "public sealed class CustomToolManager {}"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.False); + } + + [Test] + public void ContainsLegacyCSharpApi_WhenOnlyCurrentApiExists_ReturnsFalse() + { + // Verifies that already migrated V3 tools are not detected again. + string source = "[UnityCliLoopTool] public sealed class HelloTool : UnityCliLoopTool {}"; + + bool containsLegacyApi = ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + + Assert.That(containsLegacyApi, Is.False); + } + + [Test] + public void GetExcludedDirectoryNames_IncludesUnityGeneratedDirectories() + { + // Verifies that generated Unity folders are skipped during project-wide scans. + string[] names = ThirdPartyToolMigrationRules.GetExcludedDirectoryNames(); + + Assert.That(names.Contains("Library"), Is.True); + Assert.That(names.Contains("Temp"), Is.True); + Assert.That(names.Contains(".git"), Is.True); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta new file mode 100644 index 000000000..3a1a57758 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationRulesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 421cf9412b7894ec5b5da141229f5598 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs new file mode 100644 index 000000000..7a39535da --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs @@ -0,0 +1,151 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Presentation; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the dedicated V3 custom tool migration wizard. + /// + public sealed class ThirdPartyToolMigrationWizardWindowTests + { + [TestCase(true, true)] + [TestCase(false, false)] + public void ShouldAutoShowForMigrationTargets_ReturnsDetectionResult( + bool hasMigrationTargets, + bool expected) + { + // Verifies that migration startup is controlled only by migration target detection. + bool shouldAutoShow = + ThirdPartyToolMigrationWizardWindow.ShouldAutoShowForMigrationTargets(hasMigrationTargets); + + Assert.That(shouldAutoShow, Is.EqualTo(expected)); + } + + [TestCase( + 1, + "1 file needs V3 custom tool migration.\n" + + "The Unity Console is showing errors because this file still uses the old custom tool API.\n\n" + + "Click Migrate to update it automatically. The errors should disappear after migration.")] + [TestCase( + 3, + "3 files need V3 custom tool migration.\n" + + "The Unity Console is showing errors because these files still use the old custom tool API.\n\n" + + "Click Migrate to update them automatically. The errors should disappear after migration.")] + public void GetMigrationStatusText_WhenTargetsExist_ReturnsFileCount( + int fileCount, + string expectedText) + { + // Verifies that the migration wizard summarizes detected V2 custom tool files. + string text = ThirdPartyToolMigrationWizardWindow.GetMigrationStatusText(fileCount); + + Assert.That(text, Is.EqualTo(expectedText)); + } + + [Test] + public void GetMigrationProgressText_WhenProgressExists_ReturnsCheckCount() + { + // Verifies that the migration wizard reports scan progress while migration targets are unknown. + ThirdPartyToolMigrationProgress progress = new(3, 12); + + string text = ThirdPartyToolMigrationWizardWindow.GetMigrationProgressText(progress); + + Assert.That( + text, + Is.EqualTo("Scanning project for V3 custom tool migration...\n3/12 checks complete.")); + } + + [TestCase(false, true, "Migrate")] + [TestCase(true, true, "Migrating...")] + [TestCase(false, false, "Nothing to migrate")] + public void GetMigrationButtonText_ReturnsExpectedLabel( + bool isMigrating, + bool hasMigrationTargets, + string expectedLabel) + { + // Verifies that the migration action communicates its current state. + string label = ThirdPartyToolMigrationWizardWindow.GetMigrationButtonText( + isMigrating, + hasMigrationTargets); + + Assert.That(label, Is.EqualTo(expectedLabel)); + } + + [Test] + public void PrepareForOpen_PopulatesWindowStateBeforeShowing() + { + // Verifies that startup-created migration windows can preview immediately after CreateGUI. + ThirdPartyToolMigrationWizardWindow window = + ScriptableObject.CreateInstance(); + try + { + Rect position = new(12f, 34f, 360f, 220f); + + ThirdPartyToolMigrationWizardWindow.PrepareForOpen( + window, + "Unity CLI Loop Migration", + position, + true); + + SerializedObject serializedWindow = new(window); + SerializedProperty refreshProperty = + serializedWindow.FindProperty("_shouldRefreshAfterCreateGui"); + + Assert.That(window.titleContent.text, Is.EqualTo("Unity CLI Loop Migration")); + Assert.That(window.position, Is.EqualTo(position)); + Assert.That(window.minSize, Is.EqualTo(new Vector2(360f, 120f))); + Assert.That(refreshProperty, Is.Not.Null); + Assert.That(refreshProperty.boolValue, Is.True); + } + finally + { + Object.DestroyImmediate(window); + } + } + + [Test] + public void WithContentHeight_UsesMeasuredHeightAndPreservesCenter() + { + // Verifies that the migration wizard resizes vertically from measured content height. + Rect initialRect = new(123f, 456f, 400f, 220f); + Vector2 frameSize = new(18f, 28f); + + Rect resizedRect = + ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 180f, frameSize); + + Assert.That(resizedRect.center, Is.EqualTo(initialRect.center)); + Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 208f))); + } + + [Test] + public void WithContentHeight_WhenMeasuredHeightIsSmall_ClampsToMinimumHeight() + { + // Verifies that content fitting keeps the migration wizard from becoming unusably short. + Rect initialRect = new(123f, 456f, 400f, 220f); + Vector2 frameSize = new(18f, 28f); + + Rect resizedRect = + ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 12f, frameSize); + + Assert.That(resizedRect.center, Is.EqualTo(initialRect.center)); + Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 120f))); + } + + [Test] + public void WithContentHeight_WhenCurrentWidthIsWide_UsesSetupWizardWidth() + { + // Verifies that content fitting keeps the migration wizard at Setup Wizard width. + Rect initialRect = new(123f, 456f, 520f, 220f); + Vector2 frameSize = new(18f, 28f); + + Rect resizedRect = + ThirdPartyToolMigrationWizardWindow.WithContentHeight(initialRect, 120f, frameSize); + + Assert.That(resizedRect.center, Is.EqualTo(initialRect.center)); + Assert.That(resizedRect.size, Is.EqualTo(new Vector2(360f, 148f))); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta new file mode 100644 index 000000000..8aef13903 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationWizardWindowTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e92d6d0c7ab84404aa057f047ce14328 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs new file mode 100644 index 000000000..3fcb896ad --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Coordinates V3 migration for third-party custom tools without owning file-system details. + /// + public sealed class ThirdPartyToolMigrationUseCase + { + private readonly IThirdPartyToolMigrationPort _migrationPort; + + public ThirdPartyToolMigrationUseCase(IThirdPartyToolMigrationPort migrationPort) + { + Debug.Assert(migrationPort != null, "migrationPort must not be null"); + + _migrationPort = migrationPort ?? throw new ArgumentNullException(nameof(migrationPort)); + } + + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.PreviewMigration(projectRoot); + } + + public Task PreviewMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + return _migrationPort.PreviewMigrationAsync(projectRoot, progress, ct); + } + + public Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.HasMigrationTargetsAsync(projectRoot, ct); + } + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return _migrationPort.ApplyMigration(projectRoot); + } + } +} diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta new file mode 100644 index 000000000..9586e68b6 --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 792cd64b3299949f9baf51e9a63eea8c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs new file mode 100644 index 000000000..8f6a5fcec --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.Application +{ + /// + /// Stores the third-party tool migration use case for Unity-created presentation objects. + /// + internal static class ThirdPartyToolMigrationUseCaseRegistry + { + private static ThirdPartyToolMigrationUseCase RegisteredUseCase; + + internal static void Register(ThirdPartyToolMigrationUseCase useCase) + { + Debug.Assert(useCase != null, "useCase must not be null"); + + RegisteredUseCase = useCase ?? throw new ArgumentNullException(nameof(useCase)); + } + + internal static ThirdPartyToolMigrationUseCase GetRegisteredUseCase() + { + if (RegisteredUseCase == null) + { + throw new InvalidOperationException("Unity CLI Loop third-party tool migration use case is not registered."); + } + + return RegisteredUseCase; + } + } +} diff --git a/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta new file mode 100644 index 000000000..04db50e14 --- /dev/null +++ b/Packages/src/Editor/Application/UseCases/ThirdPartyToolMigrationUseCaseRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7e9d9700538b41e0ae2cd7846439cab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs index bb9db5e38..7601ed5ac 100644 --- a/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs +++ b/Packages/src/Editor/CompositionRoot/UnityCliLoopApplicationRegistration.cs @@ -32,6 +32,8 @@ internal UnityCliLoopApplicationServices Register() toolRegistrarService)); SkillSetupUseCase skillSetupUseCase = new(new SkillSetupService(new ToolSkillSetupService(toolSettingsService))); SkillSetupUseCaseRegistry.Register(skillSetupUseCase); + ThirdPartyToolMigrationUseCaseRegistry.Register( + new ThirdPartyToolMigrationUseCase(new ThirdPartyToolMigrationFileService())); CliSetupApplicationFacade.RegisterService(new CliSetupApplicationService( new CliInstallationDetector(), new NativeCliInstallerService())); diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs new file mode 100644 index 000000000..f18fc1de9 --- /dev/null +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs @@ -0,0 +1,105 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace io.github.hatayama.UnityCliLoop.Domain +{ + /// + /// Describes pending V3 custom tool migration work found in the Unity project. + /// + public readonly struct ThirdPartyToolMigrationPreview + { + private readonly string[] _filePaths; + + public ThirdPartyToolMigrationPreview(int fileCount, int replacementCount, string[] filePaths) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + Debug.Assert(filePaths != null, "filePaths must not be null"); + + FileCount = fileCount; + ReplacementCount = replacementCount; + _filePaths = ThirdPartyToolMigrationFilePathSnapshot.Copy(filePaths); + } + + public int FileCount { get; } + public int ReplacementCount { get; } + public string[] FilePaths => ThirdPartyToolMigrationFilePathSnapshot.Copy(_filePaths); + public bool HasTargets => FileCount > 0; + } + + /// + /// Reports preview scan progress so editor UI can repaint while project files are inspected. + /// + public readonly struct ThirdPartyToolMigrationProgress + { + public ThirdPartyToolMigrationProgress(int processedItemCount, int totalItemCount) + { + Debug.Assert(processedItemCount >= 0, "processedItemCount must not be negative"); + Debug.Assert(totalItemCount >= 0, "totalItemCount must not be negative"); + Debug.Assert( + processedItemCount <= totalItemCount || totalItemCount == 0, + "processedItemCount must not exceed totalItemCount"); + + ProcessedItemCount = processedItemCount; + TotalItemCount = totalItemCount; + } + + public int ProcessedItemCount { get; } + public int TotalItemCount { get; } + } + + /// + /// Describes the files rewritten by the V3 custom tool migration workflow. + /// + public readonly struct ThirdPartyToolMigrationResult + { + private readonly string[] _filePaths; + + public ThirdPartyToolMigrationResult(int fileCount, int replacementCount, string[] filePaths) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + Debug.Assert(filePaths != null, "filePaths must not be null"); + + FileCount = fileCount; + ReplacementCount = replacementCount; + _filePaths = ThirdPartyToolMigrationFilePathSnapshot.Copy(filePaths); + } + + public int FileCount { get; } + public int ReplacementCount { get; } + public string[] FilePaths => ThirdPartyToolMigrationFilePathSnapshot.Copy(_filePaths); + public bool Changed => FileCount > 0; + } + + public interface IThirdPartyToolMigrationPort + { + ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot); + Task PreviewMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct); + Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct); + ThirdPartyToolMigrationResult ApplyMigration(string projectRoot); + } + + /// + /// Creates stable migration file path snapshots before value objects store caller-owned arrays. + /// + internal static class ThirdPartyToolMigrationFilePathSnapshot + { + internal static string[] Copy(string[] filePaths) + { + if (filePaths == null || filePaths.Length == 0) + { + return Array.Empty(); + } + + string[] copy = new string[filePaths.Length]; + Array.Copy(filePaths, copy, filePaths.Length); + return copy; + } + } +} diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta new file mode 100644 index 000000000..d7d4f0942 --- /dev/null +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d34ccf1cea694100982036051096ac6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta new file mode 100644 index 000000000..e835d3fcc --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3977ef001d234ff59cf1e4d41a33793 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs new file mode 100644 index 000000000..1d831b9d0 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs @@ -0,0 +1,2073 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Scans Unity project files and rewrites V2 custom tool source to the V3 public contract API. + /// + public sealed class ThirdPartyToolMigrationFileService : IThirdPartyToolMigrationPort + { + private const string ImplicitEditorAssemblyDirectoryName = "__UnityCliLoopImplicitEditorAssembly"; + private const string ImplicitRuntimeAssemblyDirectoryName = "__UnityCliLoopImplicitRuntimeAssembly"; + private const string ImplicitFirstPassEditorAssemblyDirectoryName = + "__UnityCliLoopImplicitFirstPassEditorAssembly"; + private const string ImplicitFirstPassRuntimeAssemblyDirectoryName = + "__UnityCliLoopImplicitFirstPassRuntimeAssembly"; + private const int PreviewYieldBatchSize = 32; + + private readonly object _previewCacheLock = new(); + private bool _hasCachedPreview; + private string _cachedPreviewProjectRoot = string.Empty; + private ThirdPartyToolMigrationPreview _cachedPreview; + + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + if (TryGetCachedPreview(normalizedProjectRoot, out ThirdPartyToolMigrationPreview cachedPreview)) + { + return cachedPreview; + } + + MigrationPlan plan = CreateMigrationPlan(normalizedProjectRoot); + ThirdPartyToolMigrationPreview preview = new( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + StoreCachedPreview(normalizedProjectRoot, preview); + return preview; + } + + public async Task PreviewMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + MigrationPlan plan = await CreateMigrationPlanAsync(normalizedProjectRoot, progress, ct); + if (ct.IsCancellationRequested) + { + return new ThirdPartyToolMigrationPreview(0, 0, Array.Empty()); + } + + ThirdPartyToolMigrationPreview preview = new( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + StoreCachedPreview(normalizedProjectRoot, preview); + return preview; + } + + public async Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + if (!Directory.Exists(normalizedProjectRoot)) + { + throw new DirectoryNotFoundException(normalizedProjectRoot); + } + + if (!Directory.Exists(Path.Combine(normalizedProjectRoot, "Assets"))) + { + return false; + } + + return await HasMigrationTargetAsync(normalizedProjectRoot, ct); + } + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string normalizedProjectRoot = NormalizeProjectRoot(projectRoot); + InvalidatePreviewCache(); + MigrationPlan plan = CreateMigrationPlan(normalizedProjectRoot); + foreach (MigrationFileChange change in plan.Changes) + { + WriteMigrationFile(change.FilePath, change.Content); + } + + return new ThirdPartyToolMigrationResult( + plan.ChangedFilePaths.Count, + plan.ReplacementCount, + plan.ChangedFilePaths.ToArray()); + } + + internal void InvalidatePreviewCache() + { + lock (_previewCacheLock) + { + _hasCachedPreview = false; + _cachedPreviewProjectRoot = string.Empty; + _cachedPreview = default; + } + } + + private static async Task HasMigrationTargetAsync(string projectRoot, CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync( + projectRoot, + new Progress(), + ct); + if (ct.IsCancellationRequested) + { + return false; + } + + List asmdefDirectories = inventory.AsmdefFilePaths + .Select(path => Path.GetDirectoryName(path) ?? string.Empty) + .Where(path => !string.IsNullOrEmpty(path)) + .OrderByDescending(path => path.Length) + .ToList(); + List assemblyReferenceDirectories = inventory.AsmrefFilePaths.Count == 0 + ? new List() + : CreateAssemblyReferenceDirectories(inventory.AsmdefFilePaths, inventory.AsmrefFilePaths); + HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal); + HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal); + HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal); + int inspectedEntryCount = 0; + foreach (string csharpFilePath in inventory.CSharpFilePaths) + { + if (ct.IsCancellationRequested) + { + return false; + } + + string source = File.ReadAllText(csharpFilePath); + if (ContainsFastCSharpMigrationTarget(source)) + { + return true; + } + + if (ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source)) + { + assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory); + } + + CollectFastAssemblyReferenceRequirements( + source, + assemblyDirectory, + assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory), + legacyAssemblyDirectories, + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + inspectedEntryCount++; + if (inspectedEntryCount % PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + + if (assemblyScopedCurrentDomainDirectories.Count > 0) + { + await CollectFastAssemblyScopedCurrentDomainRequirementsAsync( + inventory.CSharpFilePaths, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot, + assemblyScopedCurrentDomainDirectories, + domainReferenceAssemblyDirectories, + ct); + if (ct.IsCancellationRequested) + { + return false; + } + } + + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + if (ct.IsCancellationRequested) + { + return false; + } + + string source = File.ReadAllText(asmdefFilePath); + if (ContainsFastAsmdefMigrationTarget(source)) + { + return true; + } + + inspectedEntryCount++; + if (inspectedEntryCount % PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + + if (toolContractsReferenceAssemblyDirectories.Count == 0 && + applicationReferenceAssemblyDirectories.Count == 0 && + domainReferenceAssemblyDirectories.Count == 0) + { + return false; + } + + MigrationAssemblyUsage assemblyUsage = new( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + new HashSet(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal), + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + if (ct.IsCancellationRequested) + { + return false; + } + + if (ContainsAsmdefMigrationTarget(asmdefFilePath, projectRoot, assemblyUsage)) + { + return true; + } + } + + return false; + } + + private static void CollectFastAssemblyReferenceRequirements( + string source, + string assemblyDirectory, + bool hasAssemblyScopedCurrentDomainNamespaceUsage, + HashSet legacyAssemblyDirectories, + HashSet toolContractsReferenceAssemblyDirectories, + HashSet applicationReferenceAssemblyDirectories, + HashSet domainReferenceAssemblyDirectories) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty"); + Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null"); + Debug.Assert( + toolContractsReferenceAssemblyDirectories != null, + "toolContractsReferenceAssemblyDirectories must not be null"); + Debug.Assert( + applicationReferenceAssemblyDirectories != null, + "applicationReferenceAssemblyDirectories must not be null"); + Debug.Assert(domainReferenceAssemblyDirectories != null, "domainReferenceAssemblyDirectories must not be null"); + + if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) + { + legacyAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source)) + { + toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) || + ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source)) + { + applicationReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source) || + ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly( + source, + hasAssemblyScopedCurrentDomainNamespaceUsage)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + } + + private static async Task CollectFastAssemblyScopedCurrentDomainRequirementsAsync( + List csharpFilePaths, + List asmdefDirectories, + List assemblyReferenceDirectories, + string projectRoot, + HashSet assemblyScopedCurrentDomainDirectories, + HashSet domainReferenceAssemblyDirectories, + CancellationToken ct) + { + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + Debug.Assert( + assemblyReferenceDirectories != null, + "assemblyReferenceDirectories must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert( + assemblyScopedCurrentDomainDirectories != null, + "assemblyScopedCurrentDomainDirectories must not be null"); + Debug.Assert(domainReferenceAssemblyDirectories != null, "domainReferenceAssemblyDirectories must not be null"); + + int inspectedEntryCount = 0; + foreach (string csharpFilePath in csharpFilePaths) + { + if (ct.IsCancellationRequested) + { + return; + } + + string source = File.ReadAllText(csharpFilePath); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + if (assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory) && + ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly(source, true)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + inspectedEntryCount++; + if (inspectedEntryCount % PreviewYieldBatchSize == 0) + { + await Task.Yield(); + } + } + } + + private static bool ContainsAsmdefMigrationTarget( + string asmdefFilePath, + string projectRoot, + MigrationAssemblyUsage assemblyUsage) + { + Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + bool hasLegacyCSharpSource; + bool requiresToolContractsReference; + bool requiresApplicationReference; + bool requiresDomainReference; + bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements( + asmdefFilePath, + projectRoot, + assemblyUsage, + out hasLegacyCSharpSource, + out requiresToolContractsReference, + out requiresApplicationReference, + out requiresDomainReference); + string source = File.ReadAllText(asmdefFilePath); + if (!hasAssemblyMigrationRequirement && + !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source)) + { + return false; + } + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource, + requiresToolContractsReference, + requiresApplicationReference, + requiresDomainReference); + + return result.Changed; + } + + private static bool TryGetAsmdefMigrationRequirements( + string asmdefFilePath, + string projectRoot, + MigrationAssemblyUsage assemblyUsage, + out bool hasLegacyCSharpSource, + out bool requiresToolContractsReference, + out bool requiresApplicationReference, + out bool requiresDomainReference) + { + Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? projectRoot; + hasLegacyCSharpSource = assemblyUsage.LegacyAssemblyDirectories.Contains(asmdefDirectory); + requiresToolContractsReference = + assemblyUsage.ToolContractsReferenceAssemblyDirectories.Contains(asmdefDirectory); + requiresApplicationReference = + assemblyUsage.ApplicationReferenceAssemblyDirectories.Contains(asmdefDirectory); + requiresDomainReference = + assemblyUsage.DomainReferenceAssemblyDirectories.Contains(asmdefDirectory); + return hasLegacyCSharpSource || + requiresToolContractsReference || + requiresApplicationReference || + requiresDomainReference; + } + + private static bool ContainsFastCSharpMigrationTarget(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source) && + ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source); + } + + private static bool ContainsFastAsmdefMigrationTarget(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ThirdPartyToolMigrationRules.ContainsLegacyAsmdefNameReference(source); + } + + private static bool ShouldExcludeFastScanDirectory(string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + string directoryName = Path.GetFileName(directoryPath); + return ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName); + } + + private bool TryGetCachedPreview( + string projectRoot, + out ThirdPartyToolMigrationPreview preview) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + lock (_previewCacheLock) + { + if (_hasCachedPreview && + string.Equals(_cachedPreviewProjectRoot, projectRoot, StringComparison.Ordinal)) + { + preview = _cachedPreview; + return true; + } + } + + preview = default; + return false; + } + + private void StoreCachedPreview(string projectRoot, ThirdPartyToolMigrationPreview preview) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + lock (_previewCacheLock) + { + _cachedPreviewProjectRoot = projectRoot; + _cachedPreview = preview; + _hasCachedPreview = true; + } + } + + private static MigrationPlan CreateMigrationPlan(string projectRoot) + { + if (!Directory.Exists(projectRoot)) + { + throw new DirectoryNotFoundException(projectRoot); + } + + ProjectFileInventory inventory = ProjectFileInventory.Create(projectRoot); + MigrationAssemblyUsage assemblyUsage = FindMigrationAssemblyUsage( + projectRoot, + inventory.CSharpFilePaths, + inventory.AsmdefFilePaths, + inventory.AsmrefFilePaths); + List changes = new(); + int replacementCount = 0; + string[] legacyToolInfoAliases = GetAllAssemblyScopedLegacyToolInfoAliases(assemblyUsage); + + foreach (string csharpFilePath in inventory.CSharpFilePaths) + { + string source = File.ReadAllText(csharpFilePath); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source) && + !ThirdPartyToolMigrationRules.ContainsLegacyTypeAliasReference(source, legacyToolInfoAliases)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + assemblyUsage.AsmdefDirectories, + assemblyUsage.AssemblyReferenceDirectories, + projectRoot); + string[] legacyAssemblyAliases; + if (!assemblyUsage.AssemblyScopedLegacyAliasesByDirectory.TryGetValue( + assemblyDirectory, + out legacyAssemblyAliases)) + { + legacyAssemblyAliases = Array.Empty(); + } + string[] legacyAssemblyToolInfoAliases; + if (!assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.TryGetValue( + assemblyDirectory, + out legacyAssemblyToolInfoAliases)) + { + legacyAssemblyToolInfoAliases = Array.Empty(); + } + bool hasLegacyAssemblySource = + assemblyUsage.AssemblyScopedLegacyDirectories.Contains(assemblyDirectory) && + ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(source, legacyAssemblyAliases); + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly( + source, + hasLegacyAssemblySource, + legacyAssemblyAliases, + legacyAssemblyToolInfoAliases); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(csharpFilePath, result.Content)); + } + + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + bool hasLegacyCSharpSource; + bool requiresToolContractsReference; + bool requiresApplicationReference; + bool requiresDomainReference; + bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements( + asmdefFilePath, + projectRoot, + assemblyUsage, + out hasLegacyCSharpSource, + out requiresToolContractsReference, + out requiresApplicationReference, + out requiresDomainReference); + string source = File.ReadAllText(asmdefFilePath); + if (!hasAssemblyMigrationRequirement && + !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source)) + { + continue; + } + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource, + requiresToolContractsReference, + requiresApplicationReference, + requiresDomainReference); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(asmdefFilePath, result.Content)); + } + + return new MigrationPlan(changes, replacementCount); + } + + private static async Task CreateMigrationPlanAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + if (!Directory.Exists(projectRoot)) + { + throw new DirectoryNotFoundException(projectRoot); + } + + ProjectFileInventory inventory = await ProjectFileInventory.CreateAsync(projectRoot, progress, ct); + if (ct.IsCancellationRequested) + { + return MigrationPlan.Empty; + } + + MigrationProgressCounter progressCounter = new(GetPreviewWorkItemCount(inventory), progress); + MigrationAssemblyUsage assemblyUsage = await FindMigrationAssemblyUsageAsync( + projectRoot, + inventory.CSharpFilePaths, + inventory.AsmdefFilePaths, + inventory.AsmrefFilePaths, + progressCounter, + ct); + if (ct.IsCancellationRequested) + { + return MigrationPlan.Empty; + } + + List changes = new(); + int replacementCount = 0; + string[] legacyToolInfoAliases = GetAllAssemblyScopedLegacyToolInfoAliases(assemblyUsage); + + foreach (string csharpFilePath in inventory.CSharpFilePaths) + { + if (ct.IsCancellationRequested) + { + return MigrationPlan.Empty; + } + + string source = File.ReadAllText(csharpFilePath); + await progressCounter.ReportProcessedItemAsync(ct); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source) && + !ThirdPartyToolMigrationRules.ContainsLegacyTypeAliasReference(source, legacyToolInfoAliases)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + assemblyUsage.AsmdefDirectories, + assemblyUsage.AssemblyReferenceDirectories, + projectRoot); + string[] legacyAssemblyAliases; + if (!assemblyUsage.AssemblyScopedLegacyAliasesByDirectory.TryGetValue( + assemblyDirectory, + out legacyAssemblyAliases)) + { + legacyAssemblyAliases = Array.Empty(); + } + string[] legacyAssemblyToolInfoAliases; + if (!assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.TryGetValue( + assemblyDirectory, + out legacyAssemblyToolInfoAliases)) + { + legacyAssemblyToolInfoAliases = Array.Empty(); + } + bool hasLegacyAssemblySource = + assemblyUsage.AssemblyScopedLegacyDirectories.Contains(assemblyDirectory) && + ThirdPartyToolMigrationRules.ContainsLegacyAssemblyScopedApi(source, legacyAssemblyAliases); + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateCSharpSourceForLegacyAssembly( + source, + hasLegacyAssemblySource, + legacyAssemblyAliases, + legacyAssemblyToolInfoAliases); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(csharpFilePath, result.Content)); + } + + foreach (string asmdefFilePath in inventory.AsmdefFilePaths) + { + if (ct.IsCancellationRequested) + { + return MigrationPlan.Empty; + } + + bool hasLegacyCSharpSource; + bool requiresToolContractsReference; + bool requiresApplicationReference; + bool requiresDomainReference; + bool hasAssemblyMigrationRequirement = TryGetAsmdefMigrationRequirements( + asmdefFilePath, + projectRoot, + assemblyUsage, + out hasLegacyCSharpSource, + out requiresToolContractsReference, + out requiresApplicationReference, + out requiresDomainReference); + string source = File.ReadAllText(asmdefFilePath); + await progressCounter.ReportProcessedItemAsync(ct); + if (!hasAssemblyMigrationRequirement && + !ThirdPartyToolMigrationRules.ContainsLegacyMigrationCandidateText(source)) + { + continue; + } + + ThirdPartyToolMigrationContentResult result = + ThirdPartyToolMigrationRules.MigrateAsmdefSource( + source, + hasLegacyCSharpSource, + requiresToolContractsReference, + requiresApplicationReference, + requiresDomainReference); + if (!result.Changed) + { + continue; + } + + replacementCount += result.ReplacementCount; + changes.Add(new MigrationFileChange(asmdefFilePath, result.Content)); + } + + progressCounter.ReportComplete(); + return new MigrationPlan(changes, replacementCount); + } + + private static int GetPreviewWorkItemCount(ProjectFileInventory inventory) + { + Debug.Assert(inventory != null, "inventory must not be null"); + + return (inventory.CSharpFilePaths.Count * 3) + + (inventory.AsmdefFilePaths.Count * 2) + + inventory.AsmrefFilePaths.Count; + } + + private static void WriteMigrationFile(string filePath, string content) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(content != null, "content must not be null"); + + string tempFilePath = CreateUniqueSidecarPath(filePath, ".tmp"); + File.WriteAllText(tempFilePath, content); + if (!File.Exists(filePath)) + { + File.Move(tempFilePath, filePath); + return; + } + + string backupFilePath = CreateUniqueSidecarPath(filePath, ".bak"); + File.Replace(tempFilePath, filePath, backupFilePath); + File.Delete(backupFilePath); + } + + private static string CreateUniqueSidecarPath(string filePath, string extension) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(extension), "extension must not be null or empty"); + + string directory = Path.GetDirectoryName(filePath) ?? string.Empty; + string fileName = Path.GetFileName(filePath); + string sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}"); + while (File.Exists(sidecarPath)) + { + sidecarPath = Path.Combine(directory, $"{fileName}.{Guid.NewGuid():N}{extension}"); + } + + return sidecarPath; + } + + private static string NormalizeProjectRoot(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + return Path.GetFullPath(projectRoot) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static MigrationAssemblyUsage FindMigrationAssemblyUsage( + string projectRoot, + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + + List asmdefDirectories = asmdefFilePaths + .Select(path => Path.GetDirectoryName(path) ?? string.Empty) + .Where(path => !string.IsNullOrEmpty(path)) + .OrderByDescending(path => path.Length) + .ToList(); + List assemblyReferenceDirectories = + CreateAssemblyReferenceDirectories(asmdefFilePaths, asmrefFilePaths); + HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal); + HashSet assemblyScopedLegacyDirectories = new(StringComparer.Ordinal); + HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal); + Dictionary> assemblyScopedLegacyAliasesByDirectory = + new(StringComparer.Ordinal); + Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory = + new(StringComparer.Ordinal); + HashSet registrarAssemblyDirectories = new(StringComparer.Ordinal); + HashSet domainMetadataAssemblyDirectories = new(StringComparer.Ordinal); + HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal); + + foreach (string csharpFilePath in csharpFilePaths) + { + string source = File.ReadAllText(csharpFilePath); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + + if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) + { + legacyAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source)) + { + assemblyScopedLegacyDirectories.Add(assemblyDirectory); + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source)); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source)) + { + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyToolInfoAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source)); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source)) + { + assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) || + ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source)) + { + registrarAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source)) + { + toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyDomainMetadataApi(source)) + { + domainMetadataAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + } + + foreach (string csharpFilePath in csharpFilePaths) + { + string source = File.ReadAllText(csharpFilePath); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + string[] legacyAssemblyAliases = Array.Empty(); + if (assemblyScopedLegacyAliasesByDirectory.TryGetValue( + assemblyDirectory, + out HashSet legacyAssemblyAliasSet)) + { + legacyAssemblyAliases = legacyAssemblyAliasSet + .OrderBy(alias => alias, StringComparer.Ordinal) + .ToArray(); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyDomainHelperApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + domainMetadataAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + registrarAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly( + source, + assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory))) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + } + + foreach (string registrarAssemblyDirectory in registrarAssemblyDirectories) + { + applicationReferenceAssemblyDirectories.Add(registrarAssemblyDirectory); + } + + foreach (string domainMetadataAssemblyDirectory in domainMetadataAssemblyDirectories) + { + if (legacyAssemblyDirectories.Contains(domainMetadataAssemblyDirectory)) + { + domainReferenceAssemblyDirectories.Add(domainMetadataAssemblyDirectory); + } + } + + return new MigrationAssemblyUsage( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + assemblyScopedLegacyDirectories, + CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyAliasesByDirectory), + CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyToolInfoAliasesByDirectory), + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + private static async Task FindMigrationAssemblyUsageAsync( + string projectRoot, + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths, + MigrationProgressCounter progressCounter, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + Debug.Assert(progressCounter != null, "progressCounter must not be null"); + + List asmdefDirectories = asmdefFilePaths + .Select(path => Path.GetDirectoryName(path) ?? string.Empty) + .Where(path => !string.IsNullOrEmpty(path)) + .OrderByDescending(path => path.Length) + .ToList(); + List assemblyReferenceDirectories = + await CreateAssemblyReferenceDirectoriesAsync( + asmdefFilePaths, + asmrefFilePaths, + progressCounter, + ct); + HashSet legacyAssemblyDirectories = new(StringComparer.Ordinal); + HashSet assemblyScopedLegacyDirectories = new(StringComparer.Ordinal); + HashSet assemblyScopedCurrentDomainDirectories = new(StringComparer.Ordinal); + Dictionary> assemblyScopedLegacyAliasesByDirectory = + new(StringComparer.Ordinal); + Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory = + new(StringComparer.Ordinal); + HashSet registrarAssemblyDirectories = new(StringComparer.Ordinal); + HashSet domainMetadataAssemblyDirectories = new(StringComparer.Ordinal); + HashSet toolContractsReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet applicationReferenceAssemblyDirectories = new(StringComparer.Ordinal); + HashSet domainReferenceAssemblyDirectories = new(StringComparer.Ordinal); + + foreach (string csharpFilePath in csharpFilePaths) + { + if (ct.IsCancellationRequested) + { + return CreateMigrationAssemblyUsage( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + assemblyScopedLegacyDirectories, + assemblyScopedLegacyAliasesByDirectory, + assemblyScopedLegacyToolInfoAliasesByDirectory, + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + string source = File.ReadAllText(csharpFilePath); + await progressCounter.ReportProcessedItemAsync(ct); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + + if (ThirdPartyToolMigrationRules.ContainsLegacyCSharpApi(source)) + { + legacyAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalUsing(source)) + { + assemblyScopedLegacyDirectories.Add(assemblyDirectory); + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalNamespaceAliases(source)); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyGlobalToolInfoTypeAlias(source)) + { + AddAssemblyScopedLegacyAliases( + assemblyScopedLegacyToolInfoAliasesByDirectory, + assemblyDirectory, + ThirdPartyToolMigrationRules.GetLegacyGlobalToolInfoTypeAliases(source)); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainGlobalUsing(source)) + { + assemblyScopedCurrentDomainDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApi(source) || + ThirdPartyToolMigrationRules.ContainsCurrentRegistrarApi(source)) + { + registrarAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApi(source)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentToolContractsApi(source)) + { + toolContractsReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyDomainMetadataApi(source)) + { + domainMetadataAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApi(source)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + } + + foreach (string csharpFilePath in csharpFilePaths) + { + if (ct.IsCancellationRequested) + { + return CreateMigrationAssemblyUsage( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + assemblyScopedLegacyDirectories, + assemblyScopedLegacyAliasesByDirectory, + assemblyScopedLegacyToolInfoAliasesByDirectory, + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + string source = File.ReadAllText(csharpFilePath); + await progressCounter.ReportProcessedItemAsync(ct); + if (!ThirdPartyToolMigrationRules.ContainsMigrationCandidateText(source)) + { + continue; + } + + string assemblyDirectory = FindNearestAssemblyDirectory( + csharpFilePath, + asmdefDirectories, + assemblyReferenceDirectories, + projectRoot); + string[] legacyAssemblyAliases = Array.Empty(); + if (assemblyScopedLegacyAliasesByDirectory.TryGetValue( + assemblyDirectory, + out HashSet legacyAssemblyAliasSet)) + { + legacyAssemblyAliases = legacyAssemblyAliasSet + .OrderBy(alias => alias, StringComparer.Ordinal) + .ToArray(); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyDomainHelperApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + domainMetadataAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsLegacyRegistrarApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + registrarAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsRegistrarDomainReturnApiForAssembly( + source, + assemblyScopedLegacyDirectories.Contains(assemblyDirectory), + legacyAssemblyAliases)) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + + if (ThirdPartyToolMigrationRules.ContainsCurrentDomainMetadataApiForAssembly( + source, + assemblyScopedCurrentDomainDirectories.Contains(assemblyDirectory))) + { + domainReferenceAssemblyDirectories.Add(assemblyDirectory); + } + } + + foreach (string registrarAssemblyDirectory in registrarAssemblyDirectories) + { + applicationReferenceAssemblyDirectories.Add(registrarAssemblyDirectory); + } + + foreach (string domainMetadataAssemblyDirectory in domainMetadataAssemblyDirectories) + { + if (legacyAssemblyDirectories.Contains(domainMetadataAssemblyDirectory)) + { + domainReferenceAssemblyDirectories.Add(domainMetadataAssemblyDirectory); + } + } + + return CreateMigrationAssemblyUsage( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + assemblyScopedLegacyDirectories, + assemblyScopedLegacyAliasesByDirectory, + assemblyScopedLegacyToolInfoAliasesByDirectory, + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + private static MigrationAssemblyUsage CreateMigrationAssemblyUsage( + List asmdefDirectories, + List assemblyReferenceDirectories, + HashSet legacyAssemblyDirectories, + HashSet assemblyScopedLegacyDirectories, + Dictionary> assemblyScopedLegacyAliasesByDirectory, + Dictionary> assemblyScopedLegacyToolInfoAliasesByDirectory, + HashSet toolContractsReferenceAssemblyDirectories, + HashSet applicationReferenceAssemblyDirectories, + HashSet domainReferenceAssemblyDirectories) + { + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + Debug.Assert( + assemblyReferenceDirectories != null, + "assemblyReferenceDirectories must not be null"); + Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null"); + Debug.Assert( + assemblyScopedLegacyDirectories != null, + "assemblyScopedLegacyDirectories must not be null"); + Debug.Assert( + assemblyScopedLegacyAliasesByDirectory != null, + "assemblyScopedLegacyAliasesByDirectory must not be null"); + Debug.Assert( + assemblyScopedLegacyToolInfoAliasesByDirectory != null, + "assemblyScopedLegacyToolInfoAliasesByDirectory must not be null"); + Debug.Assert( + toolContractsReferenceAssemblyDirectories != null, + "toolContractsReferenceAssemblyDirectories must not be null"); + Debug.Assert( + applicationReferenceAssemblyDirectories != null, + "applicationReferenceAssemblyDirectories must not be null"); + Debug.Assert( + domainReferenceAssemblyDirectories != null, + "domainReferenceAssemblyDirectories must not be null"); + + return new MigrationAssemblyUsage( + asmdefDirectories, + assemblyReferenceDirectories, + legacyAssemblyDirectories, + assemblyScopedLegacyDirectories, + CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyAliasesByDirectory), + CreateAssemblyScopedLegacyAliasesByDirectory(assemblyScopedLegacyToolInfoAliasesByDirectory), + toolContractsReferenceAssemblyDirectories, + applicationReferenceAssemblyDirectories, + domainReferenceAssemblyDirectories); + } + + private static void AddAssemblyScopedLegacyAliases( + Dictionary> aliasesByDirectory, + string assemblyDirectory, + string[] aliases) + { + Debug.Assert(aliasesByDirectory != null, "aliasesByDirectory must not be null"); + Debug.Assert(!string.IsNullOrEmpty(assemblyDirectory), "assemblyDirectory must not be null or empty"); + Debug.Assert(aliases != null, "aliases must not be null"); + + if (aliases.Length == 0) + { + return; + } + + if (!aliasesByDirectory.TryGetValue(assemblyDirectory, out HashSet aliasSet)) + { + aliasSet = new HashSet(StringComparer.Ordinal); + aliasesByDirectory.Add(assemblyDirectory, aliasSet); + } + + foreach (string alias in aliases) + { + aliasSet.Add(alias); + } + } + + private static Dictionary CreateAssemblyScopedLegacyAliasesByDirectory( + Dictionary> aliasesByDirectory) + { + Debug.Assert(aliasesByDirectory != null, "aliasesByDirectory must not be null"); + + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair> aliasesForDirectory in aliasesByDirectory) + { + result.Add( + aliasesForDirectory.Key, + aliasesForDirectory.Value.OrderBy(alias => alias, StringComparer.Ordinal).ToArray()); + } + + return result; + } + + private static string[] GetAllAssemblyScopedLegacyToolInfoAliases(MigrationAssemblyUsage assemblyUsage) + { + return assemblyUsage.AssemblyScopedLegacyToolInfoAliasesByDirectory.Values + .SelectMany(aliases => aliases) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static List CreateAssemblyReferenceDirectories( + List asmdefFilePaths, + List asmrefFilePaths) + { + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + + if (asmrefFilePaths.Count == 0) + { + return new List(); + } + + Dictionary asmdefDirectoriesByReference = CreateAsmdefDirectoryMap(asmdefFilePaths); + List assemblyReferenceDirectories = new(); + foreach (string asmrefFilePath in asmrefFilePaths) + { + if (!TryReadJsonObjectFromFile(asmrefFilePath, out JObject asmref)) + { + continue; + } + + string reference = asmref["reference"]?.Value() ?? string.Empty; + if (reference.Length == 0) + { + continue; + } + + if (!asmdefDirectoriesByReference.TryGetValue(reference, out string targetAssemblyDirectory)) + { + continue; + } + + string sourceDirectory = Path.GetDirectoryName(asmrefFilePath) ?? string.Empty; + if (sourceDirectory.Length == 0) + { + continue; + } + + assemblyReferenceDirectories.Add( + new AssemblyReferenceDirectory(sourceDirectory, targetAssemblyDirectory)); + } + + return assemblyReferenceDirectories + .OrderByDescending(assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length) + .ToList(); + } + + private static async Task> CreateAssemblyReferenceDirectoriesAsync( + List asmdefFilePaths, + List asmrefFilePaths, + MigrationProgressCounter progressCounter, + CancellationToken ct) + { + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + Debug.Assert(progressCounter != null, "progressCounter must not be null"); + + if (asmrefFilePaths.Count == 0) + { + return new List(); + } + + Dictionary asmdefDirectoriesByReference = + await CreateAsmdefDirectoryMapAsync(asmdefFilePaths, progressCounter, ct); + List assemblyReferenceDirectories = new(); + foreach (string asmrefFilePath in asmrefFilePaths) + { + if (ct.IsCancellationRequested) + { + return assemblyReferenceDirectories + .OrderByDescending( + assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length) + .ToList(); + } + + if (!TryReadJsonObjectFromFile(asmrefFilePath, out JObject asmref)) + { + await progressCounter.ReportProcessedItemAsync(ct); + continue; + } + + await progressCounter.ReportProcessedItemAsync(ct); + string reference = asmref["reference"]?.Value() ?? string.Empty; + if (reference.Length == 0) + { + continue; + } + + if (!asmdefDirectoriesByReference.TryGetValue(reference, out string targetAssemblyDirectory)) + { + continue; + } + + string sourceDirectory = Path.GetDirectoryName(asmrefFilePath) ?? string.Empty; + if (sourceDirectory.Length == 0) + { + continue; + } + + assemblyReferenceDirectories.Add( + new AssemblyReferenceDirectory(sourceDirectory, targetAssemblyDirectory)); + } + + return assemblyReferenceDirectories + .OrderByDescending(assemblyReferenceDirectory => assemblyReferenceDirectory.SourceDirectory.Length) + .ToList(); + } + + private static Dictionary CreateAsmdefDirectoryMap(List asmdefFilePaths) + { + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + + Dictionary asmdefDirectoriesByReference = new(StringComparer.Ordinal); + foreach (string asmdefFilePath in asmdefFilePaths) + { + string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? string.Empty; + if (asmdefDirectory.Length == 0) + { + continue; + } + + if (!TryReadJsonObjectFromFile(asmdefFilePath, out JObject asmdef)) + { + continue; + } + + string assemblyName = asmdef["name"]?.Value() ?? string.Empty; + AddAsmdefDirectoryReference(asmdefDirectoriesByReference, assemblyName, asmdefDirectory); + AddAsmdefDirectoryReference( + asmdefDirectoriesByReference, + ReadAsmdefGuidReference(asmdefFilePath), + asmdefDirectory); + } + + return asmdefDirectoriesByReference; + } + + private static async Task> CreateAsmdefDirectoryMapAsync( + List asmdefFilePaths, + MigrationProgressCounter progressCounter, + CancellationToken ct) + { + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(progressCounter != null, "progressCounter must not be null"); + + Dictionary asmdefDirectoriesByReference = new(StringComparer.Ordinal); + foreach (string asmdefFilePath in asmdefFilePaths) + { + if (ct.IsCancellationRequested) + { + return asmdefDirectoriesByReference; + } + + string asmdefDirectory = Path.GetDirectoryName(asmdefFilePath) ?? string.Empty; + if (asmdefDirectory.Length == 0) + { + await progressCounter.ReportProcessedItemAsync(ct); + continue; + } + + if (!TryReadJsonObjectFromFile(asmdefFilePath, out JObject asmdef)) + { + await progressCounter.ReportProcessedItemAsync(ct); + continue; + } + + await progressCounter.ReportProcessedItemAsync(ct); + string assemblyName = asmdef["name"]?.Value() ?? string.Empty; + AddAsmdefDirectoryReference(asmdefDirectoriesByReference, assemblyName, asmdefDirectory); + AddAsmdefDirectoryReference( + asmdefDirectoriesByReference, + ReadAsmdefGuidReference(asmdefFilePath), + asmdefDirectory); + } + + return asmdefDirectoriesByReference; + } + + private static bool TryReadJsonObjectFromFile(string filePath, out JObject jsonObject) + { + return TryReadJsonObjectForMigration(filePath, File.ReadAllText, out jsonObject); + } + + internal static bool TryReadJsonObjectForMigration( + string filePath, + Func readAllText, + out JObject jsonObject) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(readAllText != null, "readAllText must not be null"); + + try + { + jsonObject = JObject.Parse(readAllText(filePath)); + return true; + } + catch (Exception ex) when (IsSkippableAssemblyJsonReadException(ex)) + { + UnityEngine.Debug.LogWarning( + $"[UnityCliLoop] Skipping unreadable or malformed assembly JSON at {filePath}: {ex.Message}"); + jsonObject = null; + return false; + } + } + + private static bool IsSkippableAssemblyJsonReadException(Exception ex) + { + Debug.Assert(ex != null, "ex must not be null"); + + return ex is JsonException || + ex is IOException || + ex is UnauthorizedAccessException; + } + + private static void AddAsmdefDirectoryReference( + Dictionary asmdefDirectoriesByReference, + string reference, + string asmdefDirectory) + { + Debug.Assert(asmdefDirectoriesByReference != null, "asmdefDirectoriesByReference must not be null"); + Debug.Assert(reference != null, "reference must not be null"); + Debug.Assert(!string.IsNullOrEmpty(asmdefDirectory), "asmdefDirectory must not be null or empty"); + + if (reference.Length == 0 || asmdefDirectoriesByReference.ContainsKey(reference)) + { + return; + } + + asmdefDirectoriesByReference.Add(reference, asmdefDirectory); + } + + private static string ReadAsmdefGuidReference(string asmdefFilePath) + { + Debug.Assert(!string.IsNullOrEmpty(asmdefFilePath), "asmdefFilePath must not be null or empty"); + + string metaPath = asmdefFilePath + ".meta"; + if (!File.Exists(metaPath)) + { + return string.Empty; + } + + foreach (string line in File.ReadLines(metaPath)) + { + string trimmedLine = line.Trim(); + if (!trimmedLine.StartsWith("guid:", StringComparison.Ordinal)) + { + continue; + } + + string guid = trimmedLine.Substring("guid:".Length).Trim(); + return guid.Length == 0 ? string.Empty : $"GUID:{guid}"; + } + + return string.Empty; + } + + private static string FindNearestAssemblyDirectory( + string csharpFilePath, + List asmdefDirectories, + List assemblyReferenceDirectories, + string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty"); + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + Debug.Assert( + assemblyReferenceDirectories != null, + "assemblyReferenceDirectories must not be null"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string csharpDirectory = Path.GetDirectoryName(csharpFilePath) ?? string.Empty; + string matchedAssemblyDirectory = string.Empty; + int matchedSourceDirectoryLength = -1; + foreach (string asmdefDirectory in asmdefDirectories) + { + if (!IsSameOrChildPath(csharpDirectory, asmdefDirectory) || + asmdefDirectory.Length <= matchedSourceDirectoryLength) + { + continue; + } + + matchedAssemblyDirectory = asmdefDirectory; + matchedSourceDirectoryLength = asmdefDirectory.Length; + } + + foreach (AssemblyReferenceDirectory assemblyReferenceDirectory in assemblyReferenceDirectories) + { + string sourceDirectory = assemblyReferenceDirectory.SourceDirectory; + if (!IsSameOrChildPath(csharpDirectory, sourceDirectory) || + sourceDirectory.Length <= matchedSourceDirectoryLength) + { + continue; + } + + matchedAssemblyDirectory = assemblyReferenceDirectory.TargetAssemblyDirectory; + matchedSourceDirectoryLength = sourceDirectory.Length; + } + + if (matchedAssemblyDirectory.Length > 0) + { + return matchedAssemblyDirectory; + } + + return GetImplicitAssemblyDirectory(csharpFilePath, projectRoot); + } + + private static string GetImplicitAssemblyDirectory(string csharpFilePath, string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + bool isEditorAssemblyPath = IsEditorAssemblyPath(csharpFilePath, projectRoot); + bool isFirstPassAssemblyPath = IsFirstPassAssemblyPath(csharpFilePath, projectRoot); + string implicitAssemblyDirectoryName = GetImplicitAssemblyDirectoryName( + isEditorAssemblyPath, + isFirstPassAssemblyPath); + return Path.Combine(projectRoot, implicitAssemblyDirectoryName); + } + + private static string GetImplicitAssemblyDirectoryName( + bool isEditorAssemblyPath, + bool isFirstPassAssemblyPath) + { + if (isEditorAssemblyPath && isFirstPassAssemblyPath) + { + return ImplicitFirstPassEditorAssemblyDirectoryName; + } + + if (isFirstPassAssemblyPath) + { + return ImplicitFirstPassRuntimeAssemblyDirectoryName; + } + + return isEditorAssemblyPath + ? ImplicitEditorAssemblyDirectoryName + : ImplicitRuntimeAssemblyDirectoryName; + } + + private static bool IsEditorAssemblyPath(string csharpFilePath, string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string[] pathSegments = GetRelativePathSegments(csharpFilePath, projectRoot); + return pathSegments.Any( + pathSegment => string.Equals(pathSegment, "Editor", StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsFirstPassAssemblyPath(string csharpFilePath, string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(csharpFilePath), "csharpFilePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string[] pathSegments = GetRelativePathSegments(csharpFilePath, projectRoot); + if (pathSegments.Length < 2 || + !string.Equals(pathSegments[0], "Assets", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return string.Equals(pathSegments[1], "Plugins", StringComparison.OrdinalIgnoreCase) || + string.Equals(pathSegments[1], "Standard Assets", StringComparison.OrdinalIgnoreCase) || + string.Equals(pathSegments[1], "Pro Standard Assets", StringComparison.OrdinalIgnoreCase); + } + + private static string[] GetRelativePathSegments(string filePath, string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + string relativePath = filePath.StartsWith(projectRoot, StringComparison.Ordinal) + ? filePath.Substring(projectRoot.Length) + : filePath; + char[] separators = + { + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + }; + return relativePath.Split(separators, StringSplitOptions.RemoveEmptyEntries); + } + + private static bool IsSameOrChildPath(string childPath, string parentPath) + { + Debug.Assert(childPath != null, "childPath must not be null"); + Debug.Assert(parentPath != null, "parentPath must not be null"); + + if (string.Equals(childPath, parentPath, StringComparison.Ordinal)) + { + return true; + } + + string parentWithSeparator = parentPath.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return childPath.StartsWith(parentWithSeparator, StringComparison.Ordinal); + } + + private readonly struct MigrationFileChange + { + public MigrationFileChange(string filePath, string content) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(content != null, "content must not be null"); + + FilePath = filePath; + Content = content ?? string.Empty; + } + + public string FilePath { get; } + public string Content { get; } + } + + private readonly struct MigrationPlan + { + public static MigrationPlan Empty => new(new List(), 0); + + public MigrationPlan(List changes, int replacementCount) + { + Debug.Assert(changes != null, "changes must not be null"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + + Changes = changes ?? new List(); + ReplacementCount = replacementCount; + ChangedFilePaths = Changes + .Select(change => change.FilePath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + } + + public List Changes { get; } + public int ReplacementCount { get; } + public List ChangedFilePaths { get; } + } + + private sealed class MigrationProgressCounter + { + private readonly IProgress _progress; + private readonly int _totalItemCount; + private int _processedItemCount; + + public MigrationProgressCounter( + int totalItemCount, + IProgress progress) + { + Debug.Assert(totalItemCount >= 0, "totalItemCount must not be negative"); + Debug.Assert(progress != null, "progress must not be null"); + + _totalItemCount = totalItemCount; + _progress = progress; + Report(); + } + + public async Task ReportProcessedItemAsync(CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + return; + } + + _processedItemCount++; + Report(); + if (_processedItemCount % PreviewYieldBatchSize != 0) + { + return; + } + + await Task.Yield(); + } + + public void ReportComplete() + { + _processedItemCount = _totalItemCount; + Report(); + } + + private void Report() + { + _progress.Report( + new ThirdPartyToolMigrationProgress( + Math.Min(_processedItemCount, _totalItemCount), + _totalItemCount)); + } + } + + private readonly struct MigrationAssemblyUsage + { + public MigrationAssemblyUsage( + List asmdefDirectories, + List assemblyReferenceDirectories, + HashSet legacyAssemblyDirectories, + HashSet assemblyScopedLegacyDirectories, + Dictionary assemblyScopedLegacyAliasesByDirectory, + Dictionary assemblyScopedLegacyToolInfoAliasesByDirectory, + HashSet toolContractsReferenceAssemblyDirectories, + HashSet applicationReferenceAssemblyDirectories, + HashSet domainReferenceAssemblyDirectories) + { + Debug.Assert(asmdefDirectories != null, "asmdefDirectories must not be null"); + Debug.Assert( + assemblyReferenceDirectories != null, + "assemblyReferenceDirectories must not be null"); + Debug.Assert(legacyAssemblyDirectories != null, "legacyAssemblyDirectories must not be null"); + Debug.Assert( + assemblyScopedLegacyDirectories != null, + "assemblyScopedLegacyDirectories must not be null"); + Debug.Assert( + assemblyScopedLegacyAliasesByDirectory != null, + "assemblyScopedLegacyAliasesByDirectory must not be null"); + Debug.Assert( + assemblyScopedLegacyToolInfoAliasesByDirectory != null, + "assemblyScopedLegacyToolInfoAliasesByDirectory must not be null"); + Debug.Assert( + toolContractsReferenceAssemblyDirectories != null, + "toolContractsReferenceAssemblyDirectories must not be null"); + Debug.Assert( + applicationReferenceAssemblyDirectories != null, + "applicationReferenceAssemblyDirectories must not be null"); + Debug.Assert( + domainReferenceAssemblyDirectories != null, + "domainReferenceAssemblyDirectories must not be null"); + + AsmdefDirectories = asmdefDirectories ?? new List(); + AssemblyReferenceDirectories = assemblyReferenceDirectories ?? new List(); + LegacyAssemblyDirectories = legacyAssemblyDirectories ?? new HashSet(StringComparer.Ordinal); + AssemblyScopedLegacyDirectories = assemblyScopedLegacyDirectories ?? + new HashSet(StringComparer.Ordinal); + AssemblyScopedLegacyAliasesByDirectory = assemblyScopedLegacyAliasesByDirectory ?? + new Dictionary(StringComparer.Ordinal); + AssemblyScopedLegacyToolInfoAliasesByDirectory = assemblyScopedLegacyToolInfoAliasesByDirectory ?? + new Dictionary(StringComparer.Ordinal); + ToolContractsReferenceAssemblyDirectories = toolContractsReferenceAssemblyDirectories ?? + new HashSet(StringComparer.Ordinal); + ApplicationReferenceAssemblyDirectories = applicationReferenceAssemblyDirectories ?? + new HashSet(StringComparer.Ordinal); + DomainReferenceAssemblyDirectories = domainReferenceAssemblyDirectories ?? + new HashSet(StringComparer.Ordinal); + } + + public List AsmdefDirectories { get; } + public List AssemblyReferenceDirectories { get; } + public HashSet LegacyAssemblyDirectories { get; } + public HashSet AssemblyScopedLegacyDirectories { get; } + public Dictionary AssemblyScopedLegacyAliasesByDirectory { get; } + public Dictionary AssemblyScopedLegacyToolInfoAliasesByDirectory { get; } + public HashSet ToolContractsReferenceAssemblyDirectories { get; } + public HashSet ApplicationReferenceAssemblyDirectories { get; } + public HashSet DomainReferenceAssemblyDirectories { get; } + } + + private readonly struct AssemblyReferenceDirectory + { + public AssemblyReferenceDirectory(string sourceDirectory, string targetAssemblyDirectory) + { + Debug.Assert(!string.IsNullOrEmpty(sourceDirectory), "sourceDirectory must not be null or empty"); + Debug.Assert( + !string.IsNullOrEmpty(targetAssemblyDirectory), + "targetAssemblyDirectory must not be null or empty"); + + SourceDirectory = sourceDirectory; + TargetAssemblyDirectory = targetAssemblyDirectory; + } + + public string SourceDirectory { get; } + public string TargetAssemblyDirectory { get; } + } + + private sealed class ProjectFileInventory + { + private ProjectFileInventory( + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths) + { + CSharpFilePaths = csharpFilePaths; + AsmdefFilePaths = asmdefFilePaths; + AsmrefFilePaths = asmrefFilePaths; + } + + public List CSharpFilePaths { get; } + public List AsmdefFilePaths { get; } + public List AsmrefFilePaths { get; } + + public static ProjectFileInventory Create(string projectRoot) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + + List csharpFilePaths = new(); + List asmdefFilePaths = new(); + List asmrefFilePaths = new(); + string assetsDirectory = Path.Combine(projectRoot, "Assets"); + if (Directory.Exists(assetsDirectory)) + { + CollectCandidateFiles( + projectRoot, + assetsDirectory, + csharpFilePaths, + asmdefFilePaths, + asmrefFilePaths); + } + + csharpFilePaths.Sort(StringComparer.Ordinal); + asmdefFilePaths.Sort(StringComparer.Ordinal); + asmrefFilePaths.Sort(StringComparer.Ordinal); + return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths); + } + + public static async Task CreateAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(progress != null, "progress must not be null"); + + List csharpFilePaths = new(); + List asmdefFilePaths = new(); + List asmrefFilePaths = new(); + string assetsDirectory = Path.Combine(projectRoot, "Assets"); + if (Directory.Exists(assetsDirectory)) + { + await CollectCandidateFilesAsync( + projectRoot, + assetsDirectory, + csharpFilePaths, + asmdefFilePaths, + asmrefFilePaths, + progress, + ct); + } + + csharpFilePaths.Sort(StringComparer.Ordinal); + asmdefFilePaths.Sort(StringComparer.Ordinal); + asmrefFilePaths.Sort(StringComparer.Ordinal); + return new ProjectFileInventory(csharpFilePaths, asmdefFilePaths, asmrefFilePaths); + } + + private static void CollectCandidateFiles( + string projectRoot, + string directoryPath, + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) + { + string extension = Path.GetExtension(filePath); + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + csharpFilePaths.Add(filePath); + continue; + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + asmdefFilePaths.Add(filePath); + continue; + } + + if (string.Equals(extension, ".asmref", StringComparison.OrdinalIgnoreCase)) + { + asmrefFilePaths.Add(filePath); + } + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) + { + if (ShouldExcludeDirectory(projectRoot, childDirectoryPath)) + { + continue; + } + + CollectCandidateFiles( + projectRoot, + childDirectoryPath, + csharpFilePaths, + asmdefFilePaths, + asmrefFilePaths); + } + } + + private static async Task CollectCandidateFilesAsync( + string projectRoot, + string assetsDirectory, + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths, + IProgress progress, + CancellationToken ct) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(assetsDirectory), "assetsDirectory must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + Debug.Assert(progress != null, "progress must not be null"); + + Stack pendingDirectories = new(); + pendingDirectories.Push(assetsDirectory); + int inspectedEntryCount = 0; + progress.Report(new ThirdPartyToolMigrationProgress(0, 0)); + + while (pendingDirectories.Count > 0) + { + if (ct.IsCancellationRequested) + { + return; + } + + string directoryPath = pendingDirectories.Pop(); + foreach (string filePath in Directory.EnumerateFiles(directoryPath)) + { + AddCandidateFilePath(filePath, csharpFilePaths, asmdefFilePaths, asmrefFilePaths); + inspectedEntryCount++; + if (inspectedEntryCount % PreviewYieldBatchSize == 0) + { + progress.Report(new ThirdPartyToolMigrationProgress(0, 0)); + await Task.Yield(); + } + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath)) + { + if (ShouldExcludeDirectory(projectRoot, childDirectoryPath)) + { + continue; + } + + pendingDirectories.Push(childDirectoryPath); + inspectedEntryCount++; + if (inspectedEntryCount % PreviewYieldBatchSize == 0) + { + progress.Report(new ThirdPartyToolMigrationProgress(0, 0)); + await Task.Yield(); + } + } + } + } + + private static void AddCandidateFilePath( + string filePath, + List csharpFilePaths, + List asmdefFilePaths, + List asmrefFilePaths) + { + Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty"); + Debug.Assert(csharpFilePaths != null, "csharpFilePaths must not be null"); + Debug.Assert(asmdefFilePaths != null, "asmdefFilePaths must not be null"); + Debug.Assert(asmrefFilePaths != null, "asmrefFilePaths must not be null"); + + string extension = Path.GetExtension(filePath); + if (string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase)) + { + csharpFilePaths.Add(filePath); + return; + } + + if (string.Equals(extension, ".asmdef", StringComparison.OrdinalIgnoreCase)) + { + asmdefFilePaths.Add(filePath); + return; + } + + if (string.Equals(extension, ".asmref", StringComparison.OrdinalIgnoreCase)) + { + asmrefFilePaths.Add(filePath); + } + } + + private static bool ShouldExcludeDirectory(string projectRoot, string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + if (IsProjectRootPackagesDirectory(projectRoot, directoryPath)) + { + return true; + } + + string directoryName = Path.GetFileName(directoryPath); + return ThirdPartyToolMigrationRules.IsExcludedDirectoryName(directoryName); + } + + private static bool IsProjectRootPackagesDirectory(string projectRoot, string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + string packagesDirectory = Path.Combine(projectRoot, "Packages"); + string normalizedPackagesDirectory = NormalizeDirectoryPath(packagesDirectory); + string normalizedDirectoryPath = NormalizeDirectoryPath(directoryPath); + return string.Equals( + normalizedDirectoryPath, + normalizedPackagesDirectory, + StringComparison.Ordinal); + } + + private static string NormalizeDirectoryPath(string directoryPath) + { + Debug.Assert(!string.IsNullOrEmpty(directoryPath), "directoryPath must not be null or empty"); + + return Path.GetFullPath(directoryPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta new file mode 100644 index 000000000..155b99fe4 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationFileService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8dee7da723ba64861ba649d28577d373 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs new file mode 100644 index 000000000..aaf873d04 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs @@ -0,0 +1,3045 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Applies deterministic source rewrites for V2 custom tools that need the V3 public contract API. + /// + internal static class ThirdPartyToolMigrationRules + { + internal const string LegacyNamespace = "io.github.hatayama.uLoopMCP"; + internal const string CurrentNamespace = "io.github.hatayama.UnityCliLoop.ToolContracts"; + internal const string CurrentApplicationNamespace = "io.github.hatayama.UnityCliLoop.Application"; + internal const string CurrentDomainNamespace = "io.github.hatayama.UnityCliLoop.Domain"; + internal const string LegacyEditorAssemblyName = "uLoopMCP.Editor"; + private const string CurrentApplicationAssemblyName = "UnityCLILoop.Application"; + private const string CurrentDomainAssemblyName = "UnityCLILoop.Domain"; + private const string CurrentToolContractsAssemblyName = "UnityCLILoop.ToolContracts"; + internal const string LegacyEditorAssemblyGuidReference = "GUID:214998e563c124e8a88199b2dd1f522d"; + internal const string CurrentApplicationGuidReference = "GUID:214998e563c124e8a88199b2dd1f522d"; + internal const string CurrentDomainGuidReference = "GUID:5c4588558a3624eacbce0f50007cf1eb"; + internal const string CurrentToolContractsGuidReference = "GUID:fc3fd32eddbee40e39c2d76dc184957b"; + private const string DescriptionAttributeArgumentName = "Description"; + private const string DisplayDevelopmentOnlyAttributeArgumentName = "DisplayDevelopmentOnly"; + private const string RequiredSecuritySettingAttributeArgumentName = "RequiredSecuritySetting"; + private const string LegacySecuritySettingsTypeName = "SecuritySettings"; + private const string CurrentSecuritySettingTypeName = "UnityCliLoopSecuritySetting"; + private const int MinimumRawStringDelimiterQuoteCount = 3; + + private static readonly string[] ExcludedDirectoryNames = + { + ".git", + "Library", + "Temp", + "Logs", + "obj", + "bin", + "Build", + "Builds" + }; + + private static readonly string LegacyNamespacePattern = + $@"(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?{Regex.Escape(LegacyNamespace)}\s*;", + RegexOptions.Compiled); + + private static readonly Regex LegacyRegistrarRegex = + new(@"\bCustomToolManager\b", RegexOptions.Compiled); + + private static readonly Regex LegacyQualifiedRegistrarRegex = + new( + $@"(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?" + + @"io\.github\.hatayama\.uLoopMCP\s*;", + RegexOptions.Compiled); + + private static readonly Regex LegacyToolAttributeEntryRegex = + new( + @"^\s*(?:(?(?:global::)?io\.github\.hatayama\.uLoopMCP\.)|(?[A-Za-z_][A-Za-z0-9_]*)\.)?" + + @"McpTool(?:Attribute)?\s*(?:\((?[\s\S]*)\))?\s*$", + RegexOptions.Compiled); + + private static readonly Regex LegacyToolInfoConstructorRegex = + new( + $@"new\s+(?:(?(?:global::)?{Regex.Escape(LegacyNamespace)}\.)ToolInfo|(?[A-Za-z_][A-Za-z0-9_]*)\.ToolInfo|(?ToolInfo)|(?[A-Za-z_][A-Za-z0-9_]*))\s*\(", + RegexOptions.Compiled); + + private static readonly Regex LegacyToolInfoTypeAliasRegex = + new( + $@"\busing\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?{Regex.Escape(LegacyNamespace)}\.ToolInfo\s*;", + RegexOptions.Compiled); + + private static readonly Regex LegacyGlobalToolInfoTypeAliasRegex = + new( + $@"\bglobal\s+using\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:global::)?{Regex.Escape(LegacyNamespace)}\.ToolInfo\s*;", + RegexOptions.Compiled); + + private static readonly Regex LegacyToolSettingsCatalogItemConstructorRegex = + new( + $@"new\s+(?:(?(?:global::)?{Regex.Escape(LegacyNamespace)}\.)ToolSettingsCatalogItem|(?[A-Za-z_][A-Za-z0-9_]*)\.ToolSettingsCatalogItem|(?ToolSettingsCatalogItem))\s*\(", + RegexOptions.Compiled); + + private static readonly TypeReplacementRule[] ToolContractTypeReplacementRules = + { + new("ToolParameterSchemaGenerator", "UnityCliLoopToolParameterSchemaGenerator"), + new("ParameterValidationException", "UnityCliLoopToolParameterValidationException"), + new("Mcp" + "Constants", "UnityCliLoopConstants"), + new("McpToolAttribute", "UnityCliLoopToolAttribute"), + new("IUnityTool", "IUnityCliLoopTool"), + new("AbstractUnityTool", "UnityCliLoopTool"), + new("BaseToolSchema", "UnityCliLoopToolSchema"), + new("BaseToolResponse", "UnityCliLoopToolResponse"), + new("SecuritySettings", CurrentSecuritySettingTypeName) + }; + + private static readonly TypeReplacementRule[] DomainTypeReplacementRules = + { + new("ServiceResult", "ServiceResult"), + new("ToolSettingsCatalogItem", "ToolSettingsCatalogItem") + }; + + private static readonly ReplacementRule[] CSharpReplacementRules = + { + new( + $@"(?(), + legacyAssemblyToolInfoAliases: Array.Empty()); + } + + internal static ThirdPartyToolMigrationContentResult MigrateCSharpSourceForLegacyAssembly( + string source, + bool hasLegacyAssemblySource, + string[] legacyAssemblyAliases, + string[] legacyAssemblyToolInfoAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + Debug.Assert(legacyAssemblyToolInfoAliases != null, "legacyAssemblyToolInfoAliases must not be null"); + + string migratedContent = source; + string[] legacyNamespaceAliases = GetCombinedLegacyNamespaceAliases(source, legacyAssemblyAliases); + bool hasLegacyNamespaceUsage = RegexMatchesCode(source, LegacyNamespaceRegex); + bool hasCurrentDomainNamespaceUsage = RegexMatchesCode(source, CurrentDomainNamespaceRegex); + bool canMigrateBareLegacyToolAttribute = + hasLegacyAssemblySource || + hasLegacyNamespaceUsage || + legacyNamespaceAliases.Length > 0; + bool canMigrateBareLegacyToolInfoConstructor = + canMigrateBareLegacyToolAttribute; + bool canMigrateAmbiguousBareLegacyToolInfoConstructor = + canMigrateBareLegacyToolAttribute && + !hasCurrentDomainNamespaceUsage; + bool hasLocalLegacyMarker = ContainsLegacyToolMigrationMarker(source); + bool shouldApplyContractRenames = hasLegacyAssemblySource || hasLocalLegacyMarker; + bool shouldApplyRegistrarRenames = shouldApplyContractRenames && + RegexMatchesCode(source, LegacyRegistrarRegex); + bool shouldApplyDomainMetadataRenames = shouldApplyContractRenames && + RegexMatchesCode(source, LegacyDomainMetadataRegex); + int replacementCount = 0; + migratedContent = ReplaceLegacyToolAttributesInCode( + migratedContent, + legacyNamespaceAliases, + canMigrateBareLegacyToolAttribute, + ref replacementCount); + migratedContent = ReplaceLegacyToolInfoConstructorsInCode( + migratedContent, + legacyNamespaceAliases, + canMigrateBareLegacyToolInfoConstructor, + canMigrateAmbiguousBareLegacyToolInfoConstructor, + legacyAssemblyToolInfoAliases, + ref replacementCount); + migratedContent = ReplaceLegacyToolSettingsCatalogItemConstructorsInCode( + migratedContent, + legacyNamespaceAliases, + canMigrateBareLegacyToolAttribute, + ref replacementCount); + migratedContent = ReplaceLegacyRegistrarAliasesInCode( + migratedContent, + legacyNamespaceAliases, + ref replacementCount); + + if (shouldApplyContractRenames) + { + migratedContent = ReplaceLegacyDomainTypeNamesInCode( + migratedContent, + legacyNamespaceAliases, + ref replacementCount); + + migratedContent = ReplaceLegacyContractTypeNamesInCode( + migratedContent, + legacyNamespaceAliases, + ref replacementCount); + + foreach (ReplacementRule rule in CSharpReplacementRules) + { + migratedContent = ReplaceRegexInCode( + migratedContent, + rule.PatternRegex, + _ => rule.Replacement, + ref replacementCount); + } + } + + if (shouldApplyRegistrarRenames || shouldApplyDomainMetadataRenames) + { + if (shouldApplyRegistrarRenames) + { + migratedContent = ReplaceUnqualifiedLegacyRegistrarReferencesInCode( + migratedContent, + ref replacementCount); + } + + foreach (ReplacementRule rule in RegistrarReplacementRules) + { + migratedContent = ReplaceRegexInCode( + migratedContent, + rule.PatternRegex, + _ => rule.Replacement, + ref replacementCount); + } + + migratedContent = ReplaceLegacyToolInfoTypeReferencesInCode( + migratedContent, + ref replacementCount); + } + + return new ThirdPartyToolMigrationContentResult( + migratedContent, + replacementCount); + } + + internal static ThirdPartyToolMigrationContentResult MigrateAsmdefSource( + string source, + bool hasLegacyCSharpSource, + bool requiresToolContractsReference, + bool requiresApplicationReference, + bool requiresDomainReference) + { + Debug.Assert(source != null, "source must not be null"); + + JObject asmdef = JObject.Parse(source); + JToken referencesToken = asmdef["references"]; + JArray references = referencesToken == null ? new JArray() : referencesToken as JArray; + if (referencesToken != null && references == null) + { + return new ThirdPartyToolMigrationContentResult(source, 0); + } + + int replacementCount = 0; + HashSet addedReferences = new(StringComparer.Ordinal); + JArray migratedReferences = new(); + foreach (JToken referenceToken in references) + { + string reference = referenceToken.Value() ?? string.Empty; + string[] migratedReferenceItems = GetMigratedAsmdefReferences( + reference, + hasLegacyCSharpSource, + requiresToolContractsReference, + requiresApplicationReference, + requiresDomainReference); + bool referenceChanged = migratedReferenceItems.Length != 1 || + !string.Equals(migratedReferenceItems[0], reference, StringComparison.Ordinal); + if (referenceChanged) + { + replacementCount++; + } + + foreach (string migratedReference in migratedReferenceItems) + { + string migratedReferenceKey = GetCurrentAsmdefReferenceKey(migratedReference); + if (!addedReferences.Add(migratedReferenceKey)) + { + continue; + } + + migratedReferences.Add(migratedReference); + } + } + + AddRequiredCurrentAsmdefReferences( + migratedReferences, + addedReferences, + hasLegacyCSharpSource || requiresToolContractsReference, + requiresApplicationReference, + requiresDomainReference, + ref replacementCount); + + if (replacementCount == 0) + { + return new ThirdPartyToolMigrationContentResult(source, 0); + } + + asmdef["references"] = migratedReferences; + return new ThirdPartyToolMigrationContentResult( + asmdef.ToString(Formatting.Indented), + replacementCount); + } + + internal static bool ContainsLegacyCSharpApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsLegacyToolMigrationMarker(source); + } + + internal static bool ContainsMigrationCandidateText(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (ContainsTextFragment(source, LegacyNamespace) || + ContainsTextFragment(source, CurrentNamespace) || + ContainsTextFragment(source, CurrentApplicationNamespace) || + ContainsTextFragment(source, CurrentDomainNamespace) || + ContainsTextFragment(source, "McpTool") || + ContainsTextFragment(source, "CustomToolManager") || + ContainsTextFragment(source, "UnityCliLoopToolRegistrar") || + ContainsTextFragment(source, "ToolInfo")) + { + return true; + } + + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + if (ContainsTextFragment(source, rule.LegacyName) || + ContainsTextFragment(source, rule.CurrentName)) + { + return true; + } + } + + foreach (TypeReplacementRule rule in DomainTypeReplacementRules) + { + if (ContainsTextFragment(source, rule.LegacyName) || + ContainsTextFragment(source, rule.CurrentName)) + { + return true; + } + } + + return false; + } + + internal static bool ContainsLegacyMigrationCandidateText(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsTextFragment(source, LegacyNamespace) || + ContainsTextFragment(source, LegacyEditorAssemblyName); + } + + internal static bool ContainsLegacyAsmdefNameReference(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (!ContainsTextFragment(source, LegacyEditorAssemblyName)) + { + return false; + } + + JObject asmdef = JObject.Parse(source); + if (asmdef["references"] is not JArray references) + { + return false; + } + + foreach (JToken reference in references) + { + string referenceValue = reference.Value() ?? string.Empty; + if (string.Equals(referenceValue, LegacyEditorAssemblyName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal static bool ContainsLegacyRegistrarApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsLegacyRegistrarApiForAssembly( + source, + hasLegacyAssemblySource: ContainsLegacyToolMigrationMarker(source), + legacyAssemblyAliases: Array.Empty()); + } + + internal static bool ContainsLegacyRegistrarApiForAssembly( + string source, + bool hasLegacyAssemblySource, + string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + string[] legacyNamespaceAliases = GetCombinedLegacyNamespaceAliases(source, legacyAssemblyAliases); + bool canMigrateBareLegacyRegistrar = + hasLegacyAssemblySource || + ContainsLegacyToolMigrationMarker(source) || + legacyNamespaceAliases.Length > 0; + return ContainsLegacyRegistrarReference( + source, + canMigrateBareLegacyRegistrar, + legacyNamespaceAliases); + } + + internal static bool ContainsCurrentRegistrarApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, CurrentRegistrarRegex); + } + + internal static bool ContainsRegistrarDomainReturnApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsRegistrarDomainReturnApiForAssembly( + source, + hasLegacyAssemblySource: ContainsLegacyToolMigrationMarker(source), + legacyAssemblyAliases: Array.Empty()); + } + + internal static bool ContainsRegistrarDomainReturnApiForAssembly( + string source, + bool hasLegacyAssemblySource, + string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + if (RegexMatchesCode(source, CurrentRegistrarDomainReturnRegex)) + { + return true; + } + + string[] legacyNamespaceAliases = GetCombinedLegacyNamespaceAliases(source, legacyAssemblyAliases); + bool canMigrateBareLegacyRegistrar = + hasLegacyAssemblySource || + ContainsLegacyToolMigrationMarker(source) || + legacyNamespaceAliases.Length > 0; + return ContainsLegacyRegistrarDomainReturnReference( + source, + canMigrateBareLegacyRegistrar, + legacyNamespaceAliases); + } + + internal static bool ContainsCurrentToolContractsApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, CurrentToolContractsNamespaceRegex); + } + + internal static bool ContainsLegacyDomainMetadataApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, LegacyDomainMetadataRegex) || + ContainsLegacyDomainHelperApiForAssembly( + source, + hasLegacyAssemblySource: ContainsLegacyToolMigrationMarker(source), + legacyAssemblyAliases: Array.Empty()); + } + + internal static bool ContainsLegacyDomainHelperApiForAssembly( + string source, + bool hasLegacyAssemblySource, + string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + string[] legacyNamespaceAliases = GetCombinedLegacyNamespaceAliases(source, legacyAssemblyAliases); + return ContainsLegacyDomainHelperReference( + source, + hasLegacyAssemblySource, + legacyNamespaceAliases); + } + + internal static bool ContainsCurrentDomainMetadataApi(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return ContainsCurrentDomainMetadataApiForAssembly( + source, + hasAssemblyScopedCurrentDomainUsing: false); + } + + internal static bool ContainsCurrentDomainMetadataApiForAssembly( + string source, + bool hasAssemblyScopedCurrentDomainUsing) + { + Debug.Assert(source != null, "source must not be null"); + + bool hasCurrentDomainNamespaceUsage = RegexMatchesCode(source, CurrentDomainNamespaceRegex); + bool canUseBareCurrentDomainType = hasAssemblyScopedCurrentDomainUsing || hasCurrentDomainNamespaceUsage; + return RegexMatchesCode(source, CurrentDomainMetadataRegex) || + ContainsCurrentDomainHelperApiForAssembly(source, canUseBareCurrentDomainType) || + (canUseBareCurrentDomainType && RegexMatchesCode(source, LegacyDomainMetadataRegex)); + } + + internal static bool ContainsLegacyAssemblyScopedApi(string source, string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + return RegexMatchesCode(source, LegacyBaseTypeUsageRegex) || + RegexMatchesCode(source, LegacyAssemblyScopedApiUsageRegex) || + ContainsLegacyAssemblyScopedTypeReference(source) || + ContainsLegacyAliasQualifiedAssemblyScopedApi(source, legacyAssemblyAliases) || + ContainsLegacyToolAttributeList( + source, + legacyAssemblyAliases, + canMigrateBareLegacyToolAttribute: true); + } + + internal static bool ContainsLegacyGlobalUsing(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, LegacyGlobalUsingRegex); + } + + internal static bool ContainsCurrentDomainGlobalUsing(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, CurrentDomainGlobalUsingRegex); + } + + internal static string[] GetLegacyGlobalNamespaceAliases(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return GetRegexGroupValuesInCode(source, LegacyGlobalNamespaceAliasRegex, "alias"); + } + + internal static string[] GetLegacyGlobalToolInfoTypeAliases(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return GetRegexGroupValuesInCode(source, LegacyGlobalToolInfoTypeAliasRegex, "alias"); + } + + internal static bool ContainsLegacyGlobalToolInfoTypeAlias(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return RegexMatchesCode(source, LegacyGlobalToolInfoTypeAliasRegex); + } + + internal static bool ContainsLegacyTypeAliasReference(string source, string[] aliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(aliases != null, "aliases must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + foreach (string alias in aliases) + { + Regex aliasRegex = new($@"(? string.Equals( + excludedDirectoryName, + directoryName, + StringComparison.OrdinalIgnoreCase)); + } + + internal static string[] GetExcludedDirectoryNames() + { + string[] names = new string[ExcludedDirectoryNames.Length]; + Array.Copy(ExcludedDirectoryNames, names, ExcludedDirectoryNames.Length); + return names; + } + + private static string[] GetMigratedAsmdefReferences( + string reference, + bool hasLegacyCSharpSource, + bool requiresToolContractsReference, + bool requiresApplicationReference, + bool requiresDomainReference) + { + if (string.Equals(reference, LegacyEditorAssemblyName, StringComparison.Ordinal)) + { + return GetMigratedLegacyEditorReferences(requiresApplicationReference, requiresDomainReference); + } + + if (hasLegacyCSharpSource && + string.Equals(reference, LegacyEditorAssemblyGuidReference, StringComparison.Ordinal)) + { + return GetMigratedLegacyEditorReferences(requiresApplicationReference, requiresDomainReference); + } + + return new[] { reference }; + } + + private static string[] GetMigratedLegacyEditorReferences( + bool requiresApplicationReference, + bool requiresDomainReference) + { + List references = new() + { + CurrentToolContractsGuidReference + }; + + if (requiresApplicationReference) + { + references.Add(CurrentApplicationGuidReference); + } + + if (requiresDomainReference) + { + references.Add(CurrentDomainGuidReference); + } + + return references.ToArray(); + } + + private static void AddRequiredCurrentAsmdefReferences( + JArray references, + HashSet addedReferences, + bool requiresToolContractsReference, + bool requiresApplicationReference, + bool requiresDomainReference, + ref int replacementCount) + { + Debug.Assert(references != null, "references must not be null"); + Debug.Assert(addedReferences != null, "addedReferences must not be null"); + + if (requiresToolContractsReference || requiresApplicationReference || requiresDomainReference) + { + AddRequiredCurrentAsmdefReference( + references, + addedReferences, + CurrentToolContractsGuidReference, + ref replacementCount); + } + + if (requiresApplicationReference) + { + AddRequiredCurrentAsmdefReference( + references, + addedReferences, + CurrentApplicationGuidReference, + ref replacementCount); + } + + if (requiresDomainReference) + { + AddRequiredCurrentAsmdefReference( + references, + addedReferences, + CurrentDomainGuidReference, + ref replacementCount); + } + } + + private static void AddRequiredCurrentAsmdefReference( + JArray references, + HashSet addedReferences, + string reference, + ref int replacementCount) + { + Debug.Assert(references != null, "references must not be null"); + Debug.Assert(addedReferences != null, "addedReferences must not be null"); + Debug.Assert(!string.IsNullOrEmpty(reference), "reference must not be null or empty"); + + string referenceKey = GetCurrentAsmdefReferenceKey(reference); + if (!addedReferences.Add(referenceKey)) + { + return; + } + + references.Add(reference); + replacementCount++; + } + + private static string GetCurrentAsmdefReferenceKey(string reference) + { + Debug.Assert(!string.IsNullOrEmpty(reference), "reference must not be null or empty"); + + if (IsCurrentAsmdefReference( + reference, + CurrentToolContractsAssemblyName, + CurrentToolContractsGuidReference)) + { + return CurrentToolContractsAssemblyName; + } + + if (IsCurrentAsmdefReference( + reference, + CurrentApplicationAssemblyName, + CurrentApplicationGuidReference)) + { + return CurrentApplicationAssemblyName; + } + + if (IsCurrentAsmdefReference( + reference, + CurrentDomainAssemblyName, + CurrentDomainGuidReference)) + { + return CurrentDomainAssemblyName; + } + + return reference; + } + + private static bool IsCurrentAsmdefReference( + string reference, + string assemblyName, + string guidReference) + { + Debug.Assert(!string.IsNullOrEmpty(reference), "reference must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(assemblyName), "assemblyName must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(guidReference), "guidReference must not be null or empty"); + + return string.Equals(reference, assemblyName, StringComparison.Ordinal) || + string.Equals(reference, guidReference, StringComparison.Ordinal); + } + + private static bool TryMigrateLegacyToolAttributeList( + string attributesSource, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyToolAttribute, + out string migratedAttributes) + { + Debug.Assert(attributesSource != null, "attributesSource must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + + string[] attributes = SplitAttributeArguments(attributesSource); + List migratedAttributeItems = new(); + bool changed = false; + foreach (string attribute in attributes) + { + string trimmedAttribute = attribute.Trim(); + if (TryMigrateLegacyToolAttributeEntry( + trimmedAttribute, + legacyNamespaceAliases, + canMigrateBareLegacyToolAttribute, + out string migratedAttribute)) + { + migratedAttributeItems.Add(migratedAttribute); + changed = true; + continue; + } + + migratedAttributeItems.Add(trimmedAttribute); + } + + if (!changed) + { + migratedAttributes = string.Empty; + return false; + } + + migratedAttributes = string.Join(", ", migratedAttributeItems); + return true; + } + + private static bool TryMigrateLegacyToolAttributeEntry( + string attribute, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyToolAttribute, + out string migratedAttribute) + { + Debug.Assert(attribute != null, "attribute must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + + Match match = LegacyToolAttributeEntryRegex.Match(attribute); + if (!match.Success) + { + migratedAttribute = string.Empty; + return false; + } + + bool hasQualifier = match.Groups["qualifier"].Success; + bool hasAlias = match.Groups["alias"].Success; + if (!hasQualifier && !hasAlias && !canMigrateBareLegacyToolAttribute) + { + migratedAttribute = string.Empty; + return false; + } + + if (hasAlias && !legacyNamespaceAliases.Contains(match.Groups["alias"].Value)) + { + migratedAttribute = string.Empty; + return false; + } + + string argumentsSource = match.Groups["arguments"].Value; + string[] migratedArguments = GetMigratedSupportedAttributeArguments(argumentsSource); + string attributeName = hasQualifier || hasAlias + ? $"{CurrentNamespace}.UnityCliLoopTool" + : "UnityCliLoopTool"; + migratedAttribute = migratedArguments.Length == 0 + ? attributeName + : $"{attributeName}({string.Join(", ", migratedArguments)})"; + return true; + } + + private static string[] GetMigratedSupportedAttributeArguments(string argumentsSource) + { + Debug.Assert(argumentsSource != null, "argumentsSource must not be null"); + + List migratedArguments = new(); + string[] arguments = SplitAttributeArguments(argumentsSource); + foreach (string argument in arguments) + { + string trimmedArgument = argument.Trim(); + if (trimmedArgument.Length == 0) + { + continue; + } + + if (IsNamedAttributeArgument(trimmedArgument, DescriptionAttributeArgumentName)) + { + continue; + } + + if (IsNamedAttributeArgument(trimmedArgument, DisplayDevelopmentOnlyAttributeArgumentName)) + { + migratedArguments.Add(trimmedArgument); + continue; + } + + if (IsNamedAttributeArgument(trimmedArgument, RequiredSecuritySettingAttributeArgumentName)) + { + migratedArguments.Add(trimmedArgument); + } + } + + return migratedArguments.ToArray(); + } + + private static string[] SplitAttributeArguments(string argumentsSource) + { + Debug.Assert(argumentsSource != null, "argumentsSource must not be null"); + + List arguments = new(); + int argumentStartIndex = 0; + int nestingDepth = 0; + bool isInRegularString = false; + bool isInVerbatimString = false; + bool isInCharLiteral = false; + bool isInRawString = false; + int rawStringQuoteCount = 0; + for (int i = 0; i < argumentsSource.Length; i++) + { + char current = argumentsSource[i]; + if (isInRegularString) + { + if (current == '\\') + { + i++; + continue; + } + + if (current == '"') + { + isInRegularString = false; + } + + continue; + } + + if (isInVerbatimString) + { + if (current != '"') + { + continue; + } + + if (i + 1 < argumentsSource.Length && argumentsSource[i + 1] == '"') + { + i++; + continue; + } + + isInVerbatimString = false; + continue; + } + + if (isInRawString) + { + if (HasRepeatedCharacterAt(argumentsSource, i, '"', rawStringQuoteCount)) + { + i += rawStringQuoteCount - 1; + isInRawString = false; + } + + continue; + } + + if (isInCharLiteral) + { + if (current == '\\') + { + i++; + continue; + } + + if (current == '\'') + { + isInCharLiteral = false; + } + + continue; + } + + if (IsRawStringStart(argumentsSource, i)) + { + int dollarCount = CountRepeatedCharacter(argumentsSource, i, '$'); + int quoteIndex = i + dollarCount; + rawStringQuoteCount = CountRepeatedCharacter(argumentsSource, quoteIndex, '"'); + isInRawString = true; + i = quoteIndex + rawStringQuoteCount - 1; + continue; + } + + if (StartsWith(argumentsSource, i, "@\"") || + StartsWith(argumentsSource, i, "$@\"") || + StartsWith(argumentsSource, i, "@$\"")) + { + isInVerbatimString = true; + i += GetStringPrefixLength(argumentsSource, i); + continue; + } + + if (StartsWith(argumentsSource, i, "$\"")) + { + isInRegularString = true; + i++; + continue; + } + + if (current == '"') + { + isInRegularString = true; + continue; + } + + if (current == '\'') + { + isInCharLiteral = true; + continue; + } + + if (current == '(' || current == '[' || current == '{') + { + nestingDepth++; + continue; + } + + if (current == ')' || current == ']' || current == '}') + { + nestingDepth = Math.Max(0, nestingDepth - 1); + continue; + } + + if (current != ',' || nestingDepth != 0) + { + continue; + } + + arguments.Add(argumentsSource.Substring(argumentStartIndex, i - argumentStartIndex)); + argumentStartIndex = i + 1; + } + + arguments.Add(argumentsSource.Substring(argumentStartIndex)); + return arguments.ToArray(); + } + + private static string[] GetLegacyNamespaceAliases(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return GetRegexGroupValuesInCode(source, LegacyNamespaceAliasRegex, "alias"); + } + + private static string[] GetLegacyToolInfoTypeAliases(string source) + { + Debug.Assert(source != null, "source must not be null"); + + return GetRegexGroupValuesInCode(source, LegacyToolInfoTypeAliasRegex, "alias"); + } + + private static string[] GetCombinedLegacyNamespaceAliases( + string source, + string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + return GetLegacyNamespaceAliases(source) + .Concat(legacyAssemblyAliases) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static string[] GetCombinedLegacyToolInfoTypeAliases( + string source, + string[] legacyAssemblyToolInfoAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyToolInfoAliases != null, "legacyAssemblyToolInfoAliases must not be null"); + + return GetLegacyToolInfoTypeAliases(source) + .Concat(legacyAssemblyToolInfoAliases) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static string[] GetRegexGroupValuesInCode(string source, Regex regex, string groupName) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(regex != null, "regex must not be null"); + Debug.Assert(!string.IsNullOrEmpty(groupName), "groupName must not be null or empty"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + List values = new(); + MatchCollection matches = regex.Matches(source); + foreach (Match match in matches) + { + if (!codeTextMask.IsCodeAt(match.Index)) + { + continue; + } + + values.Add(match.Groups[groupName].Value); + } + + return values.ToArray(); + } + + private static string ReplaceLegacyRegistrarAliasesInCode( + string source, + string[] aliases, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(aliases != null, "aliases must not be null"); + + string migratedContent = source; + foreach (string alias in aliases) + { + Regex aliasRegistrarRegex = new( + $@"(? $"{CurrentApplicationNamespace}.UnityCliLoopToolRegistrar", + ref replacementCount); + + Regex aliasToolInfoRegex = new( + $@"(? $"{CurrentDomainNamespace}.ToolInfo", + ref replacementCount); + } + + return migratedContent; + } + + private static string ReplaceUnqualifiedLegacyRegistrarReferencesInCode( + string source, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + + Regex unqualifiedRegistrarRegex = + new(@"(? ShouldMigrateLegacyTypeReference(source, "CustomToolManager", match.Index) + ? $"{CurrentApplicationNamespace}.UnityCliLoopToolRegistrar" + : match.Value, + ref replacementCount); + } + + private static string ReplaceLegacyContractTypeNamesInCode( + string source, + string[] aliases, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(aliases != null, "aliases must not be null"); + + string migratedContent = source; + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + Regex fullyQualifiedRegex = new( + $@"(?:(?:global::)?{Regex.Escape(LegacyNamespace)}\.){Regex.Escape(rule.LegacyName)}\b", + RegexOptions.Compiled); + migratedContent = ReplaceRegexInCode( + migratedContent, + fullyQualifiedRegex, + _ => $"{CurrentNamespace}.{rule.CurrentName}", + ref replacementCount); + + foreach (string alias in aliases) + { + Regex aliasRegex = new( + $@"(? $"{alias}.{rule.CurrentName}", + ref replacementCount); + } + + Regex unqualifiedRegex = new( + $@"(? ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) + ? rule.CurrentName + : match.Value, + ref replacementCount); + } + + return migratedContent; + } + + private static string ReplaceLegacyDomainTypeNamesInCode( + string source, + string[] aliases, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(aliases != null, "aliases must not be null"); + + string migratedContent = source; + foreach (TypeReplacementRule rule in DomainTypeReplacementRules) + { + Regex fullyQualifiedRegex = new( + $@"(?:(?:global::)?{Regex.Escape(LegacyNamespace)}\.){Regex.Escape(rule.LegacyName)}\b", + RegexOptions.Compiled); + migratedContent = ReplaceRegexInCode( + migratedContent, + fullyQualifiedRegex, + _ => $"{CurrentDomainNamespace}.{rule.CurrentName}", + ref replacementCount); + + foreach (string alias in aliases) + { + Regex aliasRegex = new( + $@"(? $"{CurrentDomainNamespace}.{rule.CurrentName}", + ref replacementCount); + } + + Regex unqualifiedRegex = new( + $@"(? ShouldMigrateLegacyTypeReference(migratedContent, rule.LegacyName, match.Index) + ? $"{CurrentDomainNamespace}.{rule.CurrentName}" + : match.Value, + ref replacementCount); + } + + return migratedContent; + } + + private static string ReplaceLegacyToolInfoTypeReferencesInCode(string source, ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + + return ReplaceRegexInCode( + source, + UnqualifiedToolInfoRegex, + match => ShouldMigrateLegacyToolInfoTypeReference(source, match.Index) + ? $"{CurrentDomainNamespace}.ToolInfo" + : match.Value, + ref replacementCount); + } + + private static bool ShouldMigrateLegacyToolInfoTypeReference(string source, int toolInfoIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(toolInfoIndex >= 0, "toolInfoIndex must not be negative"); + + return ShouldMigrateLegacyTypeReference(source, "ToolInfo", toolInfoIndex); + } + + private static bool ShouldMigrateLegacyTypeReference(string source, string typeName, int typeNameIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(typeName), "typeName must not be empty"); + Debug.Assert(typeNameIndex >= 0, "typeNameIndex must not be negative"); + + if (IsLegacyAssemblyScopedTypeDeclaration(source, typeNameIndex)) + { + return false; + } + + char nextCharacter = ReadNextNonWhitespaceCharacter(source, typeNameIndex + typeName.Length); + char previousCharacter = ReadPreviousNonWhitespaceCharacter(source, typeNameIndex); + if (nextCharacter == '(' && !PreviousCodeTokenEquals(source, typeNameIndex, "new")) + { + return false; + } + + return !IsDeclarationIdentifierTerminator(nextCharacter) || + !CanPrecedeDeclarationIdentifier(previousCharacter); + } + + private static string ReplaceLegacyToolInfoConstructorsInCode( + string source, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyToolInfoConstructor, + bool canMigrateAmbiguousBareLegacyToolInfoConstructor, + string[] legacyAssemblyToolInfoAliases, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + Debug.Assert(legacyAssemblyToolInfoAliases != null, "legacyAssemblyToolInfoAliases must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + string[] legacyToolInfoTypeAliases = + GetCombinedLegacyToolInfoTypeAliases(source, legacyAssemblyToolInfoAliases); + MatchCollection matches = LegacyToolInfoConstructorRegex.Matches(source); + StringBuilder builder = new(source.Length); + int sourceCopyIndex = 0; + int localReplacementCount = 0; + foreach (Match match in matches) + { + if (match.Index < sourceCopyIndex || + !codeTextMask.IsCodeAt(match.Index) || + !IsLegacyToolInfoConstructorMatch( + match, + legacyNamespaceAliases, + legacyToolInfoTypeAliases, + canMigrateBareLegacyToolInfoConstructor)) + { + continue; + } + + int openParenthesisIndex = match.Index + match.Length - 1; + int closingParenthesisIndex = FindInvocationClosingParenthesisIndex( + source, + codeTextMask, + openParenthesisIndex); + if (closingParenthesisIndex < 0) + { + continue; + } + + string argumentsSource = source.Substring( + openParenthesisIndex + 1, + closingParenthesisIndex - openParenthesisIndex - 1); + string[] arguments = SplitAttributeArguments(argumentsSource); + if (!ShouldMigrateLegacyToolInfoConstructorArguments( + match, + arguments, + canMigrateAmbiguousBareLegacyToolInfoConstructor)) + { + continue; + } + + string[] migratedArguments = GetMigratedToolInfoConstructorArguments(arguments); + if (migratedArguments.Length == arguments.Length) + { + continue; + } + + builder.Append(source, sourceCopyIndex, match.Index - sourceCopyIndex); + builder.Append($"new {CurrentDomainNamespace}.ToolInfo("); + builder.Append(string.Join(", ", migratedArguments)); + builder.Append(')'); + sourceCopyIndex = closingParenthesisIndex + 1; + localReplacementCount++; + } + + if (localReplacementCount == 0) + { + return source; + } + + builder.Append(source, sourceCopyIndex, source.Length - sourceCopyIndex); + replacementCount += localReplacementCount; + return builder.ToString(); + } + + private static string ReplaceLegacyToolSettingsCatalogItemConstructorsInCode( + string source, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyConstructor, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + MatchCollection matches = LegacyToolSettingsCatalogItemConstructorRegex.Matches(source); + StringBuilder builder = new(source.Length); + int sourceCopyIndex = 0; + int localReplacementCount = 0; + foreach (Match match in matches) + { + if (match.Index < sourceCopyIndex || + !codeTextMask.IsCodeAt(match.Index) || + !IsLegacyToolSettingsCatalogItemConstructorMatch( + match, + legacyNamespaceAliases, + canMigrateBareLegacyConstructor)) + { + continue; + } + + int openParenthesisIndex = match.Index + match.Length - 1; + int closingParenthesisIndex = FindInvocationClosingParenthesisIndex( + source, + codeTextMask, + openParenthesisIndex); + if (closingParenthesisIndex < 0) + { + continue; + } + + string argumentsSource = source.Substring( + openParenthesisIndex + 1, + closingParenthesisIndex - openParenthesisIndex - 1); + string[] arguments = SplitAttributeArguments(argumentsSource); + string[] migratedArguments = GetMigratedToolSettingsCatalogItemConstructorArguments(arguments); + if (migratedArguments.Length == arguments.Length) + { + continue; + } + + builder.Append(source, sourceCopyIndex, match.Index - sourceCopyIndex); + builder.Append($"new {CurrentDomainNamespace}.ToolSettingsCatalogItem("); + builder.Append(string.Join(", ", migratedArguments)); + builder.Append(')'); + sourceCopyIndex = closingParenthesisIndex + 1; + localReplacementCount++; + } + + if (localReplacementCount == 0) + { + return source; + } + + builder.Append(source, sourceCopyIndex, source.Length - sourceCopyIndex); + replacementCount += localReplacementCount; + return builder.ToString(); + } + + private static bool IsLegacyToolSettingsCatalogItemConstructorMatch( + Match match, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyConstructor) + { + Debug.Assert(match != null, "match must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + + if (match.Groups["qualifier"].Success) + { + return true; + } + + if (match.Groups["alias"].Success) + { + return legacyNamespaceAliases.Contains(match.Groups["alias"].Value); + } + + return match.Groups["toolSettingsCatalogItem"].Success && canMigrateBareLegacyConstructor; + } + + private static bool ShouldMigrateLegacyToolInfoConstructorArguments( + Match match, + string[] arguments, + bool canMigrateAmbiguousBareLegacyToolInfoConstructor) + { + Debug.Assert(match != null, "match must not be null"); + Debug.Assert(arguments != null, "arguments must not be null"); + + if (!match.Groups["toolInfo"].Success) + { + return true; + } + + return canMigrateAmbiguousBareLegacyToolInfoConstructor || + HasUnambiguousLegacyToolInfoConstructorArguments(arguments); + } + + private static bool HasUnambiguousLegacyToolInfoConstructorArguments(string[] arguments) + { + Debug.Assert(arguments != null, "arguments must not be null"); + + if (arguments.Length == 4) + { + return true; + } + + if (arguments.Length == 3 && IsLikelyLegacyDescriptionArgument(arguments[1])) + { + return true; + } + + return FindNamedConstructorArgumentIndex( + arguments, + DescriptionAttributeArgumentName.ToLowerInvariant()) >= 0; + } + + private static bool IsLikelyLegacyDescriptionArgument(string argument) + { + Debug.Assert(argument != null, "argument must not be null"); + + string trimmedArgument = argument.Trim(); + return IsStringLiteralArgument(trimmedArgument) || + string.Equals(trimmedArgument, "description", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsStringLiteralArgument(string argument) + { + Debug.Assert(argument != null, "argument must not be null"); + + return argument.StartsWith("\"", StringComparison.Ordinal) || + argument.StartsWith("@\"", StringComparison.Ordinal) || + argument.StartsWith("$\"", StringComparison.Ordinal) || + argument.StartsWith("$@\"", StringComparison.Ordinal) || + argument.StartsWith("@$\"", StringComparison.Ordinal); + } + + private static bool IsLegacyToolInfoConstructorMatch( + Match match, + string[] legacyNamespaceAliases, + string[] legacyToolInfoTypeAliases, + bool canMigrateBareLegacyToolInfoConstructor) + { + Debug.Assert(match != null, "match must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + Debug.Assert(legacyToolInfoTypeAliases != null, "legacyToolInfoTypeAliases must not be null"); + + if (match.Groups["qualifier"].Success) + { + return true; + } + + if (match.Groups["alias"].Success) + { + return legacyNamespaceAliases.Contains(match.Groups["alias"].Value); + } + + if (match.Groups["typeAlias"].Success) + { + return legacyToolInfoTypeAliases.Contains(match.Groups["typeAlias"].Value); + } + + if (match.Groups["toolInfo"].Success) + { + return canMigrateBareLegacyToolInfoConstructor; + } + + return false; + } + + private static string[] GetMigratedToolInfoConstructorArguments(string[] arguments) + { + Debug.Assert(arguments != null, "arguments must not be null"); + + int namedDescriptionArgumentIndex = FindNamedConstructorArgumentIndex( + arguments, + DescriptionAttributeArgumentName.ToLowerInvariant()); + if (namedDescriptionArgumentIndex >= 0) + { + return RemoveArgumentAt(arguments, namedDescriptionArgumentIndex); + } + + if (arguments.Length == 4) + { + return new[] + { + arguments[0].Trim(), + arguments[2].Trim(), + arguments[3].Trim() + }; + } + + if (arguments.Length == 3) + { + return new[] + { + arguments[0].Trim(), + arguments[2].Trim() + }; + } + + return arguments; + } + + private static string[] GetMigratedToolSettingsCatalogItemConstructorArguments(string[] arguments) + { + Debug.Assert(arguments != null, "arguments must not be null"); + + int namedDescriptionArgumentIndex = FindNamedConstructorArgumentIndex( + arguments, + DescriptionAttributeArgumentName.ToLowerInvariant()); + if (namedDescriptionArgumentIndex >= 0) + { + return RemoveArgumentAt(arguments, namedDescriptionArgumentIndex); + } + + if (arguments.Length == 4) + { + return new[] + { + arguments[0].Trim(), + arguments[2].Trim(), + arguments[3].Trim() + }; + } + + return arguments; + } + + private static int FindNamedConstructorArgumentIndex(string[] arguments, string argumentName) + { + Debug.Assert(arguments != null, "arguments must not be null"); + Debug.Assert(!string.IsNullOrEmpty(argumentName), "argumentName must not be null or empty"); + + for (int i = 0; i < arguments.Length; i++) + { + if (IsNamedConstructorArgument(arguments[i].Trim(), argumentName)) + { + return i; + } + } + + return -1; + } + + private static bool IsNamedConstructorArgument(string argument, string argumentName) + { + Debug.Assert(argument != null, "argument must not be null"); + Debug.Assert(!string.IsNullOrEmpty(argumentName), "argumentName must not be null or empty"); + + int colonIndex = argument.IndexOf(':'); + if (colonIndex <= 0) + { + return false; + } + + string possibleArgumentName = argument.Substring(0, colonIndex).Trim(); + return string.Equals(possibleArgumentName, argumentName, StringComparison.Ordinal); + } + + private static string[] RemoveArgumentAt(string[] arguments, int removeIndex) + { + Debug.Assert(arguments != null, "arguments must not be null"); + Debug.Assert(removeIndex >= 0, "removeIndex must not be negative"); + Debug.Assert(removeIndex < arguments.Length, "removeIndex must be within arguments"); + + List migratedArguments = new(); + for (int i = 0; i < arguments.Length; i++) + { + if (i == removeIndex) + { + continue; + } + + migratedArguments.Add(arguments[i].Trim()); + } + + return migratedArguments.ToArray(); + } + + private static int FindInvocationClosingParenthesisIndex( + string source, + CodeTextMask codeTextMask, + int openParenthesisIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(openParenthesisIndex >= 0, "openParenthesisIndex must not be negative"); + + int nestedParenthesisDepth = 0; + for (int i = openParenthesisIndex + 1; i < source.Length; i++) + { + if (!codeTextMask.IsCodeAt(i)) + { + continue; + } + + if (source[i] == '(') + { + nestedParenthesisDepth++; + continue; + } + + if (source[i] != ')') + { + continue; + } + + if (nestedParenthesisDepth == 0) + { + return i; + } + + nestedParenthesisDepth--; + } + + return -1; + } + + private static bool IsNamedAttributeArgument(string argument, string argumentName) + { + Debug.Assert(!string.IsNullOrWhiteSpace(argument), "argument must not be null or whitespace"); + Debug.Assert(!string.IsNullOrWhiteSpace(argumentName), "argumentName must not be null or whitespace"); + + if (!argument.StartsWith(argumentName, StringComparison.Ordinal)) + { + return false; + } + + for (int i = argumentName.Length; i < argument.Length; i++) + { + char current = argument[i]; + if (char.IsWhiteSpace(current)) + { + continue; + } + + return current == '='; + } + + return false; + } + + private static string ReplaceRegexInCode( + string source, + Regex regex, + Func replacementFactory, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(regex != null, "regex must not be null"); + Debug.Assert(replacementFactory != null, "replacementFactory must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + int localReplacementCount = 0; + string migrated = regex.Replace( + source, + match => + { + if (!codeTextMask.IsCodeAt(match.Index)) + { + return match.Value; + } + + string replacement = replacementFactory(match); + if (string.Equals(match.Value, replacement, StringComparison.Ordinal)) + { + return match.Value; + } + + localReplacementCount++; + return replacement; + }); + replacementCount += localReplacementCount; + return migrated; + } + + private static string ReplaceLegacyToolAttributesInCode( + string source, + string[] legacyNamespaceAliases, + bool canMigrateBareLegacyToolAttribute, + ref int replacementCount) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyNamespaceAliases != null, "legacyNamespaceAliases must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + StringBuilder builder = new(source.Length); + int localReplacementCount = 0; + int index = 0; + while (index < source.Length) + { + if (source[index] != '[' || !codeTextMask.IsCodeAt(index)) + { + builder.Append(source[index]); + index++; + continue; + } + + int closingBracketIndex = FindAttributeListClosingBracketIndex( + source, + codeTextMask, + index + 1); + if (closingBracketIndex < 0) + { + builder.Append(source[index]); + index++; + continue; + } + + string attributesSource = source.Substring(index + 1, closingBracketIndex - index - 1); + if (!TryMigrateLegacyToolAttributeList( + attributesSource, + legacyNamespaceAliases, + canMigrateBareLegacyToolAttribute, + out string migratedAttributes)) + { + builder.Append(source, index, closingBracketIndex - index + 1); + index = closingBracketIndex + 1; + continue; + } + + builder.Append('['); + builder.Append(migratedAttributes); + builder.Append(']'); + localReplacementCount++; + index = closingBracketIndex + 1; + } + + replacementCount += localReplacementCount; + return builder.ToString(); + } + + private static int FindAttributeListClosingBracketIndex( + string source, + CodeTextMask codeTextMask, + int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + int nestedBracketDepth = 0; + for (int i = startIndex; i < source.Length; i++) + { + if (!codeTextMask.IsCodeAt(i)) + { + continue; + } + + if (source[i] == '[') + { + nestedBracketDepth++; + continue; + } + + if (source[i] != ']') + { + continue; + } + + if (nestedBracketDepth == 0) + { + return i; + } + + nestedBracketDepth--; + } + + return -1; + } + + private static bool RegexMatchesCode(string source, Regex regex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(regex != null, "regex must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + MatchCollection matches = regex.Matches(source); + foreach (Match match in matches) + { + if (codeTextMask.IsCodeAt(match.Index)) + { + return true; + } + } + + return false; + } + + private static bool ContainsLegacyAssemblyScopedTypeReference(string source) + { + Debug.Assert(source != null, "source must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + if (ContainsLegacyAssemblyScopedTypeName(source, codeTextMask, rule.LegacyName)) + { + return true; + } + } + + foreach (TypeReplacementRule rule in DomainTypeReplacementRules) + { + if (ContainsLegacyAssemblyScopedTypeName(source, codeTextMask, rule.LegacyName)) + { + return true; + } + } + + return ContainsLegacyAssemblyScopedTypeName(source, codeTextMask, "ToolInfo"); + } + + private static bool ContainsLegacyAliasQualifiedAssemblyScopedApi( + string source, + string[] legacyAssemblyAliases) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + foreach (string alias in legacyAssemblyAliases) + { + foreach (TypeReplacementRule rule in ToolContractTypeReplacementRules) + { + if (ContainsLegacyAliasQualifiedName(source, alias, rule.LegacyName)) + { + return true; + } + } + + foreach (TypeReplacementRule rule in DomainTypeReplacementRules) + { + if (ContainsLegacyAliasQualifiedName(source, alias, rule.LegacyName)) + { + return true; + } + } + + if (ContainsLegacyAliasQualifiedName(source, alias, "ToolInfo") || + ContainsLegacyAliasQualifiedName(source, alias, "CustomToolManager")) + { + return true; + } + } + + return false; + } + + private static bool ContainsLegacyAliasQualifiedName(string source, string alias, string typeName) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(alias), "alias must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(typeName), "typeName must not be null or empty"); + + Regex aliasQualifiedRegex = new( + $@"(?= 0, "typeNameIndex must not be negative"); + + string previousIdentifier = ReadPreviousIdentifier(source, typeNameIndex); + return string.Equals(previousIdentifier, "class", StringComparison.Ordinal) || + string.Equals(previousIdentifier, "struct", StringComparison.Ordinal) || + string.Equals(previousIdentifier, "interface", StringComparison.Ordinal) || + string.Equals(previousIdentifier, "enum", StringComparison.Ordinal) || + string.Equals(previousIdentifier, "using", StringComparison.Ordinal); + } + + private static string ReadPreviousIdentifier(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + int index = startIndex - 1; + while (index >= 0 && char.IsWhiteSpace(source[index])) + { + index--; + } + + int identifierEndIndex = index + 1; + while (index >= 0 && IsIdentifierCharacter(source[index])) + { + index--; + } + + return source.Substring(index + 1, identifierEndIndex - index - 1); + } + + private static char ReadNextNonWhitespaceCharacter(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + for (int index = startIndex; index < source.Length; index++) + { + if (char.IsWhiteSpace(source[index])) + { + continue; + } + + return source[index]; + } + + return '\0'; + } + + private static char ReadPreviousNonWhitespaceCharacter(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + for (int index = startIndex - 1; index >= 0; index--) + { + if (char.IsWhiteSpace(source[index])) + { + continue; + } + + return source[index]; + } + + return '\0'; + } + + private static bool IsDeclarationIdentifierTerminator(char value) + { + return value == '{' || + value == ';' || + value == '=' || + value == ')' || + value == ','; + } + + private static bool PreviousCodeTokenEquals(string source, int endIndex, string token) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(endIndex >= 0, "endIndex must not be negative"); + Debug.Assert(!string.IsNullOrEmpty(token), "token must not be null or empty"); + + int tokenEndIndex = endIndex - 1; + while (tokenEndIndex >= 0 && char.IsWhiteSpace(source[tokenEndIndex])) + { + tokenEndIndex--; + } + + int tokenStartIndex = tokenEndIndex; + while (tokenStartIndex >= 0 && IsIdentifierCharacter(source[tokenStartIndex])) + { + tokenStartIndex--; + } + + int tokenLength = tokenEndIndex - tokenStartIndex; + if (tokenLength <= 0) + { + return false; + } + + string previousToken = source.Substring(tokenStartIndex + 1, tokenLength); + return string.Equals(previousToken, token, StringComparison.Ordinal); + } + + private static bool CanPrecedeDeclarationIdentifier(char value) + { + return IsIdentifierCharacter(value) || + value == ']' || + value == '>'; + } + + private static bool IsIdentifierCharacter(char value) + { + return char.IsLetterOrDigit(value) || value == '_'; + } + + private static bool ContainsLegacyToolMigrationMarker(string source) + { + Debug.Assert(source != null, "source must not be null"); + + if (RegexMatchesCode(source, LegacyNamespaceRegex)) return true; + + return ContainsLegacyToolAttributeList( + source, + Array.Empty(), + canMigrateBareLegacyToolAttribute: false); + } + + private static bool ContainsLegacyToolAttributeList( + string source, + string[] legacyAssemblyAliases, + bool canMigrateBareLegacyToolAttribute) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(legacyAssemblyAliases != null, "legacyAssemblyAliases must not be null"); + + CodeTextMask codeTextMask = CodeTextMask.Create(source); + string[] legacyNamespaceAliases = GetCombinedLegacyNamespaceAliases(source, legacyAssemblyAliases); + int index = 0; + while (index < source.Length) + { + if (source[index] != '[' || !codeTextMask.IsCodeAt(index)) + { + index++; + continue; + } + + int closingBracketIndex = FindAttributeListClosingBracketIndex( + source, + codeTextMask, + index + 1); + if (closingBracketIndex < 0) + { + index++; + continue; + } + + string attributesSource = source.Substring(index + 1, closingBracketIndex - index - 1); + if (TryMigrateLegacyToolAttributeList( + attributesSource, + legacyNamespaceAliases, + canMigrateBareLegacyToolAttribute, + out _)) + { + return true; + } + + index = closingBracketIndex + 1; + } + + return false; + } + + private static bool StartsWith(string source, int startIndex, string value) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + Debug.Assert(value != null, "value must not be null"); + + if (startIndex + value.Length > source.Length) + { + return false; + } + + return string.CompareOrdinal(source, startIndex, value, 0, value.Length) == 0; + } + + private static bool ContainsTextFragment(string source, string text) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(!string.IsNullOrEmpty(text), "text must not be null or empty"); + + return source.IndexOf(text, StringComparison.Ordinal) >= 0; + } + + private static bool IsRawStringStart(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + int dollarCount = CountRepeatedCharacter(source, startIndex, '$'); + int quoteIndex = startIndex + dollarCount; + return CountRepeatedCharacter(source, quoteIndex, '"') >= MinimumRawStringDelimiterQuoteCount; + } + + private static int CountRepeatedCharacter(string source, int startIndex, char character) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + int index = startIndex; + while (index < source.Length && source[index] == character) + { + index++; + } + + return index - startIndex; + } + + private static bool HasRepeatedCharacterAt(string source, int startIndex, char character, int count) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + Debug.Assert(count > 0, "count must be positive"); + + if (startIndex + count > source.Length) + { + return false; + } + + for (int i = 0; i < count; i++) + { + if (source[startIndex + i] != character) + { + return false; + } + } + + return true; + } + + private static int GetStringPrefixLength(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + if (StartsWith(source, startIndex, "$@\"") || StartsWith(source, startIndex, "@$\"")) + { + return 2; + } + + return 1; + } + + private readonly struct ReplacementRule + { + public ReplacementRule(string pattern, string replacement) + { + Debug.Assert(!string.IsNullOrEmpty(pattern), "pattern must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(replacement), "replacement must not be null or empty"); + + PatternRegex = new Regex(pattern, RegexOptions.Compiled); + Replacement = replacement; + } + + public Regex PatternRegex { get; } + public string Replacement { get; } + } + + private readonly struct TypeReplacementRule + { + public TypeReplacementRule(string legacyName, string currentName) + { + Debug.Assert(!string.IsNullOrEmpty(legacyName), "legacyName must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(currentName), "currentName must not be null or empty"); + + LegacyName = legacyName; + CurrentName = currentName; + } + + public string LegacyName { get; } + public string CurrentName { get; } + } + + private readonly struct CodeTextMask + { + private readonly bool[] _codeCharacters; + + private CodeTextMask(bool[] codeCharacters) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + + _codeCharacters = codeCharacters; + } + + public static CodeTextMask Create(string source) + { + Debug.Assert(source != null, "source must not be null"); + + bool[] codeCharacters = new bool[source.Length]; + for (int i = 0; i < codeCharacters.Length; i++) + { + codeCharacters[i] = true; + } + + int index = 0; + while (index < source.Length) + { + if (IsInterpolatedRawStringStart(source, index)) + { + index = MarkInterpolatedRawStringAsIgnored(codeCharacters, source, index); + continue; + } + + if (StartsWith(source, index, "$@\"") || StartsWith(source, index, "@$\"")) + { + index = MarkInterpolatedVerbatimStringAsIgnored(codeCharacters, source, index); + continue; + } + + if (StartsWith(source, index, "$\"") && !StartsWith(source, index, "$\"\"\"")) + { + index = MarkInterpolatedRegularStringAsIgnored(codeCharacters, source, index); + continue; + } + + int ignoredTextEndIndex = GetIgnoredTextEndIndex(source, index); + if (ignoredTextEndIndex == index) + { + index++; + continue; + } + + MarkRangeAsIgnored(codeCharacters, index, ignoredTextEndIndex); + index = ignoredTextEndIndex; + } + + return new CodeTextMask(codeCharacters); + } + + public bool IsCodeAt(int index) + { + if (index < 0 || index >= _codeCharacters.Length) + { + return false; + } + + return _codeCharacters[index]; + } + + private static int GetIgnoredTextEndIndex(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + if (StartsWith(source, startIndex, "//")) + { + return FindLineCommentEndIndex(source, startIndex); + } + + if (StartsWith(source, startIndex, "/*")) + { + return FindBlockCommentEndIndex(source, startIndex); + } + + if (StartsWith(source, startIndex, "$@\"") || StartsWith(source, startIndex, "@$\"")) + { + return FindVerbatimStringEndIndex(source, startIndex + 2); + } + + if (StartsWith(source, startIndex, "@\"")) + { + return FindVerbatimStringEndIndex(source, startIndex + 1); + } + + if (IsInterpolatedRawStringStart(source, startIndex)) + { + int dollarCount = CountRepeatedCharacter(source, startIndex, '$'); + return FindRawStringEndIndex(source, startIndex + dollarCount); + } + + if (StartsWith(source, startIndex, "$\"")) + { + return FindRegularStringEndIndex(source, startIndex + 1); + } + + if (StartsWith(source, startIndex, "\"\"\"")) + { + return FindRawStringEndIndex(source, startIndex); + } + + if (source[startIndex] == '"') + { + return FindRegularStringEndIndex(source, startIndex); + } + + if (source[startIndex] == '\'') + { + return FindCharLiteralEndIndex(source, startIndex); + } + + return startIndex; + } + + private static int FindLineCommentEndIndex(string source, int startIndex) + { + int index = startIndex; + while (index < source.Length && source[index] != '\n') + { + index++; + } + + return index; + } + + private static int FindBlockCommentEndIndex(string source, int startIndex) + { + int index = startIndex + 2; + while (index + 1 < source.Length) + { + if (source[index] == '*' && source[index + 1] == '/') + { + return index + 2; + } + + index++; + } + + return source.Length; + } + + private static int FindRegularStringEndIndex(string source, int quoteIndex) + { + int index = quoteIndex + 1; + while (index < source.Length) + { + if (source[index] == '\\') + { + index += 2; + continue; + } + + if (source[index] == '"') + { + return index + 1; + } + + index++; + } + + return source.Length; + } + + private static int FindVerbatimStringEndIndex(string source, int quoteIndex) + { + int index = quoteIndex + 1; + while (index < source.Length) + { + if (source[index] != '"') + { + index++; + continue; + } + + if (index + 1 < source.Length && source[index + 1] == '"') + { + index += 2; + continue; + } + + return index + 1; + } + + return source.Length; + } + + private static int FindRawStringEndIndex(string source, int quoteIndex) + { + int quoteCount = CountRepeatedCharacter(source, quoteIndex, '"'); + Debug.Assert( + quoteCount >= MinimumRawStringDelimiterQuoteCount, + "quoteIndex must point to a raw string delimiter"); + + int index = quoteIndex + quoteCount; + while (index + quoteCount <= source.Length) + { + if (HasRepeatedCharacterAt(source, index, '"', quoteCount)) + { + return index + quoteCount; + } + + index++; + } + + return source.Length; + } + + private static int MarkInterpolatedRawStringAsIgnored( + bool[] codeCharacters, + string source, + int dollarIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(dollarIndex >= 0, "dollarIndex must not be negative"); + + int dollarCount = CountRepeatedCharacter(source, dollarIndex, '$'); + int quoteIndex = dollarIndex + dollarCount; + int quoteCount = CountRepeatedCharacter(source, quoteIndex, '"'); + Debug.Assert(dollarCount > 0, "dollarIndex must point to an interpolated raw string prefix"); + Debug.Assert( + quoteCount >= MinimumRawStringDelimiterQuoteCount, + "quoteIndex must point to a raw string delimiter"); + + int literalStartIndex = dollarIndex; + int index = quoteIndex + quoteCount; + while (index < source.Length) + { + if (HasRepeatedCharacterAt(source, index, '"', quoteCount)) + { + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index + quoteCount); + return index + quoteCount; + } + + if (source[index] == '{') + { + int braceCount = CountRepeatedCharacter(source, index, '{'); + if (braceCount < dollarCount) + { + index += braceCount; + continue; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index); + index = MarkRawInterpolationHoleNestedTextAsIgnored( + codeCharacters, + source, + index, + dollarCount); + literalStartIndex = index; + continue; + } + + if (source[index] == '}') + { + int braceCount = CountRepeatedCharacter(source, index, '}'); + index += braceCount; + continue; + } + + index++; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, source.Length); + return source.Length; + } + + private static int MarkInterpolatedRegularStringAsIgnored( + bool[] codeCharacters, + string source, + int dollarIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(dollarIndex >= 0, "dollarIndex must not be negative"); + + int quoteIndex = dollarIndex + 1; + int literalStartIndex = dollarIndex; + int index = quoteIndex + 1; + while (index < source.Length) + { + if (source[index] == '\\') + { + index += 2; + continue; + } + + if (source[index] == '{') + { + if (index + 1 < source.Length && source[index + 1] == '{') + { + index += 2; + continue; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index); + index = MarkInterpolationHoleNestedTextAsIgnored(codeCharacters, source, index); + literalStartIndex = index; + continue; + } + + if (source[index] == '"') + { + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index + 1); + return index + 1; + } + + index++; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, source.Length); + return source.Length; + } + + private static int MarkInterpolatedVerbatimStringAsIgnored( + bool[] codeCharacters, + string source, + int prefixIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(prefixIndex >= 0, "prefixIndex must not be negative"); + + int quoteIndex = prefixIndex + 2; + int literalStartIndex = prefixIndex; + int index = quoteIndex + 1; + while (index < source.Length) + { + if (source[index] == '"') + { + if (index + 1 < source.Length && source[index + 1] == '"') + { + index += 2; + continue; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index + 1); + return index + 1; + } + + if (source[index] == '{') + { + if (index + 1 < source.Length && source[index + 1] == '{') + { + index += 2; + continue; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, index); + index = MarkInterpolationHoleNestedTextAsIgnored(codeCharacters, source, index); + literalStartIndex = index; + continue; + } + + if (source[index] == '}' && index + 1 < source.Length && source[index + 1] == '}') + { + index += 2; + continue; + } + + index++; + } + + MarkRangeAsIgnored(codeCharacters, literalStartIndex, source.Length); + return source.Length; + } + + private static int MarkInterpolationHoleNestedTextAsIgnored( + bool[] codeCharacters, + string source, + int openBraceIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(openBraceIndex >= 0, "openBraceIndex must not be negative"); + + int nestedBraceDepth = 0; + int index = openBraceIndex + 1; + while (index < source.Length) + { + int ignoredTextEndIndex = MarkIgnoredTextInInterpolationHole( + codeCharacters, + source, + index); + if (ignoredTextEndIndex != index) + { + index = ignoredTextEndIndex; + continue; + } + + if (source[index] == '{') + { + nestedBraceDepth++; + index++; + continue; + } + + if (source[index] == '}') + { + if (nestedBraceDepth == 0) + { + return index + 1; + } + + nestedBraceDepth--; + } + + index++; + } + + return source.Length; + } + + private static int MarkRawInterpolationHoleNestedTextAsIgnored( + bool[] codeCharacters, + string source, + int openBraceIndex, + int interpolationBraceCount) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(openBraceIndex >= 0, "openBraceIndex must not be negative"); + Debug.Assert(interpolationBraceCount > 0, "interpolationBraceCount must be positive"); + + int nestedBraceDepth = 0; + int index = openBraceIndex + interpolationBraceCount; + while (index < source.Length) + { + int ignoredTextEndIndex = MarkIgnoredTextInInterpolationHole( + codeCharacters, + source, + index); + if (ignoredTextEndIndex != index) + { + index = ignoredTextEndIndex; + continue; + } + + if (source[index] == '{') + { + nestedBraceDepth++; + index++; + continue; + } + + if (source[index] == '}') + { + bool isClosingInterpolation = + nestedBraceDepth == 0 && + HasRepeatedCharacterAt(source, index, '}', interpolationBraceCount); + if (isClosingInterpolation) + { + return index + interpolationBraceCount; + } + + if (nestedBraceDepth > 0) + { + nestedBraceDepth--; + } + } + + index++; + } + + return source.Length; + } + + private static int MarkIgnoredTextInInterpolationHole( + bool[] codeCharacters, + string source, + int startIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + if (IsInterpolatedRawStringStart(source, startIndex)) + { + return MarkInterpolatedRawStringAsIgnored(codeCharacters, source, startIndex); + } + + if (StartsWith(source, startIndex, "$@\"") || StartsWith(source, startIndex, "@$\"")) + { + return MarkInterpolatedVerbatimStringAsIgnored(codeCharacters, source, startIndex); + } + + if (StartsWith(source, startIndex, "$\"") && !StartsWith(source, startIndex, "$\"\"\"")) + { + return MarkInterpolatedRegularStringAsIgnored(codeCharacters, source, startIndex); + } + + int ignoredTextEndIndex = GetIgnoredTextEndIndex(source, startIndex); + if (ignoredTextEndIndex == startIndex) + { + return startIndex; + } + + MarkRangeAsIgnored(codeCharacters, startIndex, ignoredTextEndIndex); + return ignoredTextEndIndex; + } + + private static int FindCharLiteralEndIndex(string source, int quoteIndex) + { + int index = quoteIndex + 1; + while (index < source.Length) + { + if (source[index] == '\\') + { + index += 2; + continue; + } + + if (source[index] == '\'') + { + return index + 1; + } + + index++; + } + + return source.Length; + } + + private static void MarkRangeAsIgnored(bool[] codeCharacters, int startIndex, int endIndex) + { + Debug.Assert(codeCharacters != null, "codeCharacters must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + Debug.Assert(endIndex >= startIndex, "endIndex must not be less than startIndex"); + + for (int i = startIndex; i < endIndex && i < codeCharacters.Length; i++) + { + codeCharacters[i] = false; + } + } + + private static bool IsInterpolatedRawStringStart(string source, int startIndex) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + if (startIndex >= source.Length || source[startIndex] != '$') + { + return false; + } + + int dollarCount = CountRepeatedCharacter(source, startIndex, '$'); + int quoteIndex = startIndex + dollarCount; + return CountRepeatedCharacter(source, quoteIndex, '"') >= MinimumRawStringDelimiterQuoteCount; + } + + private static int CountRepeatedCharacter(string source, int startIndex, char character) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + + int index = startIndex; + while (index < source.Length && source[index] == character) + { + index++; + } + + return index - startIndex; + } + + private static bool HasRepeatedCharacterAt(string source, int startIndex, char character, int count) + { + Debug.Assert(source != null, "source must not be null"); + Debug.Assert(startIndex >= 0, "startIndex must not be negative"); + Debug.Assert(count > 0, "count must be positive"); + + if (startIndex + count > source.Length) + { + return false; + } + + for (int i = 0; i < count; i++) + { + if (source[startIndex + i] != character) + { + return false; + } + } + + return true; + } + } + } + + internal readonly struct ThirdPartyToolMigrationContentResult + { + public ThirdPartyToolMigrationContentResult(string content, int replacementCount) + { + Debug.Assert(content != null, "content must not be null"); + Debug.Assert(replacementCount >= 0, "replacementCount must not be negative"); + + Content = content ?? string.Empty; + ReplacementCount = replacementCount; + } + + public string Content { get; } + public int ReplacementCount { get; } + public bool Changed => ReplacementCount > 0; + } +} diff --git a/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta new file mode 100644 index 000000000..afdf58950 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/ThirdPartyToolMigration/ThirdPartyToolMigrationRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 18d9dfae996e44930809dad94d0255b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Presentation/PresentationEditorStartup.cs b/Packages/src/Editor/Presentation/PresentationEditorStartup.cs index 335286e41..9f9b0d2c1 100644 --- a/Packages/src/Editor/Presentation/PresentationEditorStartup.cs +++ b/Packages/src/Editor/Presentation/PresentationEditorStartup.cs @@ -11,6 +11,7 @@ internal static void Initialize(UnityCliLoopEditorSettingsService editorSettings { UnityCliLoopSettingsWindow.InitializeEditorServices(editorSettingsService); SetupWizardWindow.InitializeForEditorStartup(editorSettingsService); + ThirdPartyToolMigrationWizardWindow.InitializeForEditorStartup(); } } } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs index c9a07daf9..a240fdcfd 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.cs @@ -58,9 +58,10 @@ internal static bool ShouldAutoShowForVersion( string lastSeenVersion, bool suppressAutoShow) { - if (suppressAutoShow) return false; + bool versionChanged = !string.Equals(currentVersion, lastSeenVersion, System.StringComparison.Ordinal); + if (!versionChanged) return false; - return !string.Equals(currentVersion, lastSeenVersion, System.StringComparison.Ordinal); + return !suppressAutoShow; } internal static void MaybeRecordLastSeenVersion(bool shouldRecordVersion, string version) @@ -80,13 +81,29 @@ internal static void MaybeRecordSuppressedVersion(bool suppressAutoShow, string } private static void TryShowOnVersionChange() + { + EvaluateVersionChange(CancellationToken.None); + } + + private static void EvaluateVersionChange(CancellationToken ct) { string currentVersion = UnityCliLoopConstants.PackageInfo.version; UnityCliLoopEditorSettingsService editorSettingsService = GetEditorSettingsService(); bool suppressAutoShow = editorSettingsService.GetSuppressSetupWizardAutoShow(); - MaybeRecordSuppressedVersion(suppressAutoShow, currentVersion); string lastSeenVersion = editorSettingsService.GetLastSeenSetupWizardVersion(); - if (!ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow)) return; + if (ct.IsCancellationRequested) + { + return; + } + + if (!ShouldAutoShowForVersion( + currentVersion, + lastSeenVersion, + suppressAutoShow)) + { + MaybeRecordSuppressedVersion(suppressAutoShow, currentVersion); + return; + } EditorApplication.delayCall += ShowWindowOnVersionChange; } @@ -488,6 +505,7 @@ private async void RefreshUI(bool refreshSkillsSection = true) bool canManageSkills = CanManageSkills(cliInstalled); UpdateSkillsStep(canManageSkills, targets); BeginRefreshDisplayedSkillTargets(canManageSkills); + ScheduleResizeToContent(); } diff --git a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss index 11cfc7def..ee701aee0 100644 --- a/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss +++ b/Packages/src/Editor/Presentation/Setup/SetupWizardWindow.uss @@ -85,6 +85,17 @@ padding-left: 4px; } +.setup-step--migration-alert { + background-color: #5c4a00; + border-color: #7a6400; + border-width: 2px; +} + +.setup-step--migration-alert .setup-step__title, +.setup-step--migration-alert .setup-step__status-label { + color: var(--setup-color-warning); +} + .setup-step__status-row { flex-direction: row; align-items: center; @@ -95,6 +106,17 @@ margin-left: 6px; } +.setup-step__status-label--standalone { + margin-left: 0; + white-space: normal; +} + +.setup-progress-bar { + height: 14px; + margin-top: 6px; + margin-bottom: 2px; +} + /* =========================================== Block: Status Icon =========================================== */ diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs new file mode 100644 index 000000000..5d4635fe6 --- /dev/null +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs @@ -0,0 +1,517 @@ +using System.Threading; +using System.Threading.Tasks; + +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Presentation +{ + /// + /// Provides the dedicated Editor window for V3 third-party custom tool migration. + /// + public class ThirdPartyToolMigrationWizardWindow : EditorWindow + { + private const string WindowTitle = "Unity CLI Loop Migration"; + private const string USS_RELATIVE_PATH = "Editor/Presentation/Setup/SetupWizardWindow.uss"; + private const string MigrationCheckingText = "Scanning project for V3 custom tool migration..."; + private const string NoMigrationTargetsText = "No V3 custom tool migration is needed."; + private const string MigrationButtonReadyText = "Migrate"; + private const string MigrationButtonMigratingText = "Migrating..."; + private const string MigrationButtonNoTargetsText = "Nothing to migrate"; + private static readonly Vector2 InitialWindowSize = new(360f, 220f); + private static readonly Vector2 MinimumWindowSize = new(360f, 120f); + + private ScrollView _mainScrollView; + private VisualElement _migrationSection; + private Label _migrationStatusLabel; + private ProgressBar _migrationProgressBar; + private Button _migrateButton; + private Button _refreshButton; + private Button _closeButton; + + private bool _isMigrating; + private bool _isApplyingContentSize; + [SerializeField] + private bool _shouldRefreshAfterCreateGui; + private IVisualElementScheduledItem _resizeScheduledItem; + private CancellationTokenSource _migrationPreviewCts; + private ThirdPartyToolMigrationUseCase _thirdPartyToolMigrationUseCase; + + internal static void InitializeForEditorStartup() + { + if (AssetDatabase.IsAssetImportWorkerProcess()) return; + if (UnityEngine.Application.isBatchMode) return; + + EditorApplication.delayCall += TryShowOnMigrationTargets; + } + + [MenuItem("Window/Unity CLI Loop/Custom Tool Migration", priority = 4)] + public static void ShowWindow() + { + ShowWindowInternal(true); + } + + internal static bool ShouldAutoShowForMigrationTargets(bool hasMigrationTargets) + { + return hasMigrationTargets; + } + + internal static void PrepareForOpen( + ThirdPartyToolMigrationWizardWindow window, + string title, + Rect position, + bool shouldRefreshAfterCreateGui) + { + Debug.Assert(window != null, "window must not be null"); + Debug.Assert(!string.IsNullOrEmpty(title), "title must not be null or empty"); + + window.titleContent = new GUIContent(title); + window.position = position; + window.minSize = MinimumWindowSize; + window._shouldRefreshAfterCreateGui = shouldRefreshAfterCreateGui; + } + + internal static Rect CreateCenteredRect(Rect bounds, Vector2 size) + { + Vector2 centeredPosition = bounds.center - (size * 0.5f); + return new Rect(centeredPosition, size); + } + + internal static Rect WithContentHeight(Rect currentRect, float contentHeight, Vector2 frameSize) + { + Debug.Assert(contentHeight >= 0f, "contentHeight must not be negative"); + + float measuredHeight = contentHeight + frameSize.y; + Vector2 targetSize = new( + MinimumWindowSize.x, + Mathf.Max(measuredHeight, MinimumWindowSize.y)); + return CreateCenteredRect(currentRect, targetSize); + } + + internal static string GetMigrationStatusText(int fileCount) + { + Debug.Assert(fileCount >= 0, "fileCount must not be negative"); + + string noun = fileCount == 1 ? "file" : "files"; + string verb = fileCount == 1 ? "needs" : "need"; + string subject = fileCount == 1 ? "this file still uses" : "these files still use"; + string objectPronoun = fileCount == 1 ? "it" : "them"; + + return $"{fileCount} {noun} {verb} V3 custom tool migration.\n" + + $"The Unity Console is showing errors because {subject} the old custom tool API.\n\n" + + $"Click Migrate to update {objectPronoun} automatically. " + + "The errors should disappear after migration."; + } + + internal static string GetMigrationProgressText(ThirdPartyToolMigrationProgress progress) + { + if (progress.TotalItemCount <= 0) + { + return MigrationCheckingText; + } + + return $"{MigrationCheckingText}\n" + + $"{progress.ProcessedItemCount}/{progress.TotalItemCount} checks complete."; + } + + internal static string GetMigrationButtonText(bool isMigrating, bool hasMigrationTargets) + { + if (isMigrating) + { + return MigrationButtonMigratingText; + } + + return hasMigrationTargets ? MigrationButtonReadyText : MigrationButtonNoTargetsText; + } + + private static void TryShowOnMigrationTargets() + { + TryShowOnMigrationTargetsAsync(CancellationToken.None); + } + + private static async void TryShowOnMigrationTargetsAsync(CancellationToken ct) + { + bool hasMigrationTargets = await HasMigrationTargetsAsync(ct); + if (ct.IsCancellationRequested) + { + return; + } + + if (!ShouldAutoShowForMigrationTargets(hasMigrationTargets)) + { + return; + } + + EditorApplication.delayCall += ShowWindow; + } + + private static async Task HasMigrationTargetsAsync(CancellationToken ct) + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + return await ThirdPartyToolMigrationUseCaseRegistry + .GetRegisteredUseCase() + .HasMigrationTargetsAsync(projectRoot, ct); + } + + private static void ShowWindowInternal(bool shouldRefreshAfterCreateGui) + { + if (HasOpenInstances()) + { + FocusWindowIfItsOpen(); + return; + } + + Rect windowPosition = CreateCenteredRect( + EditorGUIUtility.GetMainWindowPosition(), + InitialWindowSize); + ThirdPartyToolMigrationWizardWindow window = + CreateInstance(); + PrepareForOpen(window, WindowTitle, windowPosition, shouldRefreshAfterCreateGui); + window.ShowUtility(); + window.ScheduleResizeToContent(); + } + + private void CreateGUI() + { + InitializeApplicationServices(); + BuildLayout(); + BindEvents(); + BindSizeUpdates(); + ShowInitialState(); + ScheduleInitialRefresh(); + ScheduleResizeToContent(); + } + + private void InitializeApplicationServices() + { + _thirdPartyToolMigrationUseCase = ThirdPartyToolMigrationUseCaseRegistry.GetRegisteredUseCase(); + } + + private void OnDisable() + { + _resizeScheduledItem?.Pause(); + CancelMigrationPreview(); + } + + private void BuildLayout() + { + string ussPath = $"{UnityCliLoopConstants.PackageAssetPath}/{USS_RELATIVE_PATH}"; + StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath(ussPath); + Debug.Assert(styleSheet != null, $"USS not found at {ussPath}"); + rootVisualElement.styleSheets.Add(styleSheet); + + _mainScrollView = new ScrollView(); + _mainScrollView.AddToClassList("setup-main-container"); + rootVisualElement.Add(_mainScrollView); + + _migrationSection = new VisualElement(); + _migrationSection.AddToClassList("setup-step"); + _migrationSection.AddToClassList("setup-step--migration-alert"); + _mainScrollView.Add(_migrationSection); + + Label titleLabel = new("Custom Tool Migration"); + titleLabel.AddToClassList("setup-step__title"); + _migrationSection.Add(titleLabel); + + VisualElement content = new(); + content.AddToClassList("setup-step__content"); + _migrationSection.Add(content); + + _migrationStatusLabel = new Label(); + _migrationStatusLabel.AddToClassList("setup-step__status-label"); + _migrationStatusLabel.AddToClassList("setup-step__status-label--standalone"); + content.Add(_migrationStatusLabel); + + _migrationProgressBar = new ProgressBar(); + _migrationProgressBar.AddToClassList("setup-progress-bar"); + content.Add(_migrationProgressBar); + + VisualElement migrationButtonRow = new(); + migrationButtonRow.AddToClassList("setup-step__button-row"); + content.Add(migrationButtonRow); + + _migrateButton = new Button(); + _migrateButton.text = GetMigrationButtonText(false, true); + _migrateButton.AddToClassList("setup-button"); + migrationButtonRow.Add(_migrateButton); + + VisualElement footer = new(); + footer.AddToClassList("setup-footer"); + _mainScrollView.Add(footer); + + VisualElement footerButtonRow = new(); + footerButtonRow.AddToClassList("setup-footer__button-row"); + footer.Add(footerButtonRow); + + _refreshButton = new Button(); + _refreshButton.text = "Refresh"; + _refreshButton.AddToClassList("setup-button"); + _refreshButton.AddToClassList("setup-button--primary"); + footerButtonRow.Add(_refreshButton); + + _closeButton = new Button(); + _closeButton.text = "Close"; + _closeButton.AddToClassList("setup-button"); + footerButtonRow.Add(_closeButton); + + Label reopenHintLabel = new( + "You can close this wizard and reopen it later from\n" + + "Window > Unity CLI Loop > Custom Tool Migration."); + reopenHintLabel.AddToClassList("setup-footer__hint-label"); + footer.Add(reopenHintLabel); + } + + private void BindEvents() + { + _refreshButton.clicked += RefreshUI; + _migrateButton.clicked += HandleMigrateThirdPartyTools; + _closeButton.clicked += Close; + } + + private void BindSizeUpdates() + { + rootVisualElement.RegisterCallback(_ => + { + if (_isApplyingContentSize) + { + return; + } + + ScheduleResizeToContent(); + }); + } + + private void ShowInitialState() + { + if (_shouldRefreshAfterCreateGui) + { + ShowCheckingState(new ThirdPartyToolMigrationProgress(0, 0)); + return; + } + + ShowNoMigrationTargetsState(); + } + + private void ScheduleInitialRefresh() + { + if (!_shouldRefreshAfterCreateGui) + { + return; + } + + rootVisualElement.schedule.Execute(RefreshUI).StartingIn(0); + } + + private async void RefreshUI() + { + CancellationToken ct = BeginMigrationPreview(); + ShowCheckingState(new ThirdPartyToolMigrationProgress(0, 0)); + await Task.Yield(); + + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + System.IProgress progress = + new ThirdPartyToolMigrationPreviewProgress(this, ct); + ThirdPartyToolMigrationPreview preview = + await _thirdPartyToolMigrationUseCase.PreviewMigrationAsync(projectRoot, progress, ct); + if (ct.IsCancellationRequested) + { + return; + } + + if (!preview.HasTargets) + { + ShowNoMigrationTargetsState(); + return; + } + + ShowMigrationTargetsState(preview.FileCount); + } + + private void HandleMigrateThirdPartyTools() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + _isMigrating = true; + ShowCheckingState(new ThirdPartyToolMigrationProgress(0, 0)); + + try + { + ThirdPartyToolMigrationResult result = + _thirdPartyToolMigrationUseCase.ApplyMigration(projectRoot); + if (result.Changed) + { + AssetDatabase.Refresh(); + } + } + finally + { + _isMigrating = false; + RefreshUI(); + } + } + + private void ShowMigrationTargetsState(int fileCount) + { + _migrationStatusLabel.text = GetMigrationStatusText(fileCount); + ViewDataBinder.SetVisible(_migrationProgressBar, false); + _migrateButton.SetEnabled(!_isMigrating); + _migrateButton.text = GetMigrationButtonText(_isMigrating, true); + ScheduleResizeToContent(); + } + + private void ShowNoMigrationTargetsState() + { + _migrationStatusLabel.text = NoMigrationTargetsText; + ViewDataBinder.SetVisible(_migrationProgressBar, false); + _migrateButton.SetEnabled(false); + _migrateButton.text = GetMigrationButtonText(_isMigrating, false); + ScheduleResizeToContent(); + } + + private void ShowCheckingState(ThirdPartyToolMigrationProgress progress) + { + _migrationStatusLabel.text = GetMigrationProgressText(progress); + ViewDataBinder.SetVisible(_migrationProgressBar, true); + UpdateMigrationProgressBar(progress); + _migrateButton.SetEnabled(false); + _migrateButton.text = GetMigrationButtonText(_isMigrating, true); + ScheduleResizeToContent(); + } + + private void ScheduleResizeToContent() + { + _resizeScheduledItem?.Pause(); + _resizeScheduledItem = rootVisualElement.schedule.Execute(ResizeToContent).StartingIn(0); + } + + private void ResizeToContent() + { + if (_mainScrollView == null) return; + if (rootVisualElement.layout.width <= 0f || rootVisualElement.layout.height <= 0f) return; + + float contentHeight = MeasurePreferredContentHeight(_mainScrollView, _mainScrollView.contentContainer); + if (!IsFinite(contentHeight)) return; + if (contentHeight <= 0f) return; + + Vector2 frameSize = position.size - rootVisualElement.layout.size; + if (!HasFiniteSize(frameSize)) return; + Rect targetRect = WithContentHeight(position, contentHeight, frameSize); + if (!HasFiniteSize(targetRect.size)) return; + if (Approximately(position.size, targetRect.size)) + { + minSize = targetRect.size; + maxSize = targetRect.size; + return; + } + + _isApplyingContentSize = true; + minSize = targetRect.size; + maxSize = targetRect.size; + position = targetRect; + _isApplyingContentSize = false; + } + + private static float MeasurePreferredContentHeight(VisualElement mainContainer, VisualElement contentContainer) + { + float maxBottom = 0f; + foreach (VisualElement child in contentContainer.Children()) + { + if (!child.visible) continue; + if (!HasFiniteRect(child.worldBound)) continue; + float bottom = child.worldBound.yMax - contentContainer.worldBound.yMin; + if (!IsFinite(bottom)) continue; + maxBottom = Mathf.Max(maxBottom, bottom); + } + + float height = + mainContainer.resolvedStyle.paddingTop + + maxBottom + + mainContainer.resolvedStyle.paddingBottom; + return IsFinite(height) ? Mathf.Ceil(height) : 0f; + } + + private static bool Approximately(Vector2 left, Vector2 right) + { + const float Tolerance = 0.5f; + return Mathf.Abs(left.x - right.x) < Tolerance && Mathf.Abs(left.y - right.y) < Tolerance; + } + + internal static bool HasFiniteSize(Vector2 size) + { + return IsFinite(size.x) && IsFinite(size.y); + } + + private static bool HasFiniteRect(Rect rect) + { + return IsFinite(rect.xMin) + && IsFinite(rect.xMax) + && IsFinite(rect.yMin) + && IsFinite(rect.yMax); + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + + private void UpdateMigrationProgressBar(ThirdPartyToolMigrationProgress progress) + { + int totalItemCount = Mathf.Max(progress.TotalItemCount, 1); + int processedItemCount = Mathf.Clamp(progress.ProcessedItemCount, 0, totalItemCount); + _migrationProgressBar.lowValue = 0; + _migrationProgressBar.highValue = totalItemCount; + _migrationProgressBar.value = processedItemCount; + } + + private CancellationToken BeginMigrationPreview() + { + CancelMigrationPreview(); + CancellationTokenSource cts = new(); + _migrationPreviewCts = cts; + return cts.Token; + } + + private void CancelMigrationPreview() + { + if (_migrationPreviewCts == null) + { + return; + } + + _migrationPreviewCts.Cancel(); + _migrationPreviewCts.Dispose(); + _migrationPreviewCts = null; + } + + private sealed class ThirdPartyToolMigrationPreviewProgress + : System.IProgress + { + private readonly ThirdPartyToolMigrationWizardWindow _window; + private readonly CancellationToken _ct; + + public ThirdPartyToolMigrationPreviewProgress( + ThirdPartyToolMigrationWizardWindow window, + CancellationToken ct) + { + Debug.Assert(window != null, "window must not be null"); + + _window = window; + _ct = ct; + } + + public void Report(ThirdPartyToolMigrationProgress value) + { + if (_ct.IsCancellationRequested) + { + return; + } + + _window.ShowCheckingState(value); + } + } + } +} diff --git a/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs.meta b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs.meta new file mode 100644 index 000000000..2b98e46fb --- /dev/null +++ b/Packages/src/Editor/Presentation/Setup/ThirdPartyToolMigrationWizardWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b5845b12ecd4f42239602418c7aa8461 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: