diff --git a/src/DotNetApiDiff/Commands/CompareCommand.cs b/src/DotNetApiDiff/Commands/CompareCommand.cs index 0a420a1..bae5e93 100644 --- a/src/DotNetApiDiff/Commands/CompareCommand.cs +++ b/src/DotNetApiDiff/Commands/CompareCommand.cs @@ -32,8 +32,7 @@ public class CompareCommandSettings : CommandSettings [CommandOption("-o|--output ")] [Description("Output format (console, json, html, markdown)")] - [DefaultValue("console")] - public string OutputFormat { get; set; } = "console"; + public string? OutputFormat { get; set; } [CommandOption("-p|--output-file ")] [Description("Output file path (required for json, html, markdown formats)")] @@ -137,27 +136,30 @@ public override ValidationResult Validate([NotNull] CommandContext context, [Not return ValidationResult.Error($"Configuration file not found: {settings.ConfigFile}"); } - // Validate output format - string format = settings.OutputFormat.ToLowerInvariant(); - if (format != "console" && format != "json" && format != "html" && format != "markdown") + // Validate output format if provided + if (!string.IsNullOrEmpty(settings.OutputFormat)) { - return ValidationResult.Error($"Invalid output format: {settings.OutputFormat}. Valid formats are: console, json, html, markdown"); - } - - // Validate output file requirements - if (format == "html") - { - // HTML format requires an output file - if (string.IsNullOrEmpty(settings.OutputFile)) + string format = settings.OutputFormat.ToLowerInvariant(); + if (format != "console" && format != "json" && format != "html" && format != "markdown") { - return ValidationResult.Error($"Output file is required for {settings.OutputFormat} format. Use --output-file to specify the output file path."); + return ValidationResult.Error($"Invalid output format: {settings.OutputFormat}. Valid formats are: console, json, html, markdown"); } - // Validate output directory exists - var outputDir = Path.GetDirectoryName(settings.OutputFile); - if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + // Validate output file requirements + if (format == "html") { - return ValidationResult.Error($"Output directory does not exist: {outputDir}"); + // HTML format requires an output file + if (string.IsNullOrEmpty(settings.OutputFile)) + { + return ValidationResult.Error($"Output file is required for {settings.OutputFormat} format. Use --output-file to specify the output file path."); + } + + // Validate output directory exists + var outputDir = Path.GetDirectoryName(settings.OutputFile); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + return ValidationResult.Error($"Output directory does not exist: {outputDir}"); + } } } else if (!string.IsNullOrEmpty(settings.OutputFile)) @@ -369,8 +371,8 @@ private int ExecuteWithConfiguredServices(CompareCommandSettings settings, Compa // Use the single CompareAssemblies method - configuration is now injected into dependencies comparisonResult = apiComparer.CompareAssemblies(sourceAssembly, targetAssembly); - // Update configuration with actual command-line values used for this comparison - if (Enum.TryParse(settings.OutputFormat, true, out var outputFormat)) + // Update configuration with actual command-line values ONLY if explicitly provided by user + if (!string.IsNullOrEmpty(settings.OutputFormat) && Enum.TryParse(settings.OutputFormat, true, out var outputFormat)) { comparisonResult.Configuration.OutputFormat = outputFormat; } @@ -395,26 +397,20 @@ private int ExecuteWithConfiguredServices(CompareCommandSettings settings, Compa // Generate report using (_logger.BeginScope("Report generation")) { - _logger.LogInformation("Generating {Format} report", settings.OutputFormat); - var reportGenerator = serviceProvider.GetRequiredService(); + // Use the configuration from the comparison result, which now has the correct precedence applied + var effectiveFormat = comparisonResult.Configuration.OutputFormat; + var effectiveOutputFile = comparisonResult.Configuration.OutputPath; - // Convert string format to ReportFormat enum - ReportFormat format = settings.OutputFormat.ToLowerInvariant() switch - { - "json" => ReportFormat.Json, - "xml" => ReportFormat.Xml, - "html" => ReportFormat.Html, - "markdown" => ReportFormat.Markdown, - _ => ReportFormat.Console - }; + _logger.LogInformation("Generating {Format} report", effectiveFormat); + var reportGenerator = serviceProvider.GetRequiredService(); string report; try { - if (string.IsNullOrEmpty(settings.OutputFile)) + if (string.IsNullOrEmpty(effectiveOutputFile)) { // No output file specified - output to console regardless of format - report = reportGenerator.GenerateReport(comparisonResult, format); + report = reportGenerator.GenerateReport(comparisonResult, effectiveFormat); // Output the formatted report to the console // Use Console.Write to avoid format string interpretation issues @@ -423,13 +419,13 @@ private int ExecuteWithConfiguredServices(CompareCommandSettings settings, Compa else { // Output file specified - save to the specified file - reportGenerator.SaveReportAsync(comparisonResult, format, settings.OutputFile).GetAwaiter().GetResult(); - _logger.LogInformation("Report saved to {OutputFile}", settings.OutputFile); + reportGenerator.SaveReportAsync(comparisonResult, effectiveFormat, effectiveOutputFile).GetAwaiter().GetResult(); + _logger.LogInformation("Report saved to {OutputFile}", effectiveOutputFile); } } catch (Exception ex) { - _logger.LogError(ex, "Error generating {Format} report", format); + _logger.LogError(ex, "Error generating {Format} report", effectiveFormat); AnsiConsole.MarkupLine($"[red]Error generating report:[/] {ex.Message}"); return _exitCodeManager.GetExitCodeForException(ex); diff --git a/src/DotNetApiDiff/Models/Configuration/ComparisonConfiguration.cs b/src/DotNetApiDiff/Models/Configuration/ComparisonConfiguration.cs index 69b69d3..f6743d4 100644 --- a/src/DotNetApiDiff/Models/Configuration/ComparisonConfiguration.cs +++ b/src/DotNetApiDiff/Models/Configuration/ComparisonConfiguration.cs @@ -90,7 +90,7 @@ public static ComparisonConfiguration LoadFromJsonFile(string path) PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, - Converters = { new JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter(null, false) } }; var config = JsonSerializer.Deserialize(json, options); @@ -129,7 +129,8 @@ public void SaveToJsonFile(string path) var options = new JsonSerializerOptions { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(null, false) } }; var json = JsonSerializer.Serialize(this, options); diff --git a/tests/DotNetApiDiff.Tests/Integration/CliWorkflowTests.cs b/tests/DotNetApiDiff.Tests/Integration/CliWorkflowTests.cs index 4ecadec..1a5edfd 100644 --- a/tests/DotNetApiDiff.Tests/Integration/CliWorkflowTests.cs +++ b/tests/DotNetApiDiff.Tests/Integration/CliWorkflowTests.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Text; +using System.Text.Json; using Xunit; using Xunit.Abstractions; @@ -167,6 +168,84 @@ public void CliWorkflow_WithConfigFile_ShouldApplyConfiguration() Assert.False(string.IsNullOrEmpty(result.StandardOutput), "Should produce JSON output"); } + [Fact] + public void CliWorkflow_WithConfigFileOutputSettings_ShouldUseConfigurationValues() + { + // Arrange + var sourceAssembly = Path.Combine(_testDataPath, "TestAssemblyV1.dll"); + var targetAssembly = Path.Combine(_testDataPath, "TestAssemblyV2.dll"); + var configFile = Path.Combine(_testDataPath, "config-output-format-and-path.json"); + var expectedOutputFile = Path.Combine(_tempOutputPath, "comparison-report.html"); + + // Fail test if prerequisites are not met + Assert.NotNull(_executablePath); + Assert.True(File.Exists(sourceAssembly), $"Source test assembly not found: {sourceAssembly}"); + Assert.True(File.Exists(targetAssembly), $"Target test assembly not found: {targetAssembly}"); + Assert.True(File.Exists(configFile), $"Config file not found: {configFile}"); + + // Update the config file to use our temp output path + var configContent = File.ReadAllText(configFile); + // Escape backslashes for JSON format on Windows + var escapedOutputPath = expectedOutputFile.Replace("\\", "\\\\"); + var configWithTempPath = configContent.Replace("comparison-report.html", escapedOutputPath); + var tempConfigFile = Path.Combine(_tempOutputPath, "temp-config.json"); + File.WriteAllText(tempConfigFile, configWithTempPath); + + // Execute with config file but NO command line output options + var arguments = $"compare \"{sourceAssembly}\" \"{targetAssembly}\" --config \"{tempConfigFile}\""; + + // Act + var result = RunCliCommand(arguments); + + // Assert + // Exit code 2 is expected when there are breaking changes, which is normal for our test assemblies + Assert.True(result.ExitCode == 0 || result.ExitCode == 2, $"CLI should execute successfully with config output settings. Exit code: {result.ExitCode}"); + + // The output file should have been created (this tests that OutputPath from config was used) + Assert.True(File.Exists(expectedOutputFile), $"Output file should have been created at: {expectedOutputFile}"); + + // The output file should contain HTML content (this tests that OutputFormat from config was used) + var outputContent = File.ReadAllText(expectedOutputFile); + Assert.Contains("", outputContent, StringComparison.OrdinalIgnoreCase); + Assert.Contains("API Comparison Report", outputContent); + } + + [Fact] + public void CliWorkflow_CommandLineOverridesConfigOutputSettings_ShouldUseCommandLineValues() + { + // Arrange + var sourceAssembly = Path.Combine(_testDataPath, "TestAssemblyV1.dll"); + var targetAssembly = Path.Combine(_testDataPath, "TestAssemblyV2.dll"); + var configFile = Path.Combine(_testDataPath, "config-output-format-and-path.json"); + var commandLineOutputFile = Path.Combine(_tempOutputPath, "command-line-output.json"); + + // Fail test if prerequisites are not met + Assert.NotNull(_executablePath); + Assert.True(File.Exists(sourceAssembly), $"Source test assembly not found: {sourceAssembly}"); + Assert.True(File.Exists(targetAssembly), $"Target test assembly not found: {targetAssembly}"); + Assert.True(File.Exists(configFile), $"Config file not found: {configFile}"); + + // Execute with config file AND command line output options (command line should override config) + var arguments = $"compare \"{sourceAssembly}\" \"{targetAssembly}\" --config \"{configFile}\" --output json --output-file \"{commandLineOutputFile}\""; + + // Act + var result = RunCliCommand(arguments); + + // Assert + // Exit code 2 is expected when there are breaking changes, which is normal for our test assemblies + Assert.True(result.ExitCode == 0 || result.ExitCode == 2, $"CLI should execute successfully with command line override. Exit code: {result.ExitCode}"); + + // The command line output file should have been created (not the config one) + Assert.True(File.Exists(commandLineOutputFile), $"Command line output file should have been created at: {commandLineOutputFile}"); + + // The output file should contain JSON content (command line format should override config HTML format) + var outputContent = File.ReadAllText(commandLineOutputFile); + Assert.Contains("{", outputContent); + Assert.Contains("}", outputContent); + Assert.DoesNotContain("", outputContent, StringComparison.OrdinalIgnoreCase); + Assert.Contains("API Comparison Report", outputContent); + + // Verify the logs show the correct format was read from config and used + var combinedOutput = result.StandardOutput + result.StandardError; + Assert.Contains("Configuration loaded successfully", combinedOutput); + Assert.Contains("Generating Html report", combinedOutput); + Assert.Contains("Report saved successfully", combinedOutput); + } + + [Fact] + public void CliWorkflow_CommandLineOverridesConfig_ShouldUseExplicitCommandLineFormat() + { + // Arrange + var sourceAssembly = Path.Combine(_testDataPath, "TestAssemblyV1.dll"); + var targetAssembly = Path.Combine(_testDataPath, "TestAssemblyV2.dll"); + var outputFile = Path.Combine(_tempOutputPath, "override-test-output.json"); + + // Fail test if prerequisites are not met + Assert.NotNull(_executablePath); + Assert.True(File.Exists(sourceAssembly), $"Source test assembly not found: {sourceAssembly}"); + Assert.True(File.Exists(targetAssembly), $"Target test assembly not found: {targetAssembly}"); + + // Create config with Html format + var testConfigJson = $$""" + { + "filters": { + "includeNamespaces": [], + "excludeNamespaces": [], + "includeTypes": [], + "excludeTypes": [], + "includeInternals": false, + "includeCompilerGenerated": false + }, + "mappings": { + "namespaceMappings": {}, + "typeMappings": {}, + "autoMapSameNameTypes": true, + "ignoreCase": false + }, + "exclusions": { + "excludedTypes": [], + "excludedMembers": [], + "excludedTypePatterns": [], + "excludedMemberPatterns": [] + }, + "breakingChangeRules": { + "treatTypeRemovalAsBreaking": true, + "treatMemberRemovalAsBreaking": true, + "treatAddedTypeAsBreaking": false, + "treatAddedMemberAsBreaking": false, + "treatSignatureChangeAsBreaking": true + }, + "outputFormat": "Html", + "outputPath": "{{outputFile.Replace("\\", "\\\\")}}", + "failOnBreakingChanges": false + } + """; + + var testConfigFile = Path.Combine(_tempOutputPath, "override-test-config.json"); + File.WriteAllText(testConfigFile, testConfigJson); + + // Execute with config file that has Html format, but override with Json format on command line + var arguments = $"compare \"{sourceAssembly}\" \"{targetAssembly}\" --config \"{testConfigFile}\" --output json"; + + // Act + var result = RunCliCommand(arguments); + + // Assert + // Exit code 2 is expected when there are breaking changes + Assert.True(result.ExitCode == 0 || result.ExitCode == 2, $"CLI should execute successfully. Exit code: {result.ExitCode}"); + + // Verify the output file was created + Assert.True(File.Exists(outputFile), $"Output file should have been created at: {outputFile}"); + + // Verify JSON content was generated (command line Json overrode config Html) + var outputContent = File.ReadAllText(outputFile); + try + { + JsonDocument.Parse(outputContent); // Should not throw if valid JSON + } + catch (JsonException) + { + Assert.Fail("Output should be valid JSON when Json format is specified"); + } + + // Verify the logs show Json format was used (command line override) + var combinedOutput = result.StandardOutput + result.StandardError; + Assert.Contains("Configuration loaded successfully", combinedOutput); + Assert.Contains("Generating Json report", combinedOutput); + Assert.Contains("Report saved successfully", combinedOutput); + } + public void Dispose() { // Clean up temporary files diff --git a/tests/DotNetApiDiff.Tests/TestData/config-output-format-and-path.json b/tests/DotNetApiDiff.Tests/TestData/config-output-format-and-path.json new file mode 100644 index 0000000..bcdcbee --- /dev/null +++ b/tests/DotNetApiDiff.Tests/TestData/config-output-format-and-path.json @@ -0,0 +1,32 @@ +{ + "filters": { + "includeNamespaces": [], + "excludeNamespaces": [], + "includeTypes": [], + "excludeTypes": [], + "includeInternals": false, + "includeCompilerGenerated": false + }, + "mappings": { + "namespaceMappings": {}, + "typeMappings": {}, + "autoMapSameNameTypes": true, + "ignoreCase": false + }, + "exclusions": { + "excludedTypes": [], + "excludedMembers": [], + "excludedTypePatterns": [], + "excludedMemberPatterns": [] + }, + "breakingChangeRules": { + "treatTypeRemovalAsBreaking": true, + "treatMemberRemovalAsBreaking": true, + "treatAddedTypeAsBreaking": false, + "treatAddedMemberAsBreaking": false, + "treatSignatureChangeAsBreaking": true + }, + "outputFormat": "html", + "outputPath": "comparison-report.html", + "failOnBreakingChanges": true +} diff --git a/tests/DotNetApiDiff.Tests/Unit/JsonEnumSerializationTests.cs b/tests/DotNetApiDiff.Tests/Unit/JsonEnumSerializationTests.cs new file mode 100644 index 0000000..19091f3 --- /dev/null +++ b/tests/DotNetApiDiff.Tests/Unit/JsonEnumSerializationTests.cs @@ -0,0 +1,133 @@ +// Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT +using System.Text.Json; +using System.Text.Json.Serialization; +using DotNetApiDiff.Models; +using DotNetApiDiff.Models.Configuration; +using Xunit; + +namespace DotNetApiDiff.Tests.Unit; + +/// +/// Tests for JSON enum serialization/deserialization +/// +public class JsonEnumSerializationTests +{ + [Fact] + public void ComparisonConfiguration_JsonDeserialization_ShouldParseEnumCorrectly() + { + // Arrange + var json = """ + { + "outputFormat": "Html", + "outputPath": "test.html" + } + """; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter(null, false) } + }; + + // Act + var config = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(config); + Assert.Equal(ReportFormat.Html, config.OutputFormat); + Assert.Equal("test.html", config.OutputPath); + } + + [Theory] + [InlineData("\"Html\"", ReportFormat.Html)] + [InlineData("\"html\"", ReportFormat.Html)] + [InlineData("\"HTML\"", ReportFormat.Html)] + [InlineData("\"Console\"", ReportFormat.Console)] + [InlineData("\"console\"", ReportFormat.Console)] + [InlineData("\"Json\"", ReportFormat.Json)] + [InlineData("\"json\"", ReportFormat.Json)] + public void ReportFormat_JsonDeserialization_ShouldHandleCaseVariations(string jsonValue, ReportFormat expectedFormat) + { + // Arrange + var json = $"{{\"outputFormat\": {jsonValue}}}"; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter(null, false) } + }; + + // Act + var config = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(config); + Assert.Equal(expectedFormat, config.OutputFormat); + } + + [Fact] + public void ComparisonConfiguration_LoadFromJsonFile_ShouldWorkWithTestData() + { + // Arrange + var tempFile = Path.GetTempFileName(); + var json = """ + { + "filters": { + "includeNamespaces": [], + "excludeNamespaces": [], + "includeTypes": [], + "excludeTypes": [], + "includeInternals": false, + "includeCompilerGenerated": false + }, + "mappings": { + "namespaceMappings": {}, + "typeMappings": {}, + "autoMapSameNameTypes": true, + "ignoreCase": false + }, + "exclusions": { + "excludedTypes": [], + "excludedMembers": [], + "excludedTypePatterns": [], + "excludedMemberPatterns": [] + }, + "breakingChangeRules": { + "treatTypeRemovalAsBreaking": true, + "treatMemberRemovalAsBreaking": true, + "treatAddedTypeAsBreaking": false, + "treatAddedMemberAsBreaking": false, + "treatSignatureChangeAsBreaking": true + }, + "outputFormat": "Html", + "outputPath": "comparison-report.html", + "failOnBreakingChanges": false + } + """; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ComparisonConfiguration.LoadFromJsonFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.Equal(ReportFormat.Html, config.OutputFormat); + Assert.Equal("comparison-report.html", config.OutputPath); + Assert.False(config.FailOnBreakingChanges); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +}