diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/SpecGenSdkConfigHelperTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/SpecGenSdkConfigHelperTests.cs new file mode 100644 index 00000000000..b94d1093ec2 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Helpers/SpecGenSdkConfigHelperTests.cs @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Moq; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Tests.TestHelpers; +using System.Text.Json; +using System.Reflection; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.Cli.Tests.Helpers; + +[TestFixture] +public class SpecGenSdkConfigHelperTests +{ + #region Test Constants + + private const string TestRepoName = "azure-sdk-for-net"; + private const string TestConfigPath = "eng/swagger_to_sdk_config.json"; + private const string BuildCommandJsonPath = "packageOptions/buildScript/command"; + private const string BuildScriptPathJsonPath = "packageOptions/buildScript/path"; + + #endregion + + private SpecGenSdkConfigHelper _helper; + private TestLogger _logger; + private string _tempDirectory; + private string _configFilePath; + + [SetUp] + public void Setup() + { + _logger = new TestLogger(); + _tempDirectory = Path.Combine(Path.GetTempPath(), "SpecGenSdkConfigHelperTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + + // Create the swagger_to_sdk_config.json file at the expected location + var engDir = Path.Combine(_tempDirectory, "eng"); + Directory.CreateDirectory(engDir); + _configFilePath = Path.Combine(engDir, "swagger_to_sdk_config.json"); + _helper = new SpecGenSdkConfigHelper(_logger); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + #region Build Configuration Tests + + [Test] + public async Task GetBuildConfigurationAsync_CommandExists_ReturnsCommand() + { + // Arrange + var configContent = new + { + packageOptions = new + { + buildScript = new + { + command = "dotnet build {packagePath}" + } + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act + var result = await _helper.GetBuildConfigurationAsync(_tempDirectory); + + // Assert + Assert.That(result.type, Is.EqualTo(BuildConfigType.Command)); + Assert.That(result.value, Is.EqualTo("dotnet build {packagePath}")); + } + + [Test] + public async Task GetBuildConfigurationAsync_OnlyPathExists_ReturnsScriptPath() + { + // Arrange + var configContent = new + { + packageOptions = new + { + buildScript = new + { + path = "eng/scripts/build.sh" + } + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act + var result = await _helper.GetBuildConfigurationAsync(_tempDirectory); + + // Assert + Assert.That(result.type, Is.EqualTo(BuildConfigType.ScriptPath)); + Assert.That(result.value, Is.EqualTo("eng/scripts/build.sh")); + } + + [Test] + public async Task GetBuildConfigurationAsync_BothExist_PrefersCommand() + { + // Arrange + var configContent = new + { + packageOptions = new + { + buildScript = new + { + command = "dotnet build {packagePath}", + path = "eng/scripts/build.sh" + } + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act + var result = await _helper.GetBuildConfigurationAsync(_tempDirectory); + + // Assert + Assert.That(result.type, Is.EqualTo(BuildConfigType.Command)); + Assert.That(result.value, Is.EqualTo("dotnet build {packagePath}")); + } + + [Test] + public void GetBuildConfigurationAsync_NeitherExists_ThrowsException() + { + // Arrange + var configContent = new + { + packageOptions = new + { + other = "value" + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => _helper.GetBuildConfigurationAsync(_tempDirectory)); + Assert.That(ex.Message, Does.Contain("Neither 'packageOptions/buildScript/command' nor 'packageOptions/buildScript/path' found")); + } + + [Test] + public void GetBuildConfigurationAsync_ConfigFileNotFound_ThrowsException() + { + // Act & Assert + var ex = Assert.ThrowsAsync(() => _helper.GetBuildConfigurationAsync(_tempDirectory)); + Assert.That(ex.Message, Does.Contain("Configuration file not found")); + } + + #endregion + + #region Generic Config Value Tests + + [Test] + public async Task GetConfigValueFromRepoAsync_ValidPath_ReturnsValue() + { + // Arrange + var configContent = new + { + packageOptions = new + { + buildScript = new + { + command = "dotnet build {packagePath}" + } + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act + var result = await _helper.GetConfigValueFromRepoAsync(_tempDirectory, BuildCommandJsonPath); + + // Assert + Assert.That(result, Is.EqualTo("dotnet build {packagePath}")); + } + + [Test] + public async Task GetConfigValueFromRepoAsync_ComplexObject_ReturnsObject() + { + // Arrange + var buildOptions = new { configuration = "Release", verbosity = "minimal" }; + var configContent = new + { + packageOptions = new + { + buildOptions = buildOptions + } + }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act + var result = await _helper.GetConfigValueFromRepoAsync>(_tempDirectory, "packageOptions/buildOptions"); + + // Assert + Assert.That(result["configuration"], Is.EqualTo("Release")); + Assert.That(result["verbosity"], Is.EqualTo("minimal")); + } + + [Test] + public void GetConfigValueFromRepoAsync_InvalidPath_ThrowsException() + { + // Arrange + var configContent = new { other = "value" }; + File.WriteAllText(_configFilePath, JsonSerializer.Serialize(configContent, new JsonSerializerOptions { WriteIndented = true })); + + // Act & Assert + var ex = Assert.ThrowsAsync(() => _helper.GetConfigValueFromRepoAsync(_tempDirectory, "nonexistent/path")); + Assert.That(ex.Message, Does.Contain("Property not found at JSON path")); + } + + #endregion + + #region Variable Substitution Tests + + [Test] + public void SubstituteCommandVariables_SingleVariable_SubstitutesCorrectly() + { + // Arrange + var command = "dotnet build {packagePath}"; + var variables = new Dictionary + { + { "packagePath", "/path/to/package" } + }; + + // Act + var result = _helper.SubstituteCommandVariables(command, variables); + + // Assert + Assert.That(result, Is.EqualTo("dotnet build /path/to/package")); + } + + [Test] + public void SubstituteCommandVariables_MultipleVariables_SubstitutesAll() + { + // Arrange + var command = "dotnet build {projectPath} --configuration {config} --output {outputDir}"; + var variables = new Dictionary + { + { "projectPath", "/path/to/project" }, + { "config", "Release" }, + { "outputDir", "/path/to/output" } + }; + + // Act + var result = _helper.SubstituteCommandVariables(command, variables); + + // Assert + Assert.That(result, Is.EqualTo("dotnet build /path/to/project --configuration Release --output /path/to/output")); + } + + [Test] + public void SubstituteCommandVariables_CaseInsensitive_SubstitutesCorrectly() + { + // Arrange + var command = "dotnet build {PackagePath}"; + var variables = new Dictionary + { + { "packagepath", "/path/to/package" } + }; + + // Act + var result = _helper.SubstituteCommandVariables(command, variables); + + // Assert + Assert.That(result, Is.EqualTo("dotnet build /path/to/package")); + } + + [Test] + public void SubstituteCommandVariables_NoVariables_ReturnsOriginal() + { + // Arrange + var command = "dotnet build"; + var variables = new Dictionary(); + + // Act + var result = _helper.SubstituteCommandVariables(command, variables); + + // Assert + Assert.That(result, Is.EqualTo("dotnet build")); + } + + [Test] + public void SubstituteCommandVariables_EmptyCommand_ReturnsEmpty() + { + // Arrange + var variables = new Dictionary { { "test", "value" } }; + + // Act + var result = _helper.SubstituteCommandVariables("", variables); + + // Assert + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void SubstituteCommandVariables_NullVariables_ReturnsOriginal() + { + // Arrange + var command = "dotnet build {packagePath}"; + + // Act + var result = _helper.SubstituteCommandVariables(command, new Dictionary()); + + // Assert + Assert.That(result, Is.EqualTo(command)); + } + + #endregion + + #region Command Parsing Tests + + [Test] + public void ParseCommand_SimpleCommand_ParsesCorrectly() + { + // Arrange + var command = "dotnet build --configuration Release"; + + // Act + var result = _helper.ParseCommand(command); + + // Assert + Assert.That(result, Is.EqualTo(new[] { "dotnet", "build", "--configuration", "Release" })); + } + + [Test] + public void ParseCommand_QuotedArguments_PreservesSpaces() + { + // Arrange + var command = "dotnet build \"C:\\Path With Spaces\\Project.csproj\" --output \"C:\\Output Dir\""; + + // Act + var result = _helper.ParseCommand(command); + + // Assert + Assert.That(result, Is.EqualTo(new[] { "dotnet", "build", "C:\\Path With Spaces\\Project.csproj", "--output", "C:\\Output Dir" })); + } + + [Test] + public void ParseCommand_MultipleSpaces_IgnoresExtraSpaces() + { + // Arrange + var command = "dotnet build --configuration Release"; + + // Act + var result = _helper.ParseCommand(command); + + // Assert + Assert.That(result, Is.EqualTo(new[] { "dotnet", "build", "--configuration", "Release" })); + } + + [Test] + public void ParseCommand_EmptyCommand_ReturnsEmpty() + { + // Act + var result = _helper.ParseCommand(""); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void ParseCommand_WhitespaceOnly_ReturnsEmpty() + { + // Act + var result = _helper.ParseCommand(" "); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public void ParseCommand_SingleWord_ReturnsSingleElement() + { + // Act + var result = _helper.ParseCommand("dotnet"); + + // Assert + Assert.That(result, Is.EqualTo(new[] { "dotnet" })); + } + + #endregion +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs new file mode 100644 index 00000000000..54816bda90e --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkBuildToolTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Moq; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Tests.TestHelpers; +using Azure.Sdk.Tools.Cli.Tools.Package; +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Azure.Sdk.Tools.Cli.Tests.Tools.Package; + +[TestFixture] +public class SdkBuildToolTests +{ + #region Test Constants + + private const string EngDirectoryName = "eng"; + + // Common test file contents + private const string InvalidJsonContent = "{ invalid json }"; + + // Common error message patterns + private const string InvalidProjectPathError = "Path does not exist"; + private const string FailedToDiscoverRepoError = "Failed to discover local sdk repo"; + private const string ConfigFileNotFoundError = "Configuration file not found"; + private const string JsonParsingError = "Error parsing JSON configuration"; + + #endregion + + private SdkBuildTool _tool; + private Mock _mockGitHelper; + private Mock _mockOutputHelper; + private Mock _mockProcessHelper; + private Mock _mockSpecGenSdkConfigHelper; + private TestLogger _logger; + private string _tempDirectory; + + [SetUp] + public void Setup() + { + // Create mocks + _mockGitHelper = new Mock(); + _mockOutputHelper = new Mock(); + _mockProcessHelper = new Mock(); + _mockSpecGenSdkConfigHelper = new Mock(); + _logger = new TestLogger(); + + // Create temp directory for tests + _tempDirectory = Path.Combine(Path.GetTempPath(), "SdkBuildToolTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + + // Create the tool instance + _tool = new SdkBuildTool( + _mockGitHelper.Object, + _logger, + _mockOutputHelper.Object, + _mockProcessHelper.Object, + _mockSpecGenSdkConfigHelper.Object + ); + } + + [TearDown] + public void TearDown() + { + // Clean up temp directory + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + #region BuildSdkAsync Tests + + [Test] + public async Task BuildSdkAsync_InvalidProjectPath_ReturnsFailure() + { + // Act + var result = await _tool.BuildSdkAsync("/nonexistent/path"); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(InvalidProjectPathError)); + } + + [Test] + public async Task BuildSdkAsync_PythonProject_SkipsBuild() + { + // Arrange + var pythonProjectPath = Path.Combine(_tempDirectory, "test-python-sdk"); + Directory.CreateDirectory(pythonProjectPath); + + // Mock GitHelper to return a Python SDK repo name + _mockGitHelper + .Setup(x => x.DiscoverRepoRoot(pythonProjectPath)) + .Returns(_tempDirectory); + _mockGitHelper + .Setup(x => x.GetRepoName(_tempDirectory)) + .Returns("azure-sdk-for-python"); + + // Act + var result = await _tool.BuildSdkAsync(pythonProjectPath); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + Assert.That(result.Message, Does.Contain("Python SDK project detected")); + Assert.That(result.Message, Does.Contain("Skipping build step")); + } + + [Test] + public async Task BuildSdkAsync_GitHelperFailsToDiscoverRepo_ReturnsFailure() + { + // Arrange + _mockGitHelper + .Setup(x => x.DiscoverRepoRoot(_tempDirectory)) + .Throws(new Exception(FailedToDiscoverRepoError)); + + // Act + var result = await _tool.BuildSdkAsync(_tempDirectory); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(FailedToDiscoverRepoError)); + } + + [Test] + public async Task BuildSdkAsync_ConfigFileNotFound_ReturnsError() + { + // Arrange + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoName(_tempDirectory)).Returns("azure-sdk-for-net"); + _mockGitHelper + .Setup(x => x.GetRepoRemoteUri(_tempDirectory)) + .Returns(new Uri("https://github.com/Azure/azure-sdk-for-net.git")); + + // Mock the SpecGenSdkConfigHelper to throw an exception for missing config + _mockSpecGenSdkConfigHelper + .Setup(x => x.GetBuildConfigurationAsync(_tempDirectory)) + .ThrowsAsync(new InvalidOperationException("Neither 'packageOptions/buildScript/command' nor 'packageOptions/buildScript/path' found in configuration.")); + + // Act + var result = await _tool.BuildSdkAsync(_tempDirectory); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("Failed to get build configuration")); + } + + [Test] + public async Task BuildSdkAsync_InvalidJsonConfig_ReturnsError() + { + // Arrange + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoName(_tempDirectory)).Returns("azure-sdk-for-net"); + _mockGitHelper + .Setup(x => x.GetRepoRemoteUri(_tempDirectory)) + .Returns(new Uri("https://github.com/Azure/azure-sdk-for-net.git")); + + // Mock the SpecGenSdkConfigHelper to throw a JSON parsing exception + _mockSpecGenSdkConfigHelper + .Setup(x => x.GetBuildConfigurationAsync(_tempDirectory)) + .ThrowsAsync(new InvalidOperationException("Error parsing JSON configuration: Invalid JSON")); + + // Act + var result = await _tool.BuildSdkAsync(_tempDirectory); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("Failed to get build configuration")); + } + + #endregion + + #region New Build Configuration Tests + + [Test] + public async Task BuildSdkAsync_ConfigurationFileNotFound_ReturnsError() + { + // Arrange + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoName(_tempDirectory)).Returns("azure-sdk-for-net"); + _mockGitHelper + .Setup(x => x.GetRepoRemoteUri(_tempDirectory)) + .Returns(new Uri("https://github.com/Azure/azure-sdk-for-net.git")); + + // Mock the SpecGenSdkConfigHelper to throw when config file is not found + _mockSpecGenSdkConfigHelper + .Setup(x => x.GetBuildConfigurationAsync(_tempDirectory)) + .ThrowsAsync(new FileNotFoundException("Configuration file not found")); + + // Act + var result = await _tool.BuildSdkAsync(_tempDirectory); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("Configuration file not found")); + _mockGitHelper.Verify(x => x.DiscoverRepoRoot(_tempDirectory), Times.Once); + _mockGitHelper.Verify(x => x.GetRepoName(_tempDirectory), Times.Once); + _mockSpecGenSdkConfigHelper.Verify(x => x.GetBuildConfigurationAsync(_tempDirectory), Times.Once); + } + + #endregion + + #region Command Tests + + [Test] + public void GetCommand_ReturnsCommandWithCorrectStructure() + { + // Act + var command = _tool.GetCommand(); + + // Assert + Assert.That(command.Name, Is.EqualTo("build")); + Assert.That(command.Description, Does.Contain("Builds SDK source code")); + } + + #endregion +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkGenerationToolTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkGenerationToolTests.cs new file mode 100644 index 00000000000..a378c7d1949 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Tools/Package/SdkGenerationToolTests.cs @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Moq; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Tests.TestHelpers; +using Azure.Sdk.Tools.Cli.Tools.Package; +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Azure.Sdk.Tools.Cli.Tests.Tools.Package; + +[TestFixture] +public class SdkGenerationToolTests +{ + #region Test Constants + + private const string DefaultSpecRepo = "Azure/azure-rest-api-specs"; + private const string RemoteTspConfigUrl = "https://github.com/Azure/azure-rest-api-specs/blob/dee71463cbde1d416c47cf544e34f7966a94ddcb/specification/contosowidgetmanager/Contoso.Management/tspconfig.yaml"; + private const string InvalidRemoteTspConfigUrl = "https://example.com/tspconfig.yaml"; + private const string TspConfigFileName = "tspconfig.yaml"; + private const string TspLocationFileName = "tsp-location.yaml"; + + // Common test file contents + private const string TestTspConfigContent = "# test tspconfig.yaml"; + private const string TestTspLocationContent = "# test tsp-location.yaml"; + + // Common error message patterns + private const string BothPathsEmptyError = "Both 'tspconfig.yaml' and 'tsp-location.yaml' paths aren't provided"; + private const string FileNotExistError = "does not exist"; + private const string DirectoryNotExistError = "does not provide or exist"; + private const string TspClientInitFailedError = "tsp-client init failed"; + + // Common success messages + private const string SdkGenerationSuccessMessage = "SDK generation completed successfully"; + private const string SdkRegenerationSuccessMessage = "SDK re-generation completed successfully"; + private const string ProcessSuccessOutput = "Success"; + private const string ProcessErrorOutput = "Error occurred"; + + #endregion + + private SdkGenerationTool _tool; + private Mock _mockGitHelper; + private Mock _mockNpxHelper; + private Mock _mockOutputHelper; + private Mock _mockProcessHelper; + private TestLogger _logger; + private string _tempDirectory; + + [SetUp] + public void Setup() + { + // Create mocks + _mockGitHelper = new Mock(); + _mockNpxHelper = new Mock(); + _mockOutputHelper = new Mock(); + _mockProcessHelper = new Mock(); + _logger = new TestLogger(); + + // Create temp directory for tests + _tempDirectory = Path.Combine(Path.GetTempPath(), "SdkGenerationToolTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + + // Create the tool instance + _tool = new SdkGenerationTool( + _mockGitHelper.Object, + _logger, + _mockNpxHelper.Object, + _mockOutputHelper.Object, + _mockProcessHelper.Object + ); + } + + [TearDown] + public void TearDown() + { + // Clean up temp directory + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + #region GenerateSdkAsync Tests + + [Test] + public async Task GenerateSdkAsync_BothPathsEmpty_ReturnsFailure() + { + // Act + var result = await _tool.GenerateSdkAsync("/some/path", null, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(BothPathsEmptyError)); + } + + [Test] + public async Task GenerateSdkAsync_WithTspLocationPath_CallsRunTspUpdate() + { + // Arrange + var tspLocationPath = Path.Combine(_tempDirectory, TspLocationFileName); + File.WriteAllText(tspLocationPath, TestTspLocationContent); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync("/some/path", null, tspLocationPath, null); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + Assert.That(result.Message, Does.Contain(SdkRegenerationSuccessMessage)); + _mockNpxHelper.Verify(x => x.Run(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GenerateSdkAsync_WithTspLocationPath_FileNotExists_ReturnsFailure() + { + // Arrange + var tspLocationPath = Path.Combine(_tempDirectory, "nonexistent-" + TspLocationFileName); + + // Act + var result = await _tool.GenerateSdkAsync("/some/path", null, tspLocationPath, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("does not exist")); + } + + [Test] + public async Task GenerateSdkAsync_WithTspConfigPath_RemoteUrl_CallsRunTspInit() + { + // Arrange + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, RemoteTspConfigUrl, null, null); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + Assert.That(result.Message, Does.Contain(SdkGenerationSuccessMessage)); + _mockNpxHelper.Verify(x => x.Run(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GenerateSdkAsync_WithLocalTspConfigPath_ValidInputs_CallsRunTspInit() + { + // Arrange + var tspConfigPath = Path.Combine(_tempDirectory, TspConfigFileName); + File.WriteAllText(tspConfigPath, TestTspConfigContent); + + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoFullNameAsync(tspConfigPath, true)).ReturnsAsync(DefaultSpecRepo); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, tspConfigPath, null, null); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + Assert.That(result.Message, Does.Contain(SdkGenerationSuccessMessage)); + _mockNpxHelper.Verify(x => x.Run(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task GenerateSdkAsync_WithLocalTspConfigPath_FileNotExists_ReturnsError() + { + // Arrange + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + // Act - Use a non-existent file path + var result = await _tool.GenerateSdkAsync(_tempDirectory, "/nonexistent/" + TspConfigFileName, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(FileNotExistError)); + } + + [Test] + public async Task GenerateSdkAsync_TspClientInitFails_ReturnsFailure() + { + // Arrange + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + var failedResult = new ProcessResult { ExitCode = 1 }; + failedResult.AppendStderr(ProcessErrorOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(failedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, RemoteTspConfigUrl, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(TspClientInitFailedError)); + } + + [Test] + public async Task GenerateSdkAsync_WithInvalidRemoteUrl_ReturnsFailure() + { + // Arrange + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + // Act - Use invalid remote URL that doesn't match GitHub blob pattern + var result = await _tool.GenerateSdkAsync(_tempDirectory, InvalidRemoteTspConfigUrl, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("Invalid remote GitHub URL with commit")); + } + + [Test] + public async Task GenerateSdkAsync_ExceptionThrown_ReturnsFailureWithExceptionMessage() + { + // Arrange + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Test exception")); + + // Act - Now remote URLs work properly + var result = await _tool.GenerateSdkAsync(_tempDirectory, RemoteTspConfigUrl, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain("Test exception")); + } + + [Test] + public async Task GenerateSdkAsync_WithInvalidSdkRepoPath_ReturnsError() + { + // Act - Use a non-existent directory path + var result = await _tool.GenerateSdkAsync("/this/path/does/not/exist", RemoteTspConfigUrl, null, null); + + // Assert + Assert.That(result.ResponseErrors?.First(), Does.Contain(DirectoryNotExistError)); + } + + [Test] + public async Task GenerateSdkAsync_WithLocalTspConfigAndEmitterOptions_PassesOptionsToNpx() + { + // Arrange + var tspConfigPath = Path.Combine(_tempDirectory, TspConfigFileName); + File.WriteAllText(tspConfigPath, TestTspConfigContent); + var emitterOptions = "package-version=1.0.0-beta.1"; + + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoFullNameAsync(tspConfigPath, false)).ReturnsAsync(DefaultSpecRepo); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, tspConfigPath, null, emitterOptions); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + + // Verify the NPX arguments match expected pattern + _mockNpxHelper.Verify(x => x.Run( + It.Is(opts => + opts.Args.Contains("tsp-client") && + opts.Args.Contains("init") && + opts.Args.Contains("--update-if-exists") && + opts.Args.Contains("--tsp-config") && + opts.Args.Contains(tspConfigPath) && + opts.Args.Contains("--repo") && + opts.Args.Contains(DefaultSpecRepo) && + opts.Args.Contains("--emitter-options") && + opts.Args.Contains(emitterOptions)), + It.IsAny()), Times.Once); + } + + [Test] + public async Task GenerateSdkAsync_WithRemoteTspConfigAndEmitterOptions_PassesOptionsToNpx() + { + // Arrange + var emitterOptions = "package-version=1.0.0-beta.1"; + + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, RemoteTspConfigUrl, null, emitterOptions); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + _mockNpxHelper.Verify(x => x.Run( + It.Is(opts => + opts.Args.Contains("--emitter-options") && + opts.Args.Contains(emitterOptions) && + !opts.Args.Contains("--repo")), // Remote URLs should not include --repo + It.IsAny()), Times.Once); + } + + [Test] + public async Task GenerateSdkAsync_WithLocalTspConfigNoEmitterOptions_DoesNotPassEmitterOptionsToNpx() + { + // Arrange + var tspConfigPath = Path.Combine(_tempDirectory, TspConfigFileName); + File.WriteAllText(tspConfigPath, TestTspConfigContent); + + // Mock GitHelper to return valid repo root + _mockGitHelper.Setup(x => x.DiscoverRepoRoot(_tempDirectory)).Returns(_tempDirectory); + _mockGitHelper.Setup(x => x.GetRepoFullNameAsync(tspConfigPath, false)).ReturnsAsync(DefaultSpecRepo); + + var expectedResult = new ProcessResult { ExitCode = 0 }; + expectedResult.AppendStdout(ProcessSuccessOutput); + _mockNpxHelper + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedResult); + + // Act + var result = await _tool.GenerateSdkAsync(_tempDirectory, tspConfigPath, null, null); + + // Assert + Assert.That(result.Result, Is.EqualTo("succeeded")); + + // Verify the NPX arguments match expected pattern but don't include emitter options + _mockNpxHelper.Verify(x => x.Run( + It.Is(opts => + opts.Args.Contains("tsp-client") && + opts.Args.Contains("init") && + opts.Args.Contains("--update-if-exists") && + opts.Args.Contains("--tsp-config") && + opts.Args.Contains(tspConfigPath) && + opts.Args.Contains("--repo") && + opts.Args.Contains(DefaultSpecRepo) && + !opts.Args.Contains("--emitter-options")), // Should not include emitter options when null + It.IsAny()), Times.Once); + } + + #endregion + + #region Command Tests + + [Test] + public void GetCommand_ReturnsCommandWithCorrectStructure() + { + // Act + var command = _tool.GetCommand(); + + // Assert + Assert.That(command.Name, Is.EqualTo("generate")); + Assert.That(command.Description, Does.Contain("Generates SDK code")); + } + + #endregion +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedCommandGroups.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedCommandGroups.cs index 933bba26c2b..347829f3f66 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedCommandGroups.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedCommandGroups.cs @@ -41,6 +41,12 @@ public static class SharedCommandGroups Options: [] ); + public static readonly CommandGroup SourceCode = new( + Verb: "source-code", + Description: "Source code generation and build commands", + Options: [] + ); + public static readonly CommandGroup TypeSpec = new( Verb: "tsp", Description: "Tools for setting up or working with TypeSpec projects", diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs index 1896e1cba1b..32863c02692 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs @@ -29,6 +29,8 @@ public static class SharedOptions typeof(ReadMeGeneratorTool), typeof(ReleasePlanTool), typeof(ReleaseReadinessTool), + typeof(SdkBuildTool), + typeof(SdkGenerationTool), typeof(SdkReleaseTool), typeof(SpecCommonTools), typeof(PullRequestTools), diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/GitHelper.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/GitHelper.cs index 2da334e1344..d1af5df41ea 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/GitHelper.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/GitHelper.cs @@ -10,6 +10,7 @@ public interface IGitHelper { // Get the owner public Task GetRepoOwnerNameAsync(string path, bool findUpstreamParent = true); + public Task GetRepoFullNameAsync(string path, bool findUpstreamParent = true); public Uri GetRepoRemoteUri(string path); public string GetBranchName(string path); public string GetMergeBaseCommitSha(string path, string targetBranch); @@ -90,6 +91,19 @@ public async Task GetRepoOwnerNameAsync(string path, bool findUpstreamPa throw new InvalidOperationException("Unable to determine repository owner."); } + // Get the full name of repo in the format of "{owner/name}", e.g. "Azure/azure-rest-api-specs" + public async Task GetRepoFullNameAsync(string path, bool findUpstreamParent = true) + { + if (!string.IsNullOrEmpty(path)) + { + var repoOwner = await GetRepoOwnerNameAsync(path, findUpstreamParent); + var repoName = GetRepoName(path); + return $"{repoOwner}/{repoName}"; + } + + throw new ArgumentException("Invalid repository path.", nameof(path)); + } + public string DiscoverRepoRoot(string path) { var repoPath = Repository.Discover(path); @@ -104,10 +118,26 @@ public string DiscoverRepoRoot(string path) return gitDir.Parent?.FullName ?? throw new InvalidOperationException("Unable to determine repository root"); } + // Get the repository name from the local path public string GetRepoName(string path) { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("Invalid repository path.", nameof(path)); + } + var repoRoot = DiscoverRepoRoot(path); - return new DirectoryInfo(repoRoot).Name ?? throw new InvalidOperationException($"Unable to determine repository name for path: {path}"); + var uri = GetRepoRemoteUri(repoRoot); + var segments = uri.Segments; + + if (segments.Length < 2) + { + throw new InvalidOperationException($"Unable to parse repository owner and name from remote URL: {uri}"); + } + + string repoName = segments[^1].TrimEnd(".git".ToCharArray()); + + return repoName; } } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/Process/Options/NpxOptions.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/Process/Options/NpxOptions.cs index 0de1b364b1c..923002d5957 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/Process/Options/NpxOptions.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/Process/Options/NpxOptions.cs @@ -46,6 +46,6 @@ private static string[] BuildArgs(string? package, string[] args) { return args; } - return [$"--package={package}", "--", .. args]; + return ["--yes", $"--package={package}", "--", .. args]; } } diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/SpecGenSdkConfigHelper.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/SpecGenSdkConfigHelper.cs new file mode 100644 index 00000000000..bb0c42bc322 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Helpers/SpecGenSdkConfigHelper.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Azure.Sdk.Tools.Cli.Helpers +{ + /// + /// Interface for accessing and processing Azure SDK repository configuration from swagger_to_sdk_config.json files. + /// Supports reading build configurations, processing command templates, and navigating JSON configuration structures. + /// + public interface ISpecGenSdkConfigHelper + { + // Config value retrieval methods + Task GetConfigValueFromRepoAsync(string repositoryRoot, string jsonPath); + Task<(BuildConfigType type, string value)> GetBuildConfigurationAsync(string repositoryRoot); + + // Command processing methods + string SubstituteCommandVariables(string command, Dictionary variables); + string[] ParseCommand(string command); + } + + /// + /// Helper class for reading and processing configuration from the standardized "swagger_to_sdk_config.json" file + /// located at "eng/swagger_to_sdk_config.json" in Azure SDK repositories. This configuration file defines build + /// scripts, commands, and package options used during SDK generation from OpenAPI specifications. + /// + /// Provides functionality to: + /// - Extract build configuration (commands or script paths) for SDK compilation + /// - Substitute template variables in build commands (e.g., {packagePath}) + /// - Parse command strings into executable components + /// - Navigate JSON configuration paths to retrieve specific values + /// + /// Configuration schema reference: https://github.com/Azure/azure-sdk-tools/blob/main/tools/spec-gen-sdk/src/types/SwaggerToSdkConfigSchema.json + /// + public class SpecGenSdkConfigHelper : ISpecGenSdkConfigHelper + { + // Constants + private const string BuildCommandJsonPath = "packageOptions/buildScript/command"; + private const string BuildScriptPathJsonPath = "packageOptions/buildScript/path"; + private const string SpecToSdkConfigPath = "eng/swagger_to_sdk_config.json"; + + private readonly ILogger _logger; + + public SpecGenSdkConfigHelper(ILogger logger) + { + this._logger = logger; + } + + // Gets a configuration value from the swagger_to_sdk_config.json file + public async Task GetConfigValueFromRepoAsync(string repositoryRoot, string jsonPath) + { + var specToSdkConfigFilePath = Path.Combine(repositoryRoot, SpecToSdkConfigPath); + + _logger.LogInformation($"Reading configuration from: {specToSdkConfigFilePath} at path: {jsonPath}"); + + if (!File.Exists(specToSdkConfigFilePath)) + { + throw new FileNotFoundException($"Configuration file not found at: {specToSdkConfigFilePath}"); + } + + try + { + // Read and parse the configuration file + var configContent = await File.ReadAllTextAsync(specToSdkConfigFilePath); + using var configJson = JsonDocument.Parse(configContent); + + // Use helper method to navigate JSON path + var (found, element) = TryGetJsonElementByPath(configJson.RootElement, jsonPath); + if (!found) + { + throw new InvalidOperationException($"Property not found at JSON path '{jsonPath}' in configuration file {specToSdkConfigFilePath}."); + } + + // Deserialize to the requested type + var value = JsonSerializer.Deserialize(element.GetRawText()); + if (value == null) + { + throw new InvalidOperationException($"Failed to deserialize value at JSON path '{jsonPath}' to type {typeof(T).Name}."); + } + + _logger.LogDebug("Retrieved config value from {JsonPath} as {Type}", jsonPath, typeof(T).Name); + return value; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Error parsing JSON configuration: {ex.Message}", ex); + } + } + + // Get build configuration (either command or script path) + public async Task<(BuildConfigType type, string value)> GetBuildConfigurationAsync(string repositoryRoot) + { + // Try command first + try + { + var command = await GetConfigValueFromRepoAsync(repositoryRoot, BuildCommandJsonPath); + if (!string.IsNullOrEmpty(command)) + { + _logger.LogDebug("Found build command configuration"); + return (BuildConfigType.Command, command); + } + } + catch (InvalidOperationException) + { + // Command not found, continue to try path + _logger.LogDebug("No build command found, trying script path"); + } + + // Try path + try + { + var path = await GetConfigValueFromRepoAsync(repositoryRoot, BuildScriptPathJsonPath); + if (!string.IsNullOrEmpty(path)) + { + _logger.LogDebug("Found build script path configuration"); + return (BuildConfigType.ScriptPath, path); + } + } + catch (InvalidOperationException) + { + // Path not found either + _logger.LogError("No build configuration found"); + } + + throw new InvalidOperationException($"Neither '{BuildCommandJsonPath}' nor '{BuildScriptPathJsonPath}' found in configuration."); + } + + // Substitute template variables in command strings + public string SubstituteCommandVariables(string command, Dictionary variables) + { + if (string.IsNullOrEmpty(command)) + { + return command; + } + + if (variables == null || variables.Count == 0) + { + return command; + } + + var result = command; + + // Replace variables in the format {variableName} + foreach (var variable in variables) + { + var placeholder = $"{{{variable.Key}}}"; + result = result.Replace(placeholder, variable.Value, StringComparison.OrdinalIgnoreCase); + } + + _logger.LogDebug("Command after variable substitution: {Result}", result); + return result; + } + + // Parse command string into command and arguments + public string[] ParseCommand(string command) + { + if (string.IsNullOrWhiteSpace(command)) + { + return Array.Empty(); + } + + // Use System.CommandLine.Parser + var parser = new Parser(new RootCommand()); + var result = parser.Parse(command); + + // Get all tokens + var tokens = result.Tokens + .Select(t => t.Value) + .ToArray(); + + _logger.LogDebug("Parsed command into {Count} parts: {Parts}", tokens.Length, string.Join(", ", tokens)); + return tokens; + } + + // Try to get a JSON element by its path + private (bool found, JsonElement element) TryGetJsonElementByPath(JsonElement root, string path) + { + if (string.IsNullOrEmpty(path)) + { + return (false, default); + } + + var pathParts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + JsonElement current = root; + + foreach (var part in pathParts) + { + if (!current.TryGetProperty(part, out current)) + { + return (false, default); + } + } + + return (true, current); + } + } + + public enum BuildConfigType + { + Command, + ScriptPath + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs index db18e0f3d51..08dd5379f1d 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/ServiceRegistrations.cs @@ -53,6 +53,7 @@ public static void RegisterCommonServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Process Helper Classes diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkBuildTool.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkBuildTool.cs new file mode 100644 index 00000000000..ee754d4df0f --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkBuildTool.cs @@ -0,0 +1,209 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.Sdk.Tools.Cli.Commands; +using Azure.Sdk.Tools.Cli.Contract; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Services; +using Microsoft.AspNetCore.Mvc; +using ModelContextProtocol.Server; +using LibGit2Sharp; + +namespace Azure.Sdk.Tools.Cli.Tools.Package +{ + [McpServerToolType, Description("This type contains the tools to build/compile SDK code locally.")] + public class SdkBuildTool: MCPTool + { + // Command names + private const string BuildSdkCommandName = "build"; + private const string AzureSdkForPythonRepoName = "azure-sdk-for-python"; + private const int CommandTimeoutInMinutes = 30; + + private readonly IOutputHelper _output; + private readonly IProcessHelper _processHelper; + private readonly IGitHelper _gitHelper; + private readonly ISpecGenSdkConfigHelper _specGenSdkConfigHelper; + private readonly ILogger _logger; + + public SdkBuildTool(IGitHelper gitHelper, ILogger logger, IOutputHelper output, IProcessHelper processHelper, ISpecGenSdkConfigHelper specGenSdkConfigHelper): base() + { + _gitHelper = gitHelper; + _logger = logger; + _output = output; + _processHelper = processHelper; + _specGenSdkConfigHelper = specGenSdkConfigHelper; + CommandHierarchy = [ SharedCommandGroups.Package, SharedCommandGroups.SourceCode ]; + } + + public override Command GetCommand() + { + var command = new Command(BuildSdkCommandName, "Builds SDK source code for a specified language and project."); + command.SetHandler(async ctx => { await HandleCommand(ctx, ctx.GetCancellationToken()); }); + command.AddOption(SharedOptions.PackagePath); + + return command; + } + + public async override Task HandleCommand(InvocationContext ctx, CancellationToken ct) + { + var command = ctx.ParseResult.CommandResult.Command.Name; + var commandParser = ctx.ParseResult; + var packagePath = commandParser.GetValueForOption(SharedOptions.PackagePath); + var buildResult = await BuildSdkAsync(packagePath, ct); + ctx.ExitCode = ExitCode; + _output.Output(buildResult); + } + + [McpServerTool(Name = "azsdk_package_build_code"), Description("Build/compile SDK code for a specified project locally.")] + public async Task BuildSdkAsync( + [Description("Absolute path to the SDK project.")] + string packagePath, + CancellationToken ct = default) + { + try + { + _logger.LogInformation($"Building SDK for project path: {packagePath}"); + + // Validate inputs + if (string.IsNullOrEmpty(packagePath)) + { + return CreateFailureResponse("Package path is required."); + } + + if (!Directory.Exists(packagePath)) + { + return CreateFailureResponse($"Path does not exist: {packagePath}"); + } + + // Get repository root path from project path + string sdkRepoRoot = _gitHelper.DiscoverRepoRoot(packagePath); + if (string.IsNullOrEmpty(sdkRepoRoot)) + { + return CreateFailureResponse($"Failed to discover local sdk repo with project-path: {packagePath}."); + } + + _logger.LogInformation($"Repository root path: {sdkRepoRoot}"); + + string sdkRepoName = _gitHelper.GetRepoName(sdkRepoRoot); + _logger.LogInformation($"Repository name: {sdkRepoName}"); + + // Return if the project is python project + if (sdkRepoName.Contains(AzureSdkForPythonRepoName, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Python SDK project detected. Skipping build step as Python SDKs do not require a build process."); + return CreateSuccessResponse("Python SDK project detected. Skipping build step as Python SDKs do not require a build process."); + } + + // Get the build configuration (command or script path) + ProcessOptions options; + try + { + options = await CreateProcessOptions(sdkRepoRoot, packagePath); + } + catch (Exception ex) + { + return CreateFailureResponse($"Failed to get build configuration: {ex.Message}"); + } + + // Run the build script or command + _logger.LogInformation($"Executing build process..."); + var buildResult = await _processHelper.Run(options, ct); + var trimmedBuildResult = (buildResult.Output ?? string.Empty).Trim(); + if (buildResult.ExitCode != 0) + { + return CreateFailureResponse($"Build process failed with exit code {buildResult.ExitCode}. Output:\n{trimmedBuildResult}"); + } + + _logger.LogInformation("Build process execution completed"); + return CreateSuccessResponse($"Build completed successfully. Output:\n{trimmedBuildResult}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while building SDK"); + return CreateFailureResponse($"An error occurred: {ex.Message}"); + } + } + + // Helper method to create failure responses along with setting the failure state + private DefaultCommandResponse CreateFailureResponse(string message) + { + SetFailure(); + return new DefaultCommandResponse + { + ResponseErrors = [message] + }; + } + + // Helper method to create success responses (no SetFailure needed) + private DefaultCommandResponse CreateSuccessResponse(string message) + { + return new DefaultCommandResponse + { + Result = "succeeded", + Message = message + }; + } + + // Create process options for building the SDK based on configuration + private async Task CreateProcessOptions(string sdkRepoRoot, string packagePath) + { + var (configType, configValue) = await _specGenSdkConfigHelper.GetBuildConfigurationAsync(sdkRepoRoot); + + if (configType == BuildConfigType.Command) + { + // Execute as command + var variables = new Dictionary + { + { "packagePath", packagePath } + }; + + var substitutedCommand = _specGenSdkConfigHelper.SubstituteCommandVariables(configValue, variables); + _logger.LogInformation($"Executing build command: {substitutedCommand}"); + + var commandParts = _specGenSdkConfigHelper.ParseCommand(substitutedCommand); + if (commandParts.Length == 0) + { + throw new InvalidOperationException($"Invalid build command: {substitutedCommand}"); + } + + return new ProcessOptions( + commandParts[0], + commandParts.Skip(1).ToArray(), + logOutputStream: true, + workingDirectory: packagePath, + timeout: TimeSpan.FromMinutes(CommandTimeoutInMinutes) + ); + } + else // BuildConfigType.ScriptPath + { + // Execute as script file + // Always resolve relative paths against sdkRepoRoot, then normalize + var fullBuildScriptPath = Path.IsPathRooted(configValue) + ? configValue + : Path.Combine(sdkRepoRoot, configValue); + + // Normalize the final path + fullBuildScriptPath = Path.GetFullPath(fullBuildScriptPath); + + if (!File.Exists(fullBuildScriptPath)) + { + throw new FileNotFoundException($"Build script not found at: {fullBuildScriptPath}"); + } + + _logger.LogInformation($"Executing build script file: {fullBuildScriptPath}"); + + return new PowershellOptions( + fullBuildScriptPath, + ["-PackagePath", packagePath], + logOutputStream: true, + workingDirectory: sdkRepoRoot, + timeout: TimeSpan.FromMinutes(CommandTimeoutInMinutes) + ); + } + } + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkGenerationTool.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkGenerationTool.cs new file mode 100644 index 00000000000..8974189d415 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Tools/Package/SdkGenerationTool.cs @@ -0,0 +1,276 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.Sdk.Tools.Cli.Commands; +using Azure.Sdk.Tools.Cli.Contract; +using Azure.Sdk.Tools.Cli.Helpers; +using Azure.Sdk.Tools.Cli.Models; +using Azure.Sdk.Tools.Cli.Services; +using Microsoft.AspNetCore.Mvc; +using ModelContextProtocol.Server; +using LibGit2Sharp; + +namespace Azure.Sdk.Tools.Cli.Tools.Package +{ + [McpServerToolType, Description("This type contains the tools to generate SDK code locally.")] + public class SdkGenerationTool: MCPTool + { + // Command names + private const string GenerateSdkCommandName = "generate"; + private const int CommandTimeoutInMinutes = 30; + + // Generate command options + private readonly Option localSdkRepoPathOpt = new(["--local-sdk-repo-path", "-r"], "Absolute path to the local azure-sdk-for-{language} repository") { IsRequired = false }; + private readonly Option tspConfigPathOpt = new(["--tsp-config-path", "-t"], "Path to the 'tspconfig.yaml' configuration file, it can be a local path or remote HTTPS URL") { IsRequired = false }; + private readonly Option tspLocationPathOpt = new(["--tsp-location-path", "-l"], "Absolute path to the 'tsp-location.yaml' configuration file") { IsRequired = false }; + private readonly Option emitterOpt = new(["--emitter-options", "-o"], "Emitter options in key-value format. Example: 'package-version=1.0.0-beta.1'") { IsRequired = false }; + + private readonly IOutputHelper _output; + private readonly IProcessHelper _processHelper; + private readonly IGitHelper _gitHelper; + private readonly ILogger _logger; + private readonly INpxHelper _npxHelper; + + public SdkGenerationTool(IGitHelper gitHelper, ILogger logger, INpxHelper npxHelper, IOutputHelper output, IProcessHelper processHelper): base() + { + _gitHelper = gitHelper; + _logger = logger; + _npxHelper = npxHelper; + _output = output; + _processHelper = processHelper; + CommandHierarchy = [ SharedCommandGroups.Package, SharedCommandGroups.SourceCode ]; + } + + public override Command GetCommand() + { + var command = new Command(GenerateSdkCommandName, "Generates SDK code for a specified language based on the provided 'tspconfig.yaml' or 'tsp-location.yaml'.") { localSdkRepoPathOpt, tspConfigPathOpt, tspLocationPathOpt, emitterOpt }; + command.SetHandler(async ctx => { await HandleCommand(ctx, ctx.GetCancellationToken()); }); + + return command; + } + + public async override Task HandleCommand(InvocationContext ctx, CancellationToken ct) + { + var command = ctx.ParseResult.CommandResult.Command.Name; + var commandParser = ctx.ParseResult; + var localSdkRepoPath = commandParser.GetValueForOption(localSdkRepoPathOpt); + var tspConfigPath = commandParser.GetValueForOption(tspConfigPathOpt); + var tspLocationPath = commandParser.GetValueForOption(tspLocationPathOpt); + var emitterOptions = commandParser.GetValueForOption(emitterOpt); + var generateResult = await GenerateSdkAsync(localSdkRepoPath, tspConfigPath, tspLocationPath, emitterOptions, ct); + ctx.ExitCode = ExitCode; + _output.Output(generateResult); + } + + [McpServerTool(Name = "azsdk_package_generate_code"), Description("Generates SDK code for a specified language using either 'tspconfig.yaml' or 'tsp-location.yaml'. Runs locally.")] + public async Task GenerateSdkAsync( + [Description("Absolute path to the local Azure SDK repository. REQUIRED. Example: 'path/to/azure-sdk-for-net'. If not provided, the tool attempts to discover the repo from the current working directory.")] + string localSdkRepoPath, + [Description("Path to the 'tspconfig.yaml' file. Can be a local file path or a remote HTTPS URL. Optional if running inside a local cloned azure-sdk-for-{language} repository, for example, inside 'azure-sdk-for-net' repository.")] + string? tspConfigPath, + [Description("Path to 'tsp-location.yaml'. Optional. May be left empty if running inside a local cloned 'azure-rest-api-specs' repository.")] + string? tspLocationPath, + [Description("Emitter options in key-value format. Optional. Leave empty for defaults. Example: 'package-version=1.0.0-beta.1'.")] + string? emitterOptions, + CancellationToken ct = default) + { + try + { + // Validate inputs + if (string.IsNullOrEmpty(tspConfigPath) && string.IsNullOrEmpty(tspLocationPath)) + { + return CreateFailureResponse("Both 'tspconfig.yaml' and 'tsp-location.yaml' paths aren't provided. At least one of them is required."); + } + + // Handle tsp-location.yaml case + if (!string.IsNullOrEmpty(tspLocationPath)) + { + if (!tspLocationPath.EndsWith("tsp-location.yaml", StringComparison.OrdinalIgnoreCase)) + { + return CreateFailureResponse($"The specified 'tsp-location.yaml' path is invalid: {tspLocationPath}. It must be an absolute path to local 'tsp-location.yaml' file."); + } + return await RunTspUpdate(tspLocationPath, ct); + } + + // Handle tspconfig.yaml case + return await GenerateSdkFromTspConfigAsync(localSdkRepoPath, tspConfigPath, emitterOptions, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while generating SDK"); + return CreateFailureResponse($"An error occurred: {ex.Message}"); + } + } + + // Run language-specific script to generate the SDK code from 'tspconfig.yaml' + private async Task GenerateSdkFromTspConfigAsync(string localSdkRepoPath, string tspConfigPath, string emitterOptions, CancellationToken ct) + { + string specRepoFullName = string.Empty; + + // white spaces will be added by agent when it's a URL + tspConfigPath = tspConfigPath.Trim(); + + // Validate inputs + _logger.LogInformation($"Generating SDK at repo: {localSdkRepoPath}"); + if (string.IsNullOrEmpty(localSdkRepoPath) || !Directory.Exists(localSdkRepoPath)) + { + return CreateFailureResponse($"The directory for the local sdk repo does not provide or exist at the specified path: {localSdkRepoPath}. Prompt user to clone the matched SDK repository users want to generate SDK against."); + } + + // Get the generate script path + string sdkRepoRoot = _gitHelper.DiscoverRepoRoot(localSdkRepoPath); + if (string.IsNullOrEmpty(sdkRepoRoot)) + { + return CreateFailureResponse($"Failed to discover local sdk repo with path: {localSdkRepoPath}."); + } + + if (!tspConfigPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + // Validate arguments for local tspconfig.yaml case + if (!File.Exists(tspConfigPath)) + { + return CreateFailureResponse($"The 'tspconfig.yaml' file does not exist at the specified path: {tspConfigPath}. Prompt user to clone the azure-rest-api-specs repository locally if it does not have a local copy."); + } + specRepoFullName = await _gitHelper.GetRepoFullNameAsync(tspConfigPath, findUpstreamParent: false); + } + else + { + // specRepoFullName doesn't need to be set in this case + _logger.LogInformation($"Remote 'tspconfig.yaml' URL detected: {tspConfigPath}."); + if (!IsValidRemoteGitHubUrlWithCommit(tspConfigPath)) + { + return CreateFailureResponse($"Invalid remote GitHub URL with commit: {tspConfigPath}. The URL must include a valid commit SHA. Example: https://github.com/Azure/azure-rest-api-specs/blob/dee71463cbde1d416c47cf544e34f7966a94ddcb/specification/contosowidgetmanager/Contoso.Management/tspconfig.yaml"); + } + } + + return await RunTspInit(localSdkRepoPath, tspConfigPath, specRepoFullName, emitterOptions, ct); + } + + // Run tsp-client update command to re-generate the SDK code + private async Task RunTspUpdate(string tspLocationPath, CancellationToken ct) + { + if (!File.Exists(tspLocationPath)) + { + return CreateFailureResponse($"The 'tsp-location.yaml' file does not exist at the specified path: {tspLocationPath}"); + } + + _logger.LogInformation($"Running tsp-client update command in directory: {Path.GetDirectoryName(tspLocationPath)}"); + + var tspLocationDirectory = Path.GetDirectoryName(tspLocationPath); + var npxOptions = new NpxOptions( + "@azure-tools/typespec-client-generator-cli", + ["tsp-client", "update"], + logOutputStream: true, + workingDirectory: tspLocationDirectory, + timeout: TimeSpan.FromMinutes(CommandTimeoutInMinutes) + ); + + var tspClientResult = await _npxHelper.Run(npxOptions, ct); + if (tspClientResult.ExitCode != 0) + { + return CreateFailureResponse($"tsp-client update failed with exit code {tspClientResult.ExitCode}. Output:\n{tspClientResult.Output}"); + } + + _logger.LogInformation("tsp-client update completed successfully"); + return CreateSuccessResponse($"SDK re-generation completed successfully using tsp-location.yaml. Output:\n{tspClientResult.Output}"); + } + + // Run tsp-client init command to re-generate the SDK code + private async Task RunTspInit(string localSdkRepoPath, string tspConfigPath, string specRepoFullName, string emitterOptions, CancellationToken ct) + { + _logger.LogInformation($"Running tsp-client init command."); + + // Build arguments list dynamically + var arguments = new List { "tsp-client", "init", "--update-if-exists", "--tsp-config", tspConfigPath }; + + if (!string.IsNullOrEmpty(specRepoFullName)) + { + arguments.Add("--repo"); + arguments.Add(specRepoFullName); + } + + if (!string.IsNullOrEmpty(emitterOptions)) + { + arguments.Add("--emitter-options"); + arguments.Add(emitterOptions); + } + + var npxOptions = new NpxOptions( + "@azure-tools/typespec-client-generator-cli", + arguments.ToArray(), + logOutputStream: true, + workingDirectory: localSdkRepoPath, + timeout: TimeSpan.FromMinutes(CommandTimeoutInMinutes) + ); + + var tspClientResult = await _npxHelper.Run(npxOptions, ct); + if (tspClientResult.ExitCode != 0) + { + return CreateFailureResponse($"tsp-client init failed with exit code {tspClientResult.ExitCode}. Output:\n{tspClientResult.Output}"); + } + + _logger.LogInformation("tsp-client init completed successfully"); + return CreateSuccessResponse($"SDK generation completed successfully using tspconfig.yaml. Output:\n{tspClientResult.Output}"); + } + + + + // Validate remote GitHub URL with commit SHA + private bool IsValidRemoteGitHubUrlWithCommit(string tspConfigPath) + { + // Extract the part after /blob/ and before the next / + var blobIndex = tspConfigPath.IndexOf("/blob/", StringComparison.OrdinalIgnoreCase); + if (blobIndex == -1) + { + return false; + } + + var afterBlob = tspConfigPath.Substring(blobIndex + 6); + var nextSlashIndex = afterBlob.IndexOf('/'); + if (nextSlashIndex == -1) + { + return false; + } + + var commitOrBranch = afterBlob.Substring(0, nextSlashIndex); + + // Validate that it's a 40-character commit SHA, not a branch name + return IsValidSha(commitOrBranch); + } + + // Validate commitSha: must be a 40-character hex string + private bool IsValidSha(string sha) + { + if (!string.IsNullOrEmpty(sha)) + { + var match = Regex.Match(sha, @"^[a-f0-9]{40}$", RegexOptions.IgnoreCase); + return match.Success; + } + + return false; + } + + // Helper method to create failure responses along with setting the failure state + private DefaultCommandResponse CreateFailureResponse(string message) + { + SetFailure(); + return new DefaultCommandResponse + { + ResponseErrors = [message] + }; + } + + // Helper method to create success responses (no SetFailure needed) + private DefaultCommandResponse CreateSuccessResponse(string message) + { + return new DefaultCommandResponse + { + Result = "succeeded", + Message = message + }; + } + } +}