From 46e2d5b66fc199ace9e9a773e2f6f4a5c0b6af4d Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:43:53 +0200 Subject: [PATCH 1/8] Handle settings deserialization and output path adjustments - Add support for deserializing settings file as the primary source of truth, with CLI arguments overriding specific values. - Ensure output paths are rooted relative to the settings file when applicable. - Apply defaults for `OutputFolder` and `OutputFilename` when absent in the settings file. --- src/Refitter/GenerateCommand.cs | 70 +++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index 80ff55361..7ca9a85f5 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -37,7 +37,26 @@ protected override ValidationResult Validate(CommandContext context, Settings se protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { - var refitGeneratorSettings = CreateRefitGeneratorSettings(settings); + RefitGeneratorSettings refitGeneratorSettings; + + // When settings file is provided, deserialize it first and use as source of truth + if (!string.IsNullOrWhiteSpace(settings.SettingsFilePath)) + { + var json = await File.ReadAllTextAsync(settings.SettingsFilePath); + refitGeneratorSettings = Serializer.Deserialize(json); + + // Allow CLI to override OpenApiPath if explicitly provided + if (!string.IsNullOrWhiteSpace(settings.OpenApiPath)) + refitGeneratorSettings.OpenApiPath = settings.OpenApiPath; + + ApplySettingsFileDefaults(settings.SettingsFilePath, refitGeneratorSettings); + } + else + { + // No settings file - build from CLI arguments + refitGeneratorSettings = CreateRefitGeneratorSettings(settings); + } + try { var stopwatch = Stopwatch.StartNew(); @@ -86,16 +105,6 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 0; } - if (!string.IsNullOrWhiteSpace(settings.SettingsFilePath)) - { - var json = await File.ReadAllTextAsync(settings.SettingsFilePath); - refitGeneratorSettings = Serializer.Deserialize(json); - refitGeneratorSettings.OpenApiPath = settings.OpenApiPath!; - - if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder)) - refitGeneratorSettings.GenerateMultipleFiles = true; - } - var generator = await RefitGenerator.CreateAsync(refitGeneratorSettings); if (!settings.SkipValidation) await ValidateOpenApiSpec(refitGeneratorSettings.OpenApiPath, settings); @@ -639,20 +648,50 @@ private static void SimpleDonationBanner() private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) { + // Determine root directory based on settings file location + var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) + ? string.Empty + : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; + var outputPath = settings.OutputPath != Settings.DefaultOutputPath && !string.IsNullOrWhiteSpace(settings.OutputPath) ? settings.OutputPath : refitGeneratorSettings.OutputFilename ?? "Output.cs"; - if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder) && - refitGeneratorSettings.OutputFolder != RefitGeneratorSettings.DefaultOutputFolder) + if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder)) { outputPath = Path.Combine(refitGeneratorSettings.OutputFolder, outputPath); } + // Root the output path relative to settings file location if applicable + if (!string.IsNullOrWhiteSpace(root)) + { + outputPath = Path.Combine(root, outputPath); + } + return outputPath; } + private static void ApplySettingsFileDefaults(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) + { + // Re-apply multi-file trigger logic + if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder)) + refitGeneratorSettings.GenerateMultipleFiles = true; + + // Default outputFolder to ./Generated if not specified (property initializer not invoked by JSON deserialization) + if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder)) + { + refitGeneratorSettings.OutputFolder = RefitGeneratorSettings.DefaultOutputFolder; + } + + // Default outputFilename to .refitter filename if not specified + if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFilename)) + { + var refitterFileName = Path.GetFileNameWithoutExtension(settingsFilePath); + refitGeneratorSettings.OutputFilename = $"{refitterFileName}.cs"; + } + } + private string GetOutputPath( Settings settings, RefitGeneratorSettings refitGeneratorSettings, @@ -662,15 +701,14 @@ private string GetOutputPath( ? string.Empty : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder) && - refitGeneratorSettings.OutputFolder != RefitGeneratorSettings.DefaultOutputFolder) + if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder)) { return Path.Combine(root, refitGeneratorSettings.OutputFolder, outputFile.Filename); } return !string.IsNullOrWhiteSpace(settings.OutputPath) && settings.OutputPath != Settings.DefaultOutputPath ? Path.Combine(root, settings.OutputPath, outputFile.Filename) - : outputFile.Filename; + : Path.Combine(root, outputFile.Filename); } private static async Task ValidateOpenApiSpec(string openApiPath, Settings settings) { From 9122cd1460264e1949e69e8ebf685849da03e2d6 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:46:33 +0200 Subject: [PATCH 2/8] Regression tests for issue #998 - output path resolution with settings files --- src/Refitter.Tests/GenerateCommandTests.cs | 142 +++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/Refitter.Tests/GenerateCommandTests.cs b/src/Refitter.Tests/GenerateCommandTests.cs index eaf039f86..19c463fa7 100644 --- a/src/Refitter.Tests/GenerateCommandTests.cs +++ b/src/Refitter.Tests/GenerateCommandTests.cs @@ -147,4 +147,146 @@ public void Command_Should_Have_Protected_ExecuteAsync_Method() method.Should().NotBeNull(); method!.IsFamily.Should().BeTrue(); } + + [Test] + public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Default() + { + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settings = new Settings + { + SettingsFilePath = settingsFilePath, + OutputPath = Settings.DefaultOutputPath + }; + + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = "Output.cs" + }; + + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + [typeof(Settings), typeof(RefitGeneratorSettings)]); + + method.Should().NotBeNull(); + var result = method!.Invoke(null, [settings, refitSettings]) as string; + + result.Should().Be(@"C:\Projects\MyApi\./Generated\Output.cs"); + } + + [Test] + public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Custom() + { + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settings = new Settings + { + SettingsFilePath = settingsFilePath, + OutputPath = Settings.DefaultOutputPath + }; + + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./CustomOutput", + OutputFilename = "PetStore.cs" + }; + + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + [typeof(Settings), typeof(RefitGeneratorSettings)]); + + var result = method!.Invoke(null, [settings, refitSettings]) as string; + + result.Should().Be(@"C:\Projects\MyApi\./CustomOutput\PetStore.cs"); + } + + [Test] + public void GetOutputPath_Should_Use_SettingsFileName_When_OutputFilename_Is_Missing() + { + // OutputFilename will be defaulted by ApplySettingsFileDefaults + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settings = new Settings + { + SettingsFilePath = settingsFilePath, + OutputPath = Settings.DefaultOutputPath + }; + + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = null + }; + + // Apply defaults first (simulates what GenerateCommand does) + var applyMethod = typeof(GenerateCommand).GetMethod( + "ApplySettingsFileDefaults", + BindingFlags.NonPublic | BindingFlags.Static); + applyMethod!.Invoke(null, [settingsFilePath, refitSettings]); + + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + [typeof(Settings), typeof(RefitGeneratorSettings)]); + + var result = method!.Invoke(null, [settings, refitSettings]) as string; + + // Should use settings file base name + result.Should().Be(@"C:\Projects\MyApi\./Generated\petstore.cs"); + } + + [Test] + public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() + { + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = null + }; + + var method = typeof(GenerateCommand).GetMethod( + "ApplySettingsFileDefaults", + BindingFlags.NonPublic | BindingFlags.Static); + + method.Should().NotBeNull(); + method!.Invoke(null, [settingsFilePath, refitSettings]); + + refitSettings.OutputFolder.Should().Be("./Generated"); + } + + [Test] + public void ApplySettingsFileDefaults_Should_Preserve_Explicit_OutputFolder() + { + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./CustomFolder" + }; + + var method = typeof(GenerateCommand).GetMethod( + "ApplySettingsFileDefaults", + BindingFlags.NonPublic | BindingFlags.Static); + + method!.Invoke(null, [settingsFilePath, refitSettings]); + + refitSettings.OutputFolder.Should().Be("./CustomFolder"); + } + + [Test] + public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder() + { + var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = string.Empty + }; + + var method = typeof(GenerateCommand).GetMethod( + "ApplySettingsFileDefaults", + BindingFlags.NonPublic | BindingFlags.Static); + + method!.Invoke(null, [settingsFilePath, refitSettings]); + + refitSettings.OutputFolder.Should().Be("./Generated"); + } } From 20b6014ec1cdb8dc94ba36e91504ee1b9d77b957 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:46:53 +0200 Subject: [PATCH 3/8] Streamline output path resolution in single file mode --- src/Refitter.MSBuild/RefitterGenerateTask.cs | 17 ++++++----------- .../Examples/PropertyNamingPolicyTests.cs | 2 +- ...dSchemaAliasWithLeadingDigitPropertyTests.cs | 2 +- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Refitter.MSBuild/RefitterGenerateTask.cs b/src/Refitter.MSBuild/RefitterGenerateTask.cs index 307f47baf..6bb370596 100644 --- a/src/Refitter.MSBuild/RefitterGenerateTask.cs +++ b/src/Refitter.MSBuild/RefitterGenerateTask.cs @@ -209,17 +209,12 @@ private List GetExpectedGeneratedFiles(string refitterFilePath) else { // Single file mode - string outputPath; - if (!string.IsNullOrWhiteSpace(outputFolder)) - { - // outputFolder is specified - outputPath = Path.GetFullPath(Path.Combine(refitterFileDirectory, outputFolder, outputFilename)); - } - else - { - // No outputFolder specified - generate in current directory (default behavior) - outputPath = Path.GetFullPath(Path.Combine(refitterFileDirectory, outputFilename)); - } + // If outputFolder is not specified in .refitter, CLI uses default "./Generated" + var effectiveOutputFolder = !string.IsNullOrWhiteSpace(outputFolder) + ? outputFolder + : "./Generated"; + + var outputPath = Path.GetFullPath(Path.Combine(refitterFileDirectory, effectiveOutputFolder, outputFilename)); generatedFiles.Add(outputPath); } diff --git a/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs b/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs index 348604e1d..1929e1257 100644 --- a/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs +++ b/src/Refitter.Tests/Examples/PropertyNamingPolicyTests.cs @@ -319,4 +319,4 @@ private static async Task GenerateCodeFromSpec( } } } -} \ No newline at end of file +} diff --git a/src/Refitter.Tests/Examples/TrimUnusedSchemaAliasWithLeadingDigitPropertyTests.cs b/src/Refitter.Tests/Examples/TrimUnusedSchemaAliasWithLeadingDigitPropertyTests.cs index a5b4f4fb7..dcb024a49 100644 --- a/src/Refitter.Tests/Examples/TrimUnusedSchemaAliasWithLeadingDigitPropertyTests.cs +++ b/src/Refitter.Tests/Examples/TrimUnusedSchemaAliasWithLeadingDigitPropertyTests.cs @@ -233,4 +233,4 @@ private static async Task GenerateCodeFromSpec(string openApiSpec) } } } -} \ No newline at end of file +} From 209509eb2b5f1383b3f4dc36c72fa6f7e06f4f77 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:48:24 +0200 Subject: [PATCH 4/8] Update MSBuild test build script for issue #998 regression checks and cleanup --- test/MSBuild/build.ps1 | 77 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/test/MSBuild/build.ps1 b/test/MSBuild/build.ps1 index 0addce726..169c64ce7 100644 --- a/test/MSBuild/build.ps1 +++ b/test/MSBuild/build.ps1 @@ -1,9 +1,13 @@ -Remove-Item bin -Force -Recurse -Remove-Item obj -Force -Recurse +Remove-Item bin -Force -Recurse -ErrorAction SilentlyContinue +Remove-Item obj -Force -Recurse -ErrorAction SilentlyContinue +Remove-Item GeneratedOutput -Force -Recurse -ErrorAction SilentlyContinue +Remove-Item Generated -Force -Recurse -ErrorAction SilentlyContinue dotnet build-server shutdown -dotnet nuget locals global-packages --clear -Remove-Item Refitter.MSBuild.*.nupkg -Force -Remove-Item Petstore.cs +Remove-Item Refitter.MSBuild.*.nupkg -Force -ErrorAction SilentlyContinue +Remove-Item Petstore.cs -ErrorAction SilentlyContinue +Remove-Item PetstorePreserveOriginal.cs -ErrorAction SilentlyContinue +Remove-Item Output.cs -ErrorAction SilentlyContinue # Issue #998: Should not exist + dotnet restore ../../src/Refitter.slnx dotnet clean -c release ../../src/Refitter.slnx dotnet build -c release ../../src/Refitter/Refitter.csproj @@ -13,5 +17,68 @@ dotnet add package .\Refitter.MSBuild.1.0.0.nupkg --source . dotnet restore dotnet add package Refitter.MSBuild --source . dotnet run -v d -filelogger -c Release + +# Issue #998 regression assertions +Write-Host "" +Write-Host "=== Issue #998 Regression Checks ===" -ForegroundColor Cyan + +# Check that expected files exist under Generated (default output folder) +if (!(Test-Path "Generated\Petstore.cs")) { + Write-Host "ERROR: Expected Generated\Petstore.cs not found (from petstore.refitter with output filename)" -ForegroundColor Red + exit 1 +} +Write-Host "PASS: Generated\Petstore.cs exists (respects output filename + default outputFolder)" -ForegroundColor Green + +if (!(Test-Path "Generated\PetstorePreserveOriginal.cs")) { + Write-Host "ERROR: Expected Generated\PetstorePreserveOriginal.cs not found (from petstore-preserve-original.refitter)" -ForegroundColor Red + exit 1 +} +Write-Host "PASS: Generated\PetstorePreserveOriginal.cs exists (respects output filename + default outputFolder)" -ForegroundColor Green + +# Ensure files are not placed in root +if (Test-Path "Petstore.cs") { + Write-Host "ERROR: Unexpected Petstore.cs found in root (outputFolder default ignored)" -ForegroundColor Red + exit 1 +} +if (Test-Path "PetstorePreserveOriginal.cs") { + Write-Host "ERROR: Unexpected PetstorePreserveOriginal.cs found in root (outputFolder default ignored)" -ForegroundColor Red + exit 1 +} +Write-Host "PASS: No stray Petstore*.cs in root directory" -ForegroundColor Green + +# Check that stray Output.cs does NOT exist (the bug symptom) +if (Test-Path "Output.cs") { + Write-Host "ERROR: Unexpected Output.cs found in root (Issue #998 symptom)" -ForegroundColor Red + Write-Host " This indicates outputFolder or outputFilename settings were ignored" -ForegroundColor Red + exit 1 +} +Write-Host "PASS: No stray Output.cs in root directory" -ForegroundColor Green + +# Check interface naming (smoke test for naming.interfaceName) +$petstoreContent = Get-Content "Generated\Petstore.cs" -Raw +if ($petstoreContent -notmatch "interface ISwaggerPetstore") { + Write-Host "WARNING: Expected interface ISwaggerPetstore not found in Petstore.cs" -ForegroundColor Yellow + Write-Host " naming.interfaceName setting may have been ignored" -ForegroundColor Yellow +} else { + Write-Host "PASS: Interface ISwaggerPetstore found (respects naming.interfaceName)" -ForegroundColor Green +} + +# Check outputFolder when explicitly specified +if (!(Test-Path "GeneratedOutput\PetstoreWithFolder.cs")) { + Write-Host "ERROR: Expected GeneratedOutput\PetstoreWithFolder.cs not found (from petstore-with-outputfolder.refitter)" -ForegroundColor Red + exit 1 +} +Write-Host "PASS: GeneratedOutput\PetstoreWithFolder.cs exists (respects explicit outputFolder setting)" -ForegroundColor Green + +$petstoreWithFolderContent = Get-Content "GeneratedOutput\PetstoreWithFolder.cs" -Raw +if ($petstoreWithFolderContent -notmatch "interface ISwaggerPetstoreWithFolder") { + Write-Host "WARNING: Expected interface ISwaggerPetstoreWithFolder not found in GeneratedOutput\PetstoreWithFolder.cs" -ForegroundColor Yellow +} else { + Write-Host "PASS: Interface ISwaggerPetstoreWithFolder found (respects naming.interfaceName with outputFolder)" -ForegroundColor Green +} + +Write-Host "=== All Issue #998 Checks Complete ===" -ForegroundColor Cyan +Write-Host "" + dotnet remove package Refitter.MSBuild Remove-Item Refitter.MSBuild.*.nupkg -Force From c34d49c290e38e1e557b7a57990d2eaede2023ac Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:49:20 +0200 Subject: [PATCH 5/8] Regression tests for issue #998: MSBuild + .NET 10 ignores outputFolder and filename-from-.refitter behavior --- .../Examples/SettingsFileOutputPathTests.cs | 186 ++++++++++++++++++ .../petstore-with-outputfolder.refitter | 10 + 2 files changed, 196 insertions(+) create mode 100644 src/Refitter.Tests/Examples/SettingsFileOutputPathTests.cs create mode 100644 test/MSBuild/petstore-with-outputfolder.refitter diff --git a/src/Refitter.Tests/Examples/SettingsFileOutputPathTests.cs b/src/Refitter.Tests/Examples/SettingsFileOutputPathTests.cs new file mode 100644 index 000000000..323352199 --- /dev/null +++ b/src/Refitter.Tests/Examples/SettingsFileOutputPathTests.cs @@ -0,0 +1,186 @@ +using System.Reflection; +using FluentAssertions; +using Refitter.Core; +using Refitter.Tests.Build; + +namespace Refitter.Tests.Examples; + +/// +/// Regression tests for issue #998: MSBuild + .NET 10 ignores outputFolder and filename-from-.refitter behavior +/// Tests exercise the actual GenerateCommand helper logic via reflection +/// +public class SettingsFileOutputPathTests +{ + private const string MinimalOpenApiSpec = """ + { + "openapi": "3.0.1", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/test": { + "get": { + "operationId": "getTest", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "id": { "type": "integer" } } + } + } + } + } + } + } + } + } + } + """; + + [Test] + public void SettingsFile_WithoutOutputFilename_UsesRefitterFilenameFallback() + { + var tempDir = CreateTempDirectory(); + + try + { + var settingsFile = Path.Combine(tempDir, "myapi.refitter"); + File.WriteAllText(settingsFile, "{}"); + + var settingsJson = """ + { + "openApiPath": "spec.json", + "namespace": "TestNamespace" + } + """; + + var refitGeneratorSettings = Serializer.Deserialize(settingsJson); + var settings = new Settings { SettingsFilePath = settingsFile }; + + ApplySettingsFileDefaults(settingsFile, refitGeneratorSettings); + + refitGeneratorSettings.OutputFilename.Should().Be("myapi.cs", + "because OutputFilename should default to the .refitter filename"); + + var outputPath = GetOutputPath(settings, refitGeneratorSettings); + Path.GetFullPath(outputPath).Should().Be(Path.GetFullPath(Path.Combine(tempDir, "Generated", "myapi.cs")), + "because default output folder is ./Generated rooted at the settings file directory"); + } + finally + { + CleanupDirectory(tempDir); + } + } + + [Test] + public void SettingsFile_WithExplicitDefaultOutputFolder_RootsToSettingsFileDirectory() + { + var tempDir = CreateTempDirectory(); + + try + { + var settingsFile = Path.Combine(tempDir, "api.refitter"); + File.WriteAllText(settingsFile, "{}"); + + var settingsJson = """ + { + "openApiPath": "spec.json", + "namespace": "TestNamespace", + "outputFolder": "./Generated", + "outputFilename": "Api.cs" + } + """; + + var refitGeneratorSettings = Serializer.Deserialize(settingsJson); + var settings = new Settings { SettingsFilePath = settingsFile }; + + ApplySettingsFileDefaults(settingsFile, refitGeneratorSettings); + + var outputPath = GetOutputPath(settings, refitGeneratorSettings); + Path.GetFullPath(outputPath).Should().Be(Path.GetFullPath(Path.Combine(tempDir, "Generated", "Api.cs")), + "because outputFolder is rooted relative to the .refitter file directory"); + } + finally + { + CleanupDirectory(tempDir); + } + } + + [Test] + public async Task SettingsFile_PreservesNamingInterfaceName() + { + // Arrange: .refitter with custom naming.interfaceName + var tempDir = CreateTempDirectory(); + + try + { + var openApiFile = Path.Combine(tempDir, "spec.json"); + await File.WriteAllTextAsync(openApiFile, MinimalOpenApiSpec); + + var settingsJson = """ + { + "openApiPath": "spec.json", + "namespace": "TestNamespace", + "naming": { + "useOpenApiTitle": false, + "interfaceName": "CustomApi" + } + } + """; + + var refitGeneratorSettings = Serializer.Deserialize(settingsJson); + refitGeneratorSettings.OpenApiPath = openApiFile; + + var generator = await RefitGenerator.CreateAsync(refitGeneratorSettings); + var code = generator.Generate(); + + // Assert: Naming settings should be preserved and used in generation + refitGeneratorSettings.Naming.Should().NotBeNull(); + refitGeneratorSettings.Naming.InterfaceName.Should().Be("CustomApi"); + code.Should().Contain("interface ICustomApi", "because custom interface name should be used"); + BuildHelper.BuildCSharp(code).Should().BeTrue(); + } + finally + { + CleanupDirectory(tempDir); + } + } + + private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) + { + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(Settings), typeof(RefitGeneratorSettings)], + null); + + method.Should().NotBeNull(); + + return (string)method!.Invoke(null, [settings, refitGeneratorSettings])!; + } + + private static void ApplySettingsFileDefaults(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) + { + var method = typeof(GenerateCommand).GetMethod( + "ApplySettingsFileDefaults", + BindingFlags.NonPublic | BindingFlags.Static); + + method.Should().NotBeNull(); + method!.Invoke(null, [settingsFilePath, refitGeneratorSettings]); + } + + private static string CreateTempDirectory() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"refitter-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + return tempDir; + } + + private static void CleanupDirectory(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } +} diff --git a/test/MSBuild/petstore-with-outputfolder.refitter b/test/MSBuild/petstore-with-outputfolder.refitter new file mode 100644 index 000000000..4eaa545da --- /dev/null +++ b/test/MSBuild/petstore-with-outputfolder.refitter @@ -0,0 +1,10 @@ +{ + "openApiPath": "https://petstore3.swagger.io/api/v3/openapi.json", + "outputFolder": "./GeneratedOutput", + "outputFilename": "PetstoreWithFolder.cs", + "namespace": "Petstore.Client", + "naming": { + "useOpenApiTitle": false, + "interfaceName": "SwaggerPetstoreWithFolder" + } +} From 7840b90b7ab7abec806e0cafd16d54524de99638 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 13:50:42 +0200 Subject: [PATCH 6/8] Ignore generated code files in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 55e5f0a25..5e6d3c2d9 100644 --- a/.gitignore +++ b/.gitignore @@ -285,3 +285,4 @@ install.sh # Confidential temporary files — must not reach public repository tmp/ +test/Generated/GeneratedCode/*.generated.cs From 1e278d3383374712252edfaee7449cf90dde0982 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:07:18 +0000 Subject: [PATCH 7/8] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit --- .gitignore | 2 ++ src/Refitter.Tests/GenerateCommandTests.cs | 24 ++++++++------- src/Refitter/GenerateCommand.cs | 35 +++++++++++----------- test/MSBuild/build.ps1 | 11 ++++--- 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 5e6d3c2d9..d12b4d991 100644 --- a/.gitignore +++ b/.gitignore @@ -286,3 +286,5 @@ install.sh # Confidential temporary files — must not reach public repository tmp/ test/Generated/GeneratedCode/*.generated.cs +test/MSBuild/Generated/**/*.generated.cs +test/MSBuild/GeneratedOutput/**/*.cs \ No newline at end of file diff --git a/src/Refitter.Tests/GenerateCommandTests.cs b/src/Refitter.Tests/GenerateCommandTests.cs index 19c463fa7..ec57bf469 100644 --- a/src/Refitter.Tests/GenerateCommandTests.cs +++ b/src/Refitter.Tests/GenerateCommandTests.cs @@ -151,7 +151,7 @@ public void Command_Should_Have_Protected_ExecuteAsync_Method() [Test] public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Default() { - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var settings = new Settings { SettingsFilePath = settingsFilePath, @@ -172,13 +172,14 @@ public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder method.Should().NotBeNull(); var result = method!.Invoke(null, [settings, refitSettings]) as string; - result.Should().Be(@"C:\Projects\MyApi\./Generated\Output.cs"); + var expectedPath = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./Generated", "Output.cs"); + result.Should().Be(expectedPath); } [Test] public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Custom() { - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var settings = new Settings { SettingsFilePath = settingsFilePath, @@ -198,14 +199,15 @@ public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder var result = method!.Invoke(null, [settings, refitSettings]) as string; - result.Should().Be(@"C:\Projects\MyApi\./CustomOutput\PetStore.cs"); + var expectedPath = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./CustomOutput", "PetStore.cs"); + result.Should().Be(expectedPath); } [Test] public void GetOutputPath_Should_Use_SettingsFileName_When_OutputFilename_Is_Missing() { // OutputFilename will be defaulted by ApplySettingsFileDefaults - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var settings = new Settings { SettingsFilePath = settingsFilePath, @@ -232,13 +234,15 @@ public void GetOutputPath_Should_Use_SettingsFileName_When_OutputFilename_Is_Mis var result = method!.Invoke(null, [settings, refitSettings]) as string; // Should use settings file base name - result.Should().Be(@"C:\Projects\MyApi\./Generated\petstore.cs"); + var expectedFilename = Path.GetFileNameWithoutExtension(settingsFilePath) + ".cs"; + var expectedPath = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./Generated", expectedFilename); + result.Should().Be(expectedPath); } [Test] public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() { - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings { OutputFolder = null @@ -257,7 +261,7 @@ public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() [Test] public void ApplySettingsFileDefaults_Should_Preserve_Explicit_OutputFolder() { - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings { OutputFolder = "./CustomFolder" @@ -275,7 +279,7 @@ public void ApplySettingsFileDefaults_Should_Preserve_Explicit_OutputFolder() [Test] public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder() { - var settingsFilePath = @"C:\Projects\MyApi\petstore.refitter"; + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings { OutputFolder = string.Empty @@ -289,4 +293,4 @@ public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder refitSettings.OutputFolder.Should().Be("./Generated"); } -} +} \ No newline at end of file diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index 7ca9a85f5..f2e0c2203 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -39,26 +39,25 @@ protected override async Task ExecuteAsync(CommandContext context, Settings { RefitGeneratorSettings refitGeneratorSettings; - // When settings file is provided, deserialize it first and use as source of truth - if (!string.IsNullOrWhiteSpace(settings.SettingsFilePath)) + try { - var json = await File.ReadAllTextAsync(settings.SettingsFilePath); - refitGeneratorSettings = Serializer.Deserialize(json); - - // Allow CLI to override OpenApiPath if explicitly provided - if (!string.IsNullOrWhiteSpace(settings.OpenApiPath)) - refitGeneratorSettings.OpenApiPath = settings.OpenApiPath; + // When settings file is provided, deserialize it first and use as source of truth + if (!string.IsNullOrWhiteSpace(settings.SettingsFilePath)) + { + var json = await File.ReadAllTextAsync(settings.SettingsFilePath); + refitGeneratorSettings = Serializer.Deserialize(json); - ApplySettingsFileDefaults(settings.SettingsFilePath, refitGeneratorSettings); - } - else - { - // No settings file - build from CLI arguments - refitGeneratorSettings = CreateRefitGeneratorSettings(settings); - } + // Allow CLI to override OpenApiPath if explicitly provided + if (!string.IsNullOrWhiteSpace(settings.OpenApiPath)) + refitGeneratorSettings.OpenApiPath = settings.OpenApiPath; - try - { + ApplySettingsFileDefaults(settings.SettingsFilePath, refitGeneratorSettings); + } + else + { + // No settings file - build from CLI arguments + refitGeneratorSettings = CreateRefitGeneratorSettings(settings); + } var stopwatch = Stopwatch.StartNew(); var version = GetType().Assembly.GetName().Version!.ToString(); if (version == "1.0.0.0") @@ -936,4 +935,4 @@ internal static string DetermineSettingsFilePath(Settings settings) // Default: put .refitter file in current directory return FileExtensionConstants.Refitter; } -} +} \ No newline at end of file diff --git a/test/MSBuild/build.ps1 b/test/MSBuild/build.ps1 index 169c64ce7..9ddb792e8 100644 --- a/test/MSBuild/build.ps1 +++ b/test/MSBuild/build.ps1 @@ -57,8 +57,9 @@ Write-Host "PASS: No stray Output.cs in root directory" -ForegroundColor Green # Check interface naming (smoke test for naming.interfaceName) $petstoreContent = Get-Content "Generated\Petstore.cs" -Raw if ($petstoreContent -notmatch "interface ISwaggerPetstore") { - Write-Host "WARNING: Expected interface ISwaggerPetstore not found in Petstore.cs" -ForegroundColor Yellow - Write-Host " naming.interfaceName setting may have been ignored" -ForegroundColor Yellow + Write-Host "ERROR: Expected interface ISwaggerPetstore not found in Petstore.cs" -ForegroundColor Red + Write-Host " naming.interfaceName setting may have been ignored" -ForegroundColor Red + exit 1 } else { Write-Host "PASS: Interface ISwaggerPetstore found (respects naming.interfaceName)" -ForegroundColor Green } @@ -72,7 +73,9 @@ Write-Host "PASS: GeneratedOutput\PetstoreWithFolder.cs exists (respects explici $petstoreWithFolderContent = Get-Content "GeneratedOutput\PetstoreWithFolder.cs" -Raw if ($petstoreWithFolderContent -notmatch "interface ISwaggerPetstoreWithFolder") { - Write-Host "WARNING: Expected interface ISwaggerPetstoreWithFolder not found in GeneratedOutput\PetstoreWithFolder.cs" -ForegroundColor Yellow + Write-Host "ERROR: Expected interface ISwaggerPetstoreWithFolder not found in GeneratedOutput\PetstoreWithFolder.cs" -ForegroundColor Red + Write-Host " naming.interfaceName setting may have been ignored" -ForegroundColor Red + exit 1 } else { Write-Host "PASS: Interface ISwaggerPetstoreWithFolder found (respects naming.interfaceName with outputFolder)" -ForegroundColor Green } @@ -81,4 +84,4 @@ Write-Host "=== All Issue #998 Checks Complete ===" -ForegroundColor Cyan Write-Host "" dotnet remove package Refitter.MSBuild -Remove-Item Refitter.MSBuild.*.nupkg -Force +Remove-Item Refitter.MSBuild.*.nupkg -Force \ No newline at end of file From a8252d83b5f1a6c391139b3c0587de820798412a Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Thu, 16 Apr 2026 17:05:37 +0200 Subject: [PATCH 8/8] Fix failing smoke tests --- src/Refitter.Tests/GenerateCommandTests.cs | 80 +++++++++++++++++++++- src/Refitter/GenerateCommand.cs | 33 ++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/Refitter.Tests/GenerateCommandTests.cs b/src/Refitter.Tests/GenerateCommandTests.cs index ec57bf469..b149d398e 100644 --- a/src/Refitter.Tests/GenerateCommandTests.cs +++ b/src/Refitter.Tests/GenerateCommandTests.cs @@ -176,6 +176,56 @@ public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder result.Should().Be(expectedPath); } + [Test] + public void GetOutputPath_Should_Use_Explicit_Cli_Output_File_Path() + { + var settings = new Settings + { + OutputPath = Path.Combine("GeneratedCode", "Petstore.cs") + }; + + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = "Output.cs" + }; + + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + [typeof(Settings), typeof(RefitGeneratorSettings)]); + + method.Should().NotBeNull(); + var result = method!.Invoke(null, [settings, refitSettings]) as string; + + result.Should().Be(Path.Combine("GeneratedCode", "Petstore.cs")); + } + + [Test] + public void GetOutputPath_Should_Use_Default_Cli_Output_File_In_Current_Directory() + { + var settings = new Settings + { + OutputPath = Settings.DefaultOutputPath + }; + + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = "Output.cs" + }; + + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Static, + [typeof(Settings), typeof(RefitGeneratorSettings)]); + + method.Should().NotBeNull(); + var result = method!.Invoke(null, [settings, refitSettings]) as string; + + result.Should().Be(Settings.DefaultOutputPath); + } + [Test] public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Custom() { @@ -239,6 +289,34 @@ public void GetOutputPath_Should_Use_SettingsFileName_When_OutputFilename_Is_Mis result.Should().Be(expectedPath); } + [Test] + public void GetOutputPath_For_MultipleFiles_Should_Use_Explicit_Cli_Output_Directory() + { + var settings = new Settings + { + OutputPath = Path.Combine("GeneratedCode", "MultipleFiles"), + GenerateMultipleFiles = true + }; + + var refitSettings = new RefitGeneratorSettings + { + GenerateMultipleFiles = true, + OutputFolder = "./Generated" + }; + + var command = new GenerateCommand(); + var method = typeof(GenerateCommand).GetMethod( + "GetOutputPath", + BindingFlags.NonPublic | BindingFlags.Instance, + [typeof(Settings), typeof(RefitGeneratorSettings), typeof(GeneratedCode)]); + + method.Should().NotBeNull(); + + var result = method!.Invoke(command, [settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")]) as string; + + result.Should().Be(Path.Combine("GeneratedCode", "MultipleFiles", "RefitInterfaces.cs")); + } + [Test] public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() { @@ -293,4 +371,4 @@ public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder refitSettings.OutputFolder.Should().Be("./Generated"); } -} \ No newline at end of file +} diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index f2e0c2203..83aa55b07 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -647,6 +647,16 @@ private static void SimpleDonationBanner() private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) { + if (UsesDirectCliOutput(settings)) + { + return settings.OutputPath!; + } + + if (UsesDirectCliDefaults(settings)) + { + return Settings.DefaultOutputPath; + } + // Determine root directory based on settings file location var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) ? string.Empty @@ -696,6 +706,15 @@ private string GetOutputPath( RefitGeneratorSettings refitGeneratorSettings, GeneratedCode outputFile) { + if (IsDirectCliGeneration(settings)) + { + var outputDirectory = UsesDirectCliOutput(settings) + ? settings.OutputPath! + : "."; + + return Path.Combine(outputDirectory, outputFile.Filename); + } + var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) ? string.Empty : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; @@ -709,6 +728,18 @@ private string GetOutputPath( ? Path.Combine(root, settings.OutputPath, outputFile.Filename) : Path.Combine(root, outputFile.Filename); } + + private static bool IsDirectCliGeneration(Settings settings) => + string.IsNullOrWhiteSpace(settings.SettingsFilePath); + + private static bool UsesDirectCliOutput(Settings settings) => + IsDirectCliGeneration(settings) && + !string.IsNullOrWhiteSpace(settings.OutputPath) && + settings.OutputPath != Settings.DefaultOutputPath; + + private static bool UsesDirectCliDefaults(Settings settings) => + IsDirectCliGeneration(settings) && + (string.IsNullOrWhiteSpace(settings.OutputPath) || settings.OutputPath == Settings.DefaultOutputPath); private static async Task ValidateOpenApiSpec(string openApiPath, Settings settings) { OpenApiValidationResult validationResult; @@ -935,4 +966,4 @@ internal static string DetermineSettingsFilePath(Settings settings) // Default: put .refitter file in current directory return FileExtensionConstants.Refitter; } -} \ No newline at end of file +}