diff --git a/src/Bicep.Cli.IntegrationTests/DecompileTests.cs b/src/Bicep.Cli.IntegrationTests/DecompileTests.cs index 979e744c95e..4302cf75fa7 100644 --- a/src/Bicep.Cli.IntegrationTests/DecompileTests.cs +++ b/src/Bicep.Cli.IntegrationTests/DecompileTests.cs @@ -12,12 +12,65 @@ using FluentAssertions; using FluentAssertions.Execution; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Bicep.Core.FileSystem; namespace Bicep.Cli.IntegrationTests { [TestClass] public class DecompileTests - { + { + private const string ValidTemplate = @"{ + ""$schema"": ""https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"", + ""contentVersion"": ""1.0.0.0"", + ""parameters"": {}, + ""variables"": {}, + ""resources"": [ + { + ""type"": ""My.Rp/testType"", + ""apiVersion"": ""2020-01-01"", + ""name"": ""resName"", + ""location"": ""[resourceGroup().location]"", + ""properties"": { + ""prop1"": ""val1"" + } + } + ], + ""outputs"": {} + }"; + + private const string InvalidTemplate = @"{ + ""$schema"": ""https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"", + ""contentVersion"": ""1.0.0.0"", + ""parameters"": {}, + ""variables"": {}, + ""resources"": [ + { + ""type"": ""My.Rp/testType"", + ""apiVersion"": ""2020-01-01"", + ""name"": ""resName"", + ""properties"": { + ""cyclicDependency"": ""[reference(resourceId('My.Rp/testType', 'resName'))]"" + } + } + ], + ""outputs"": {} + }"; + + private const string ValidTemplateExpectedDecompilation = @"resource resName 'My.Rp/testType@2020-01-01' = { + name: 'resName' + location: resourceGroup().location + properties: { + prop1: 'val1' + } +}"; + + private const string InvalidTemplateExpectedDecompilation = @"resource resName 'My.Rp/testType@2020-01-01' = { + name: 'resName' + properties: { + cyclicDependency: resName.properties + } +}"; + [NotNull] public TestContext? TestContext { get; set; } @@ -76,28 +129,23 @@ public void Decompilation_of_empty_template_succeeds() } [TestMethod] - public void Decompilation_of_file_with_errors() - { - var template = @"{ - ""$schema"": ""https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"", - ""contentVersion"": ""1.0.0.0"", - ""parameters"": {}, - ""variables"": {}, - ""resources"": [ + public void Decompilation_with_zero_files_should_produce_expected_error() { - ""type"": ""My.Rp/testType"", - ""apiVersion"": ""2020-01-01"", - ""name"": ""resName"", - ""properties"": { - ""cyclicDependency"": ""[reference(resourceId('My.Rp/testType', 'resName'))]"" + var (output, error, result) = ExecuteProgram("decompile"); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().Contain($"The input file path was not specified"); + result.Should().Be(1); } } - ], - ""outputs"": {} -}"; + [TestMethod] + public void Decompilation_of_file_with_errors() + { var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); - File.WriteAllText(fileName, template); + File.WriteAllText(fileName, InvalidTemplate); var (output, error, result) = ExecuteProgram("decompile", fileName); var bicepFileName = Path.ChangeExtension(fileName, "bicep"); @@ -105,52 +153,103 @@ public void Decompilation_of_file_with_errors() using (new AssertionScope()) { output.Should().BeEmpty(); - error.Should().BeEquivalentTo( + error.Should().Contain( "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", - "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues.", - $"{bicepFileName}(4,23) : Error BCP079: This expression is referencing its own declaration, which is not allowed." - ); + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues."); + string.Join(string.Empty, error).Should().Contain("(4,23) : Error BCP079: This expression is referencing its own declaration, which is not allowed."); result.Should().Be(1); } var bicepFile = File.ReadAllText(bicepFileName); - bicepFile.Should().BeEquivalentToIgnoringNewlines(@"resource resName 'My.Rp/testType@2020-01-01' = { - name: 'resName' - properties: { - cyclicDependency: resName.properties - } -}"); + bicepFile.Should().BeEquivalentToIgnoringNewlines(InvalidTemplateExpectedDecompilation); } [TestMethod] public void Decompilation_of_file_with_no_errors() { - var template = @"{ - ""$schema"": ""https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"", - ""contentVersion"": ""1.0.0.0"", - ""parameters"": {}, - ""variables"": {}, - ""resources"": [ + var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); + File.WriteAllText(fileName, ValidTemplate); + + var (output, error, result) = ExecuteProgram("decompile", fileName); + var bicepFileName = Path.ChangeExtension(fileName, "bicep"); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues."); + result.Should().Be(0); + } + + var bicepFile = File.ReadAllText(bicepFileName); + bicepFile.Should().BeEquivalentToIgnoringNewlines(ValidTemplateExpectedDecompilation); + } + + [TestMethod] + public void Decompilation_of_file_with_errors_to_stdout() { - ""type"": ""My.Rp/testType"", - ""apiVersion"": ""2020-01-01"", - ""name"": ""resName"", - ""location"": ""[resourceGroup().location]"", - ""properties"": { - ""prop1"": ""val1"" + var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); + File.WriteAllText(fileName, InvalidTemplate); + + var (output, error, result) = ExecuteProgram("decompile", "--stdout", fileName); + + using (new AssertionScope()) + { + output.Should().BeEquivalentTo( + "resource resName 'My.Rp/testType@2020-01-01' = {", + " name: 'resName'", + " properties: {", + " cyclicDependency: resName.properties", + " }", + "}"); + error.Should().Contain( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues."); + string.Join(string.Empty, error).Should().Contain("(4,23) : Error BCP079: This expression is referencing its own declaration, which is not allowed."); + result.Should().Be(1); } } - ], - ""outputs"": {} -}"; + [TestMethod] + public void Decompilation_of_file_with_no_errors_to_stdout() + { var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); - File.WriteAllText(fileName, template); + File.WriteAllText(fileName, ValidTemplate); + + var (output, error, result) = ExecuteProgram("decompile", "--stdout", fileName); + + using (new AssertionScope()) + { + output.Should().BeEquivalentTo( + "resource resName 'My.Rp/testType@2020-01-01' = {", + " name: 'resName'", + " location: resourceGroup().location", + " properties: {", + " prop1: 'val1'", + " }", + "}"); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues."); + result.Should().Be(0); + } + } + + [TestMethod] + public void Decompilation_of_file_with_no_errors_to_outfile() + { + var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); + File.WriteAllText(fileName, ValidTemplate); - var (output, error, result) = ExecuteProgram("decompile", fileName); var bicepFileName = Path.ChangeExtension(fileName, "bicep"); + var (output, error, result) = ExecuteProgram("decompile", "--outfile", bicepFileName, fileName); + using (new AssertionScope()) { output.Should().BeEmpty(); @@ -162,13 +261,116 @@ public void Decompilation_of_file_with_no_errors() } var bicepFile = File.ReadAllText(bicepFileName); - bicepFile.Should().BeEquivalentToIgnoringNewlines(@"resource resName 'My.Rp/testType@2020-01-01' = { - name: 'resName' - location: resourceGroup().location - properties: { - prop1: 'val1' - } -}"); + bicepFile.Should().BeEquivalentToIgnoringNewlines(ValidTemplateExpectedDecompilation); + } + + [TestMethod] + public void Decompilation_of_file_with_no_errors_to_outdir() + { + var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); + File.WriteAllText(fileName, ValidTemplate); + + var outputFileDir = FileHelper.GetResultFilePath(TestContext, "outputdir"); + Directory.CreateDirectory(outputFileDir); + var expectedOutputFile = Path.Combine(outputFileDir, "main.bicep"); + + var (output, error, result) = ExecuteProgram("decompile", "--outdir", outputFileDir, fileName); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues."); + result.Should().Be(0); + } + + var bicepFile = File.ReadAllText(expectedOutputFile); + bicepFile.Should().BeEquivalentToIgnoringNewlines(ValidTemplateExpectedDecompilation); + } + + [TestMethod] + public void Decompilation_of_file_with_no_errors_to_nonexistent_outdir() + { + var fileName = FileHelper.GetResultFilePath(TestContext, "main.json"); + File.WriteAllText(fileName, ValidTemplate); + + var outputFileDir = FileHelper.GetResultFilePath(TestContext, "outputdir"); + + var (output, error, result) = ExecuteProgram("decompile", "--outdir", outputFileDir, fileName); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues.", + $"The specified output directory \"{outputFileDir}\" does not exist."); + result.Should().Be(1); + } + } + + [DataRow("DoesNotExist.json")] + [DataRow("WrongDir\\Fake.json")] + [DataTestMethod] + public void Decompilation_of_invalid_input_paths_should_produce_expected_errors(string badPath) + { + var (output, error, result) = ExecuteProgram("decompile", badPath); + var expectedErrorBadPath = Path.GetFullPath(badPath); + var expectedErrorBadUri = PathHelper.FilePathToFileUrl(expectedErrorBadPath); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues.", + $"{expectedErrorBadPath}: Decompilation failed with fatal error \"Failed to read {expectedErrorBadUri}\""); + result.Should().Be(1); + } + } + + [DataRow("DoesNotExist.json")] + [DataRow("WrongDir\\Fake.json")] + [DataTestMethod] + public void Decompilation_of_invalid_input_paths_to_stdout_should_produce_expected_errors(string badPath) + { + var (output, error, result) = ExecuteProgram("decompile", "--stdout", badPath); + var expectedErrorBadPath = Path.GetFullPath(badPath); + var expectedErrorBadUri = PathHelper.FilePathToFileUrl(expectedErrorBadPath); + + using (new AssertionScope()) + { + output.Should().BeEmpty(); + error.Should().BeEquivalentTo( + "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.", + "You may need to fix warnings and errors in the generated bicep file(s), or decompilation may fail entirely if an accurate conversion is not possible.", + "If you would like to report any issues or inaccurate conversions, please see https://github.com/Azure/bicep/issues.", + $"{expectedErrorBadPath}: Decompilation failed with fatal error \"Failed to read {expectedErrorBadUri}\""); + result.Should().Be(1); + } + } + + [TestMethod] + public void Locked_output_file_Should_produce_expected_error() + { + var inputFile = FileHelper.SaveResultFile(this.TestContext, "Empty.json", string.Empty); + var outputFile = PathHelper.GetDefaultDecompileOutputPath(inputFile); + + // ReSharper disable once ConvertToUsingDeclaration + using (new FileStream(outputFile, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) + { + // keep the output stream open while we attempt to write to it + // this should force an access denied error + var (output, error, result) = ExecuteProgram("decompile", inputFile); + + output.Should().BeEmpty(); + string.Join(string.Empty, error).Should().Contain("Empty.json"); + result.Should().Be(1); + } } } -} \ No newline at end of file +} diff --git a/src/Bicep.Cli.IntegrationTests/ProgramTests.cs b/src/Bicep.Cli.IntegrationTests/ProgramTests.cs index e08c7c4acbe..a56f294dd1b 100644 --- a/src/Bicep.Cli.IntegrationTests/ProgramTests.cs +++ b/src/Bicep.Cli.IntegrationTests/ProgramTests.cs @@ -315,7 +315,7 @@ public void BuildInvalidInputPathsToStdOutShouldProduceExpectedError(string badP public void LockedOutputFileShouldProduceExpectedError() { var inputFile = FileHelper.SaveResultFile(this.TestContext, "Empty.bicep", DataSets.Empty.Bicep); - var outputFile = PathHelper.GetDefaultOutputPath(inputFile); + var outputFile = PathHelper.GetDefaultBuildOutputPath(inputFile); // ReSharper disable once ConvertToUsingDeclaration using (new FileStream(outputFile, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) diff --git a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs b/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs index 3fa472a5a9e..be7a76415ef 100644 --- a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs +++ b/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs @@ -78,8 +78,16 @@ public void Wrong_command_should_return_null() [DataRow(new [] { "build", "--stdout", "--outdir", "dir1", "file1" }, "The --outdir and --stdout parameters cannot both be used")] [DataRow(new [] { "build", "--outfile", "dir1", "--outdir", "dir2", "file1" }, "The --outdir and --outfile parameters cannot both be used")] [DataRow(new [] { "decompile" }, "The input file path was not specified")] + [DataRow(new [] { "decompile", "--stdout" }, "The input file path was not specified")] [DataRow(new [] { "decompile", "file1", "file2" }, "The input file path cannot be specified multiple times")] [DataRow(new [] { "decompile", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new [] { "decompile", "--outdir" }, "The --outdir parameter expects an argument")] + [DataRow(new [] { "decompile", "--outdir", "dir1", "--outdir", "dir2" }, "The --outdir parameter cannot be specified twice")] + [DataRow(new [] { "decompile", "--outfile" }, "The --outfile parameter expects an argument")] + [DataRow(new [] { "decompile", "--outfile", "dir1", "--outfile", "dir2" }, "The --outfile parameter cannot be specified twice")] + [DataRow(new [] { "decompile", "--stdout", "--outfile", "dir1", "file1" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new [] { "decompile", "--stdout", "--outdir", "dir1", "file1" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new [] { "decompile", "--outfile", "dir1", "--outdir", "dir2", "file1" }, "The --outdir and --outfile parameters cannot both be used")] public void Invalid_args_trigger_validation_exceptions(string[] parameters, string expectedException) { Action parseFunc = () => ArgumentParser.TryParse(parameters); @@ -90,66 +98,71 @@ public void Invalid_args_trigger_validation_exceptions(string[] parameters, stri [TestMethod] public void BuildOneFile_ShouldReturnOneFile() { - var arguments = (BuildArguments?)ArgumentParser.TryParse(new[] {"build", "file1"}); + var arguments = ArgumentParser.TryParse(new[] {"build", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; // using classic assert so R# understands the value is not null Assert.IsNotNull(arguments); - arguments!.InputFile.Should().Be("file1"); - arguments!.OutputToStdOut.Should().BeFalse(); - arguments!.OutputDir.Should().BeNull(); - arguments!.OutputFile.Should().BeNull(); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); } [TestMethod] public void BuildOneFileStdOut_ShouldReturnOneFileAndStdout() { - var arguments = (BuildArguments?)ArgumentParser.TryParse(new[] {"build", "--stdout", "file1"}); + var arguments = ArgumentParser.TryParse(new[] {"build", "--stdout", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; // using classic assert so R# understands the value is not null Assert.IsNotNull(arguments); - arguments!.InputFile.Should().Be("file1"); - arguments!.OutputToStdOut.Should().BeTrue(); - arguments!.OutputDir.Should().BeNull(); - arguments!.OutputFile.Should().BeNull(); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); } [TestMethod] public void BuildOneFileStdOutAllCaps_ShouldReturnOneFileAndStdout() { - var arguments = (BuildArguments?)ArgumentParser.TryParse(new[] {"build", "--STDOUT", "file1"}); + var arguments = ArgumentParser.TryParse(new[] {"build", "--STDOUT", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; // using classic assert so R# understands the value is not null Assert.IsNotNull(arguments); - arguments!.InputFile.Should().Be("file1"); - arguments!.OutputToStdOut.Should().BeTrue(); - arguments!.OutputDir.Should().BeNull(); - arguments!.OutputFile.Should().BeNull(); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); } [TestMethod] public void Build_with_outputdir_parameter_should_parse_correctly() { - var arguments = (BuildArguments?)ArgumentParser.TryParse(new[] {"build", "--outdir", "outdir", "file1"}); + var arguments = ArgumentParser.TryParse(new[] {"build", "--outdir", "outdir", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; // using classic assert so R# understands the value is not null Assert.IsNotNull(arguments); - arguments!.InputFile.Should().Be("file1"); - arguments!.OutputToStdOut.Should().BeFalse(); - arguments!.OutputDir.Should().Be("outdir"); - arguments!.OutputFile.Should().BeNull(); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().Be("outdir"); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); } [TestMethod] public void Build_with_outputfile_parameter_should_parse_correctly() { - var arguments = (BuildArguments?)ArgumentParser.TryParse(new[] {"build", "--outfile", "jsonFile", "file1"}); + var arguments = ArgumentParser.TryParse(new[] {"build", "--outfile", "jsonFile", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; // using classic assert so R# understands the value is not null Assert.IsNotNull(arguments); - arguments!.InputFile.Should().Be("file1"); - arguments!.OutputToStdOut.Should().BeFalse(); - arguments!.OutputDir.Should().BeNull(); - arguments!.OutputFile.Should().Be("jsonFile"); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().Be("jsonFile"); } [TestMethod] @@ -187,10 +200,71 @@ public void Help_argument_should_return_HelpShortArguments_instance() [TestMethod] public void DecompileOneFile_ShouldReturnOneFile() { - var arguments = ArgumentParser.TryParse(new[] {"decompile", "file1"}) as DecompileArguments; + var arguments = ArgumentParser.TryParse(new[] {"build", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; - arguments!.Should().NotBeNull(); - arguments!.InputFile.Should().Be("file1"); + // using classic assert so R# understands the value is not null + Assert.IsNotNull(arguments); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); + } + + [TestMethod] + public void DecompileOneFileStdOut_ShouldReturnOneFileAndStdout() + { + var arguments = ArgumentParser.TryParse(new[] {"build", "--stdout", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; + + // using classic assert so R# understands the value is not null + Assert.IsNotNull(arguments); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); + } + + [TestMethod] + public void DecompileOneFileStdOutAllCaps_ShouldReturnOneFileAndStdout() + { + var arguments = ArgumentParser.TryParse(new[] {"build", "--STDOUT", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; + + // using classic assert so R# understands the value is not null + Assert.IsNotNull(arguments); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); + } + + [TestMethod] + public void Decompile_with_outputdir_parameter_should_parse_correctly() + { + var arguments = ArgumentParser.TryParse(new[] {"build", "--outdir", "outdir", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; + + // using classic assert so R# understands the value is not null + Assert.IsNotNull(arguments); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().Be("outdir"); + bulidOrDecompileArguments!.OutputFile.Should().BeNull(); + } + + [TestMethod] + public void Decompile_with_outputfile_parameter_should_parse_correctly() + { + var arguments = ArgumentParser.TryParse(new[] {"build", "--outfile", "jsonFile", "file1"}); + var bulidOrDecompileArguments = (BuildOrDecompileArguments?) arguments; + + // using classic assert so R# understands the value is not null + Assert.IsNotNull(arguments); + bulidOrDecompileArguments!.InputFile.Should().Be("file1"); + bulidOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); + bulidOrDecompileArguments!.OutputDir.Should().BeNull(); + bulidOrDecompileArguments!.OutputFile.Should().Be("jsonFile"); } } -} \ No newline at end of file +} diff --git a/src/Bicep.Cli/CommandLine/ArgumentParser.cs b/src/Bicep.Cli/CommandLine/ArgumentParser.cs index 076cdcc32db..69c8c906c10 100644 --- a/src/Bicep.Cli/CommandLine/ArgumentParser.cs +++ b/src/Bicep.Cli/CommandLine/ArgumentParser.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System; using System.IO; -using System.Linq; using Bicep.Cli.CommandLine.Arguments; namespace Bicep.Cli.CommandLine @@ -19,12 +18,12 @@ public static class ArgumentParser // parse verb return (args[0].ToLowerInvariant()) switch { - CliConstants.CommandBuild => new BuildArguments(args[1..]), - CliConstants.CommandDecompile => new DecompileArguments(args[1..]), - CliConstants.ArgumentHelp => new HelpArguments(), - CliConstants.ArgumentHelpShort => new HelpArguments(), - CliConstants.ArgumentVersion => new VersionArguments(), - CliConstants.ArgumentVersionShort => new VersionArguments(), + CliConstants.CommandBuild => new BuildOrDecompileArguments(args[1..], CliConstants.CommandBuild), + CliConstants.CommandDecompile => new BuildOrDecompileArguments(args[1..], CliConstants.CommandDecompile), + CliConstants.ArgumentHelp => new HelpArguments(CliConstants.ArgumentHelp), + CliConstants.ArgumentHelpShort => new HelpArguments(CliConstants.ArgumentHelpShort), + CliConstants.ArgumentVersion => new VersionArguments(CliConstants.ArgumentVersion), + CliConstants.ArgumentVersionShort => new VersionArguments(CliConstants.ArgumentVersionShort), _ => null, }; } @@ -78,6 +77,17 @@ Attempts to decompile a template .json file to .bicep Arguments: The input file. + Options: + --outdir Saves the output at the specified directory. + --outfile Saves the output as the specified file path. + --stdout Prints the output to stdout. + + Examples: + bicep decompile file.json + bicep decompile file.json --stdout + bicep decompile file.json --outdir dir1 + bicep decompile file.json --outfile file.bicep + {exeName} [options] Options: --version -v Shows bicep version information diff --git a/src/Bicep.Cli/CommandLine/Arguments/ArgumentsBase.cs b/src/Bicep.Cli/CommandLine/Arguments/ArgumentsBase.cs index eab364076b3..c519df29241 100644 --- a/src/Bicep.Cli/CommandLine/Arguments/ArgumentsBase.cs +++ b/src/Bicep.Cli/CommandLine/Arguments/ArgumentsBase.cs @@ -4,5 +4,11 @@ namespace Bicep.Cli.CommandLine.Arguments { public abstract class ArgumentsBase { + public string CommandName { get; } + + protected ArgumentsBase(string commandName) + { + CommandName = commandName; + } } } diff --git a/src/Bicep.Cli/CommandLine/Arguments/BuildArguments.cs b/src/Bicep.Cli/CommandLine/Arguments/BuildOrDecompileArguments.cs similarity index 95% rename from src/Bicep.Cli/CommandLine/Arguments/BuildArguments.cs rename to src/Bicep.Cli/CommandLine/Arguments/BuildOrDecompileArguments.cs index 6d278cdc719..656b918df88 100644 --- a/src/Bicep.Cli/CommandLine/Arguments/BuildArguments.cs +++ b/src/Bicep.Cli/CommandLine/Arguments/BuildOrDecompileArguments.cs @@ -3,9 +3,9 @@ namespace Bicep.Cli.CommandLine.Arguments { - public class BuildArguments : ArgumentsBase + public class BuildOrDecompileArguments : ArgumentsBase { - public BuildArguments(string[] args) + public BuildOrDecompileArguments(string[] args, string commandName) : base(commandName) { for (var i = 0; i < args.Length; i++) { @@ -80,4 +80,4 @@ public BuildArguments(string[] args) public string? OutputFile { get; } } -} \ No newline at end of file +} diff --git a/src/Bicep.Cli/CommandLine/Arguments/DecompileArguments.cs b/src/Bicep.Cli/CommandLine/Arguments/DecompileArguments.cs deleted file mode 100644 index 32060521733..00000000000 --- a/src/Bicep.Cli/CommandLine/Arguments/DecompileArguments.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Bicep.Cli.CommandLine.Arguments -{ - public class DecompileArguments : ArgumentsBase - { - public DecompileArguments(string[] args) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) { - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (this.InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - this.InputFile = args[i]; - break; - } - } - - if (this.InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - } - - public string InputFile { get; } - } -} \ No newline at end of file diff --git a/src/Bicep.Cli/CommandLine/Arguments/HelpArguments.cs b/src/Bicep.Cli/CommandLine/Arguments/HelpArguments.cs index dbcd7088a04..e4b388c8921 100644 --- a/src/Bicep.Cli/CommandLine/Arguments/HelpArguments.cs +++ b/src/Bicep.Cli/CommandLine/Arguments/HelpArguments.cs @@ -4,5 +4,8 @@ namespace Bicep.Cli.CommandLine.Arguments { public class HelpArguments : ArgumentsBase { + public HelpArguments(string commandName) : base(commandName) + { + } } } diff --git a/src/Bicep.Cli/CommandLine/Arguments/VersionArguments.cs b/src/Bicep.Cli/CommandLine/Arguments/VersionArguments.cs index 58aac2cd1c8..2e0b7101102 100644 --- a/src/Bicep.Cli/CommandLine/Arguments/VersionArguments.cs +++ b/src/Bicep.Cli/CommandLine/Arguments/VersionArguments.cs @@ -4,5 +4,8 @@ namespace Bicep.Cli.CommandLine.Arguments { public class VersionArguments : ArgumentsBase { + public VersionArguments(string commandName) : base(commandName) + { + } } } diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 6d98ee13b37..26adb8247b6 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -56,9 +56,9 @@ public int Run(string[] args) { switch (ArgumentParser.TryParse(args)) { - case BuildArguments buildArguments: // build + case BuildOrDecompileArguments buildArguments when buildArguments.CommandName == CliConstants.CommandBuild: // build return Build(logger, buildArguments); - case DecompileArguments decompileArguments: + case BuildOrDecompileArguments decompileArguments when decompileArguments.CommandName == CliConstants.CommandDecompile: // decompile return Decompile(logger, decompileArguments); case VersionArguments _: // --version ArgumentParser.PrintVersion(this.outputWriter); @@ -100,7 +100,7 @@ private ILoggerFactory CreateLoggerFactory() }); } - private int Build(ILogger logger, BuildArguments arguments) + private int Build(ILogger logger, BuildOrDecompileArguments arguments) { var diagnosticLogger = new BicepDiagnosticLogger(logger); var bicepPath = PathHelper.ResolvePath(arguments.InputFile); @@ -119,7 +119,7 @@ private int Build(ILogger logger, BuildArguments arguments) var outputPath = Path.Combine(outputDir, Path.GetFileName(bicepPath)); - BuildToFile(diagnosticLogger, bicepPath, PathHelper.GetDefaultOutputPath(outputPath)); + BuildToFile(diagnosticLogger, bicepPath, PathHelper.GetDefaultBuildOutputPath(outputPath)); } else if (arguments.OutputFile is not null) { @@ -127,7 +127,7 @@ private int Build(ILogger logger, BuildArguments arguments) } else { - BuildToFile(diagnosticLogger, bicepPath, PathHelper.GetDefaultOutputPath(bicepPath)); + BuildToFile(diagnosticLogger, bicepPath, PathHelper.GetDefaultBuildOutputPath(bicepPath)); } // return non-zero exit code on errors @@ -183,6 +183,59 @@ private void BuildToStdout(IDiagnosticLogger logger, string bicepPath) } } + private int DecompileToFile(IDiagnosticLogger logger, string jsonPath, string outputPath) + { + try + { + var (_, filesToSave) = TemplateDecompiler.DecompileFileWithModules(resourceTypeProvider, new FileResolver(), PathHelper.FilePathToFileUrl(jsonPath)); + foreach (var (_, bicepOutput) in filesToSave) + { + File.WriteAllText(outputPath, bicepOutput); + } + + var outputPathToCheck = Path.GetFullPath(outputPath); + var syntaxTreeGrouping = SyntaxTreeGroupingBuilder.Build(new FileResolver(), new Workspace(), PathHelper.FilePathToFileUrl(outputPathToCheck)); + var compilation = new Compilation(resourceTypeProvider, syntaxTreeGrouping); + + return LogDiagnosticsAndCheckSuccess(logger, compilation) ? 0 : 1; + } + catch (Exception exception) + { + this.errorWriter.WriteLine($"{jsonPath}: Decompilation failed with fatal error \"{exception.Message}\""); + return 1; + } + } + + private int DecompileToStdout(IDiagnosticLogger logger, string jsonPath) + { + var tempOutputPath = Path.ChangeExtension(Path.GetTempFileName(), "bicep"); + try + { + var (_, filesToSave) = TemplateDecompiler.DecompileFileWithModules(resourceTypeProvider, new FileResolver(), PathHelper.FilePathToFileUrl(jsonPath)); + foreach (var (_, bicepOutput) in filesToSave) + { + this.outputWriter.Write(bicepOutput); + File.WriteAllText(tempOutputPath, bicepOutput); + } + + var syntaxTreeGrouping = SyntaxTreeGroupingBuilder.Build(new FileResolver(), new Workspace(), PathHelper.FilePathToFileUrl(tempOutputPath)); + var compilation = new Compilation(resourceTypeProvider, syntaxTreeGrouping); + + return LogDiagnosticsAndCheckSuccess(logger, compilation) ? 0 : 1; + } + catch (Exception exception) + { + this.errorWriter.WriteLine($"{jsonPath}: Decompilation failed with fatal error \"{exception.Message}\""); + return 1; + } finally + { + if (File.Exists(tempOutputPath)) + { + File.Delete(tempOutputPath); + } + } + } + private static FileStream CreateFileStream(string path) { try @@ -195,7 +248,7 @@ private static FileStream CreateFileStream(string path) } } - public int Decompile(ILogger logger, DecompileArguments arguments) + public int Decompile(ILogger logger, BuildOrDecompileArguments arguments) { logger.LogWarning( "WARNING: Decompilation is a best-effort process, as there is no guaranteed mapping from ARM JSON to Bicep.\n" + @@ -205,23 +258,29 @@ public int Decompile(ILogger logger, DecompileArguments arguments) var diagnosticLogger = new BicepDiagnosticLogger(logger); var jsonPath = PathHelper.ResolvePath(arguments.InputFile); - try + if (arguments.OutputToStdOut) { - var (bicepUri, filesToSave) = TemplateDecompiler.DecompileFileWithModules(resourceTypeProvider, new FileResolver(), PathHelper.FilePathToFileUrl(jsonPath)); - foreach (var (fileUri, bicepOutput) in filesToSave) + return DecompileToStdout(diagnosticLogger, jsonPath); + } + else if (arguments.OutputDir is not null) + { + var outputDir = PathHelper.ResolvePath(arguments.OutputDir); + if (!Directory.Exists(outputDir)) { - File.WriteAllText(fileUri.LocalPath, bicepOutput); + throw new CommandLineException($"The specified output directory \"{outputDir}\" does not exist."); } - var syntaxTreeGrouping = SyntaxTreeGroupingBuilder.Build(new FileResolver(), new Workspace(), bicepUri); - var compilation = new Compilation(resourceTypeProvider, syntaxTreeGrouping); + var outputPath = Path.Combine(outputDir, Path.GetFileName(jsonPath)); - return LogDiagnosticsAndCheckSuccess(diagnosticLogger, compilation) ? 0 : 1; + return DecompileToFile(diagnosticLogger, jsonPath, PathHelper.GetDefaultDecompileOutputPath(outputPath)); } - catch (Exception exception) + else if (arguments.OutputFile is not null) { - this.errorWriter.WriteLine($"{jsonPath}: Decompilation failed with fatal error \"{exception.Message}\""); - return 1; + return DecompileToFile(diagnosticLogger, jsonPath, arguments.OutputFile); + } + else + { + return DecompileToFile(diagnosticLogger, jsonPath, PathHelper.GetDefaultDecompileOutputPath(jsonPath)); } } } diff --git a/src/Bicep.Core.UnitTests/FileSystem/PathHelperTests.cs b/src/Bicep.Core.UnitTests/FileSystem/PathHelperTests.cs index 44457481ea5..9efce414a2a 100644 --- a/src/Bicep.Core.UnitTests/FileSystem/PathHelperTests.cs +++ b/src/Bicep.Core.UnitTests/FileSystem/PathHelperTests.cs @@ -26,11 +26,19 @@ public void LinuxFileSystem_ShouldBeCaseSensitive() [DataTestMethod] [DataRow("foo.json")] - public void GetOutputPath_ShouldThrowOnJsonExtensions_Linux(string path) + public void GetBuildOutputPath_ShouldThrowOnJsonExtensions_Linux(string path) { - Action badExtension = () => PathHelper.GetDefaultOutputPath(path); + Action badExtension = () => PathHelper.GetDefaultBuildOutputPath(path); badExtension.Should().Throw().WithMessage("The specified file already already has the '.json' extension."); } + + [DataTestMethod] + [DataRow("foo.bicep")] + public void GetDecompileOutputPath_ShouldThrowOnBicepExtensions_Linux(string path) + { + Action badExtension = () => PathHelper.GetDefaultDecompileOutputPath(path); + badExtension.Should().Throw().WithMessage("The specified file already already has the '.bicep' extension."); + } #else [TestMethod] public void WindowsAndMacFileSystem_ShouldBeCaseInsensitive() @@ -43,11 +51,21 @@ public void WindowsAndMacFileSystem_ShouldBeCaseInsensitive() [DataRow("foo.json")] [DataRow("foo.JSON")] [DataRow("foo.JsOn")] - public void GetOutputPath_ShouldThrowOnJsonExtensions_WindowsAndMac(string path) + public void GetBuildOutputPath_ShouldThrowOnJsonExtensions_WindowsAndMac(string path) { - Action badExtension = () => PathHelper.GetDefaultOutputPath(path); + Action badExtension = () => PathHelper.GetDefaultBuildOutputPath(path); badExtension.Should().Throw().WithMessage("The specified file already already has the '.json' extension."); } + + [DataTestMethod] + [DataRow("foo.bicep")] + [DataRow("foo.BICEP")] + [DataRow("foo.BiCeP")] + public void GetDecompileOutputPath_ShouldThrowOnBicepExtensions_WindowsAndMac(string path) + { + Action badExtension = () => PathHelper.GetDefaultDecompileOutputPath(path); + badExtension.Should().Throw().WithMessage("The specified file already already has the '.bicep' extension."); + } #endif [DataTestMethod] @@ -58,10 +76,17 @@ public void ResolvePath_ShouldResolveCorrectly(string path, string expectedPath) } [DataTestMethod] - [DynamicData(nameof(GetOutputPathData), DynamicDataSourceType.Method, DynamicDataDisplayName = nameof(GetDisplayName))] - public void GetOutputPath_ShouldChangeExtensionCorrectly(string path, string expectedPath) + [DynamicData(nameof(GetBuildOutputPathData), DynamicDataSourceType.Method, DynamicDataDisplayName = nameof(GetDisplayName))] + public void GetDefaultBuildOutputPath_ShouldChangeExtensionCorrectly(string path, string expectedPath) + { + PathHelper.GetDefaultBuildOutputPath(path).Should().Be(expectedPath); + } + + [DataTestMethod] + [DynamicData(nameof(GetDecompileOutputPathData), DynamicDataSourceType.Method, DynamicDataDisplayName = nameof(GetDisplayName))] + public void GetDefaultDecompileOutputPath_ShouldChangeExtensionCorrectly(string path, string expectedPath) { - PathHelper.GetDefaultOutputPath(path).Should().Be(expectedPath); + PathHelper.GetDefaultDecompileOutputPath(path).Should().Be(expectedPath); } public static string GetDisplayName(MethodInfo info, object[] row) @@ -106,7 +131,7 @@ private static IEnumerable GetResolvePathData() #endif } - private static IEnumerable GetOutputPathData() + private static IEnumerable GetBuildOutputPathData() { yield return CreateRow(@"foo.bicep", @"foo.json"); @@ -124,6 +149,24 @@ private static IEnumerable GetOutputPathData() #endif } + private static IEnumerable GetDecompileOutputPathData() + { + yield return CreateRow(@"foo.json", @"foo.bicep"); + +#if LINUX_BUILD + yield return CreateRow(@"/lib/bar/foo.json", @"/lib/bar/foo.bicep"); + + // these will throw on Windows + yield return CreateRow(@"/lib/bar/foo.BICEP", @"/lib/bar/foo.bicep"); + yield return CreateRow(@"/bar/foo.bIcEp", @"/bar/foo.bicep"); +#else + yield return CreateRow(@"C:\foo.json", @"C:\foo.bicep"); + yield return CreateRow(@"D:\a\b\c\foo.json", @"D:\a\b\c\foo.bicep"); + + yield return CreateRow(@"/foo.json", @"/foo.bicep"); +#endif + } + private static object[] CreateRow(string input, string expectedOutput) => new object[] {input, expectedOutput}; } } diff --git a/src/Bicep.Core/FileSystem/PathHelper.cs b/src/Bicep.Core/FileSystem/PathHelper.cs index 13c2843366b..f903211f5f6 100644 --- a/src/Bicep.Core/FileSystem/PathHelper.cs +++ b/src/Bicep.Core/FileSystem/PathHelper.cs @@ -46,7 +46,7 @@ public static string ResolveAndNormalizePath(string path, string? baseDirectory return Path.GetFullPath(resolvedPath); } - public static string GetDefaultOutputPath(string path) + public static string GetDefaultBuildOutputPath(string path) { if (string.Equals(Path.GetExtension(path), TemplateOutputExtension, PathComparison)) { @@ -57,6 +57,22 @@ public static string GetDefaultOutputPath(string path) return Path.ChangeExtension(path, TemplateOutputExtension); } + /// + /// Returns a normalized absolute path. Relative paths are converted to absolute paths relative to current directory prior to normalization. + /// + /// The path. + /// The base directory to use when resolving relative paths. Set to null to use CWD. + public static string GetDefaultDecompileOutputPath(string path) + { + if (string.Equals(Path.GetExtension(path), BicepExtension, PathComparison)) + { + // throwing because this could lead to us destroying the input file if extensions get mixed up. + throw new ArgumentException($"The specified file already already has the '{BicepExtension}' extension."); + } + + return Path.ChangeExtension(path, BicepExtension); + } + /// /// Returns true if the current file system is case sensitive (most Linux and MacOS X file systems). Returns false if the file system is case insensitive (Windows file systems.) ///