diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index 92a0ff1fe..67389aaa3 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -32,7 +32,6 @@ jobs: working-directory: test/MSBuild run: | dotnet restore ../../src/Refitter.slnx - dotnet build -c release ../../src/Refitter/Refitter.csproj dotnet build -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj dotnet pack -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj -o . nuget add .\Refitter.MSBuild.1.0.0.nupkg -source . diff --git a/.gitignore b/.gitignore index ed4cda419..4227cd8d0 100644 --- a/.gitignore +++ b/.gitignore @@ -278,6 +278,8 @@ test/Apizr/GeneratedCode test/MultipleFiles/**/*.cs test/HelloWorld/HelloWorld/Output.cs test/MSBuild/refitter.msbuild/ +test/MSBuild/nupkg-*/ +src/Refitter.MSBuild/nupkg-check/ # Install scripts dotnet-install.sh @@ -286,7 +288,7 @@ install.sh # Confidential temporary files — must not reach public repository tmp/ test/Generated/GeneratedCode/*.generated.cs -test/MSBuild/Generated/**/*.generated.cs +test/MSBuild/Generated/**/*.cs test/MSBuild/GeneratedOutput/**/*.cs .squad/orchestration-log/ .squad/log/ diff --git a/AGENTS.md b/AGENTS.md index 9141b1b02..a061f1654 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Run these first if source generator tests fail with missing generated types. |---|---|---| | `Refitter.Core` | `netstandard2.0` | Core generation logic (NSwag-based) | | `Refitter` | `net8.0;net9.0;net10.0` | CLI tool (`PackAsTool`, container support) | -| `Refitter.MSBuild` | `netstandard2.0` | MSBuild task; **requires Refitter CLI binaries** to be built first because it copies `../Refitter/bin/$(Configuration)/net{8,9,10}.0/**/*` into the package | +| `Refitter.MSBuild` | `netstandard2.0` | MSBuild task; references `Refitter.Core` directly (in-process); no CLI binary dependency | | `Refitter.SourceGenerator` | `netstandard2.0` | Roslyn source generator; emits code in-memory via `AddSource()` (not to disk since v2.0.0) | | `Refitter.Tests` | `net10.0` | Unit tests | | `Refitter.SourceGenerator.Tests` | `net8.0;net10.0` | Source generator integration tests | diff --git a/docs/prd/decouple-msbuild-from-cli.md b/docs/prd/decouple-msbuild-from-cli.md new file mode 100644 index 000000000..4197b303f --- /dev/null +++ b/docs/prd/decouple-msbuild-from-cli.md @@ -0,0 +1,270 @@ +# Decouple CLI Binary Dependency from MSBuild Task + +## Status + +**Draft** — Ready for implementation + +## Context + +`Refitter.MSBuild` (`netstandard2.0`) currently has **zero compile-time dependency** on `Refitter.Core` or `Refitter`. Instead, it bundles the entire CLI output (`refitter.dll` + all transitive dependencies — Spectre.Console, Exceptionless, ApplicationInsights, Fluid, etc.) for `net8.0`, `net9.0`, and `net10.0` into the NuGet package. + +At build time, the task discovers `.refitter` files and spawns `dotnet --settings-file --simple-output` as an **out-of-process** subprocess. It parses stdout for `GeneratedFile: ` markers and returns them to MSBuild as `@(Compile)` items. + +The CLI (`Refitter`) handles everything else: settings deserialization, applying defaults, resolving relative spec paths, OpenAPI validation, generation, output path planning, file writing, and telemetry. + +## Problem + +1. **Build dependency order:** The MSBuild project must be built after the CLI project, and its package depends on CLI binaries being present in the correct output folders. +2. **Package bloat:** The MSBuild package contains hundreds of files (CLI + deps for 3 TFMs) that are never directly used by the task itself. +3. **Complexity:** The task manages process spawning, runtime detection, timeout handling, and stdout parsing — all just to invoke a CLI tool that could be called directly as a library. + +## Decision + +**Approach:** `ProjectReference` to `Refitter.Core` with `SuppressDependenciesWhenPacking`. + +- The MSBuild task will call `Refitter.Core` APIs directly (in-process). +- No subprocess spawning, no runtime detection, no stdout parsing. +- The NuGet package will have zero `` entries. +- `Refitter.Core` and its transitive dependencies will be bundled into `tasks/netstandard2.0/` in the package. + +## Trade-offs + +| Concern | Mitigation | +|---|---| +| **Assembly loading conflicts in MSBuild process** | `Refitter.Core` depends on `Microsoft.CodeAnalysis.CSharp` (Roslyn) and `NSwag`. The MSBuild process may already load different versions. The same risk is already handled successfully by `Refitter.SourceGenerator` in the Roslyn compiler process. If conflicts arise, binding redirects can resolve them. | +| **Package size** | The MSBuild package will contain `Refitter.Core.dll` + `NSwag` + `OasReader` + `Microsoft.CodeAnalysis.CSharp` + etc. This is comparable to the current package size (which already bundles the CLI + all its deps for 3 TFMs). | +| **Behavioral changes** | Zero. The orchestration logic is moved to Core, not rewritten. The task performs the same operations as the CLI did. | + +## Implementation Plan + +### Phase 1: Move shared orchestration logic to `Refitter.Core` + +The MSBuild task needs to perform the same operations as the CLI (settings loading, output path planning, validation, file writing). These must be shared to avoid duplication and behavioral drift. + +| File | From | To | Action | +|---|---|---|---| +| `OutputPlanner.cs` | `src/Refitter/` | `src/Refitter.Core/` | Move to `Refitter.Core` namespace. Change `Settings` parameter to `string? settingsFilePath` + `string? cliOutputPath` (or keep dual overloads for CLI compatibility). | +| `Validation/OpenApiValidator.cs` | `src/Refitter/Validation/` | `src/Refitter.Core/Validation/` | Move to `Refitter.Core` namespace. | +| `Validation/OpenApiValidationResult.cs` | `src/Refitter/Validation/` | `src/Refitter.Core/Validation/` | Move to `Refitter.Core` namespace. | +| `Validation/OpenApiStats.cs` | `src/Refitter/Validation/` | `src/Refitter.Core/Validation/` | Move to `Refitter.Core` namespace. | +| `Validation/OpenApiValidationException.cs` | `src/Refitter/Validation/` | `src/Refitter.Core/Validation/` | Move to `Refitter.Core` namespace. | +| `ApplySettingsFileDefaults` (in `GenerateCommand.cs`) | `src/Refitter/GenerateCommand.cs` | `src/Refitter.Core/RefitterSettingsLoader.cs` | Extract to `RefitterSettingsLoader.ApplyDefaults(string settingsFilePath, RefitGeneratorSettings settings)` or a new `RefitGeneratorSettingsDefaults` class. | + +**Files to update in `src/Refitter/`:** +- `GenerateCommand.cs` — remove `ApplySettingsFileDefaults`, use Core's version. Keep `FormatGeneratedFileMarker` (CLI still needs it for `--simple-output`). +- `GenerationOrchestrator.cs` — update `using` statements for moved types. +- `IGenerationReporter.cs`, `RichGenerationReporter.cs`, `SimpleGenerationReporter.cs` — update `using` for `OpenApiValidationResult`. + +**Files to update in `src/Refitter.Tests/`:** +- `OutputPlannerTests.cs` — update `using` if namespace changes. +- `OpenApi/*Tests.cs` — update `using` for moved validation types. +- `GenerationOrchestratorTests.cs` — update `using` for moved validation types. +- `GenerateCommandTests.cs` — remove `FormatGeneratedFileMarker_Should_Emit_A_Task_Parseable_Full_Path` test (MSBuild no longer parses CLI markers). + +### Phase 2: Rewrite `Refitter.MSBuild` task + +**Project file changes (`src/Refitter.MSBuild/Refitter.MSBuild.csproj`):** + +```xml + + + all + +true + + + +``` + +**Task rewrite (`src/Refitter.MSBuild/RefitterGenerateTask.cs`):** + +- **Remove** all subprocess-related properties: + - `ProcessRunner`, `RuntimeResolver`, `FileExists`, `ProcessTimeoutMilliseconds` + - `DefaultRuntimeResolver`, `PreferredRuntimeOrder`, `CompatibilityFallbackOrder` +- **Remove** methods: + - `ResolveRefitterDll`, `StartProcess`, `TryExecuteRefitter` + - `HandleProcessStandardOutput`, `HandleProcessErrorOutput`, `ParseGeneratedFilePath`, `ResolveGeneratedFiles` +- **Keep** `FilterFiles` (still needed) +- **Implement new `Execute` flow:** + 1. Find `.refitter` files (same as before) + 2. Filter by `IncludePatterns` (same as before) + 3. For each file: + - Read JSON + - `var settings = RefitterSettingsLoader.Load(json, baseDirectory)` + - `RefitterSettingsLoader.ApplyDefaults(filePath, settings)` (or equivalent) + - `var generator = await RefitGenerator.CreateAsync(settings)` + - If `settings.GenerateMultipleFiles`: + - `var output = generator.GenerateMultipleFiles()` + - For each file in `output.Files`: + - Compute path using `OutputPlanner.GetMultiFileOutputPath(...)` + - Write to disk + - Collect absolute path + - Else: + - `var code = generator.Generate()` + - Compute path using `OutputPlanner.GetSingleFileOutputPath(...)` + - Write to disk + - Collect absolute path + - If `!SkipValidation`: + - Validate each OpenAPI spec using `OpenApiValidator.Validate(...)` + - `validationResult.ThrowIfInvalid()` + 4. Return collected paths as `GeneratedFiles` + +**Files to delete:** +- `IProcessRunner.cs` +- `DefaultProcessRunner.cs` +- `IRuntimeResolver.cs` +- `DefaultRuntimeResolver.cs` +- `ProcessExecutionResult.cs` + +### Phase 3: Update `Refitter` CLI to use moved Core types + +**Files to update:** +- `src/Refitter/GenerateCommand.cs` — use `RefitterSettingsLoader.ApplyDefaults`, `Refitter.Core.OutputPlanner` +- `src/Refitter/GenerationOrchestrator.cs` — use `Refitter.Core.OpenApiValidator`, `Refitter.Core.OutputPlanner` + +### Phase 4: Update tests + +**`src/Refitter.Tests/MSBuild/RefitterGenerateTaskTests.cs` — major rewrite:** + +- **Remove** tests for: + - `ResolveRefitterDll` (all variants) + - `GetInstalledDotnetRuntimes` (timeout, termination failure, non-zero exit) + - `HandleProcessStandardOutput` / `HandleProcessErrorOutput` + - `ParseGeneratedFilePath` + - `Execute_Should_Return_False_When_Runtime_Discovery_Throws` + - `Execute_Should_Fall_Back_When_Runtime_Discovery_Times_Out` + - `Execute_Should_Use_DotNet9_Runtime_When_Available` + - `Execute_Should_Fall_Back_To_DotNet8_Runtime_When_Newer_Runtimes_Are_Unavailable` + - `Execute_Should_Return_False_When_Refitter_Cli_Cannot_Be_Located` + - `Execute_Should_Log_Timeout_When_Process_Does_Not_Exit` + - `Execute_Should_Log_Millisecond_Timeout_Value` + - `Execute_Should_Log_When_Timed_Out_Process_Cannot_Be_Terminated` + - `Execute_Should_Log_Configured_Timeout_Value` + - `Execute_Should_Log_ProcessRunner_Exception_And_Return_False` + - `Execute_Should_Log_When_Process_Exits_With_Non_Zero_Code` + - `RunProcess_Should_Return_TimedOut_Result_When_Process_Exceeds_Timeout` + - `RunProcess_Should_Return_Termination_Exception_When_Kill_Fails_After_Timeout` +- **Keep** tests for: + - `FilterFiles` (all variants) + - `Execute_Should_Generate_Files_Reported_By_Refitter` (rewrite to verify Core integration) + - `TryLogCommandLine_Should_Swallow_BuildEngine_Exceptions` + - `TryLogError_Should_Swallow_BuildEngine_Exceptions` + - `TryLogErrorFromException_Should_Swallow_BuildEngine_Exceptions` + - `TryLogErrorFromException_Should_Log_When_BuildEngine_Allows_It` +- **Add** tests for: + - `Execute_Should_Skip_Validation_When_SkipValidation_Is_True` + - `Execute_Should_Fail_When_Validation_Fails_And_SkipValidation_Is_False` + - `Execute_Should_Generate_Multiple_Files` + - `Execute_Should_Respect_ContractsOutputFolder` + - `Execute_Should_Apply_SettingsFileDefaults` + +**`src/Refitter.Tests/GenerateCommandTests.cs`:** +- Remove `FormatGeneratedFileMarker_Should_Emit_A_Task_Parseable_Full_Path` test + +**`src/Refitter.Tests/OutputPlannerTests.cs`:** +- Update namespace if `OutputPlanner` moves to `Refitter.Core` + +### Phase 5: Update CI and build scripts + +**`.github/workflows/msbuild.yml`:** +- Remove step: `dotnet build -c release ../../src/Refitter/Refitter.csproj` +- Remove step: `dotnet build -c release ../../src/Refitter/Refitter.csproj` (there are two instances) + +**`test/MSBuild/build.ps1`:** +- Remove step: `dotnet build -c release ../../src/Refitter/Refitter.csproj` + +**`AGENTS.md`:** +- Update MSBuild row: "Requires Refitter CLI binaries to be built first" → "References Refitter.Core directly; no CLI binary dependency" + +### Phase 6: Verify + +1. `dotnet build -c Release src/Refitter.slnx` +2. `dotnet test --solution src/Refitter.slnx -c Release` +3. Run `test/MSBuild/build.ps1` to verify package smoke tests + +## Behavioral Notes (Zero behavioral changes expected) + +- **Generated file paths:** `OutputPlanner` logic is preserved exactly (moved to Core, not rewritten). +- **Validation:** `SkipValidation` still works; validation runs the same `OpenApiValidator` logic. +- **Settings defaults:** `ApplySettingsFileDefaults` is preserved exactly (moved to Core). +- **Logging:** MSBuild task logs change from "process stdout" to "direct Core logging." The same information is logged (which file is being processed, success/failure). +- **No CLI marker parsing:** The task no longer needs `GeneratedFile:` markers because it writes files directly and knows their paths. + +## Appendix: NuGet Packaging Strategy + +### Why `SuppressDependenciesWhenPacking` works + +The NuGet `PackTask` supports `SuppressDependenciesWhenPacking` (NuGet/Home#6354). When set to `true`: + +1. The `_GetFrameworksWithSuppressedDependencies` target returns the current TFM as suppressed. +2. The `PackTask` receives `FrameworksWithSuppressedDependencies` and suppresses all dependencies for that TFM in the generated `.nuspec`. +3. The package still contains all `BuildOutput` (DLLs) and `Content` files. + +### What the package will contain + +- `tasks/netstandard2.0/Refitter.MSBuild.dll` — the task assembly +- `tasks/netstandard2.0/Refitter.Core.dll` — the core library +- `tasks/netstandard2.0/NSwag.*.dll` — NSwag dependencies +- `tasks/netstandard2.0/OasReader.dll` — OpenAPI reader +- `tasks/netstandard2.0/Microsoft.OpenApi.dll` — OpenAPI model +- `tasks/netstandard2.0/Microsoft.CodeAnalysis.CSharp.dll` — Roslyn +- `tasks/netstandard2.0/System.Text.Json.dll` — etc. +- `build/Refitter.MSBuild.props` — props file +- `build/Refitter.MSBuild.targets` — targets file +- `tasks/Refitter.MSBuild.props` — copy for MSBuild +- `tasks/Refitter.MSBuild.targets` — copy for MSBuild + +The `.nuspec` will have **zero** `` entries. The user's project will not see any of these assemblies at compile time or runtime. They are only loaded by MSBuild when the task executes. + +## Appendix: Current MSBuild Task Architecture + +``` +User Project + ↓ +Refitter.MSBuild (NuGet) + ↓ +Refitter.MSBuild.props / .targets + ↓ +RefitterGenerateTask (netstandard2.0) + ↓ + ┌─────────────────────────────────────────────────────────┐ + │ 1. Find .refitter files │ + │ 2. Filter by IncludePatterns │ + │ 3. For each file: │ + │ a. Resolve refitter.dll (runtime detection) │ + │ b. Spawn: dotnet refitter.dll --settings-file │ + │ c. Parse stdout for "GeneratedFile: " markers │ + │ d. Return paths to MSBuild │ + └─────────────────────────────────────────────────────────┘ + ↓ +@(Compile) += @(GeneratedFiles) +``` + +## Appendix: Target MSBuild Task Architecture + +``` +User Project + ↓ +Refitter.MSBuild (NuGet) + ↓ +Refitter.MSBuild.props / .targets + ↓ +RefitterGenerateTask (netstandard2.0) + ↓ + ┌─────────────────────────────────────────────────────────┐ + │ 1. Find .refitter files │ + │ 2. Filter by IncludePatterns │ + │ 3. For each file: │ + │ a. Read JSON │ + │ b. Load settings (RefitterSettingsLoader) │ + │ c. Apply defaults │ + │ d. Create generator (RefitGenerator.CreateAsync) │ + │ e. Generate code (generator.Generate*) │ + │ f. Plan output paths (OutputPlanner) │ + │ g. Write files to disk │ + │ h. Validate OpenAPI (OpenApiValidator) │ + │ i. Return paths to MSBuild │ + └─────────────────────────────────────────────────────────┘ + ↓ +@(Compile) += @(GeneratedFiles) +``` diff --git a/src/Refitter.Core/OutputPlanner.cs b/src/Refitter.Core/OutputPlanner.cs new file mode 100644 index 000000000..c7b872ba0 --- /dev/null +++ b/src/Refitter.Core/OutputPlanner.cs @@ -0,0 +1,136 @@ +namespace Refitter.Core; + +public sealed record PlannedFile(string Path, string Content); + +public static class OutputPlanner +{ + public const string DefaultOutputPath = "Output.cs"; + + public static PlannedFile PlanSingleFile( + string? settingsFilePath, + string? cliOutputPath, + RefitGeneratorSettings refitGeneratorSettings, + string code) => + new(GetSingleFileOutputPath(settingsFilePath, cliOutputPath, refitGeneratorSettings), code); + + public static IReadOnlyList PlanMultipleFiles( + string? settingsFilePath, + string? cliOutputPath, + RefitGeneratorSettings refitGeneratorSettings, + GeneratorOutput generatorOutput) + { + var planned = new List(generatorOutput.Files.Count); + foreach (var outputFile in generatorOutput.Files) + { + var path = ShouldRerouteToContractsFolder(refitGeneratorSettings, outputFile) + ? GetContractsOutputPath(settingsFilePath, refitGeneratorSettings, outputFile) + : GetMultiFileOutputPath(settingsFilePath, cliOutputPath, refitGeneratorSettings, outputFile); + + planned.Add(new PlannedFile(path, outputFile.Content)); + } + + return planned; + } + + public static string GetSingleFileOutputPath( + string? settingsFilePath, + string? cliOutputPath, + RefitGeneratorSettings refitGeneratorSettings) + { + if (IsDirectCliGeneration(settingsFilePath)) + { + if (HasExplicitCliOutputOverride(cliOutputPath)) + return cliOutputPath!; + + return DefaultOutputPath; + } + + var root = string.IsNullOrWhiteSpace(settingsFilePath) + ? string.Empty + : Path.GetDirectoryName(settingsFilePath) ?? string.Empty; + + var cliOverridesOutput = HasExplicitCliOutputOverride(cliOutputPath); + + string outputPath; + if (cliOverridesOutput) + { + outputPath = cliOutputPath!; + } + else + { + var filename = refitGeneratorSettings.OutputFilename ?? "Output.cs"; + outputPath = !string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder) + ? Path.Combine(refitGeneratorSettings.OutputFolder, filename) + : filename; + } + + if (!string.IsNullOrWhiteSpace(root) && !Path.IsPathRooted(outputPath)) + outputPath = Path.Combine(root, outputPath); + + return outputPath; + } + + public static string GetMultiFileOutputPath( + string? settingsFilePath, + string? cliOutputPath, + RefitGeneratorSettings refitGeneratorSettings, + GeneratedCode outputFile) + { + if (IsDirectCliGeneration(settingsFilePath)) + { + var outputDirectory = HasExplicitCliOutputOverride(cliOutputPath) + ? cliOutputPath! + : "."; + + return Path.Combine(outputDirectory, outputFile.Filename); + } + + var root = string.IsNullOrWhiteSpace(settingsFilePath) + ? string.Empty + : Path.GetDirectoryName(settingsFilePath) ?? string.Empty; + + var outputFolder = HasExplicitCliOutputOverride(cliOutputPath) + ? cliOutputPath + : refitGeneratorSettings.OutputFolder; + + if (!string.IsNullOrWhiteSpace(outputFolder)) + return CombineWithSettingsRoot(root, outputFolder!, outputFile.Filename); + + return CombineWithSettingsRoot(root, outputFile.Filename); + } + + public static bool ShouldRerouteToContractsFolder( + RefitGeneratorSettings refitGeneratorSettings, + GeneratedCode outputFile) => + !string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder) + && refitGeneratorSettings.ContractsOutputFolder != RefitGeneratorSettings.DefaultOutputFolder + && outputFile.Filename == $"{TypenameConstants.Contracts}.cs"; + + public static string GetContractsOutputPath( + string? settingsFilePath, + RefitGeneratorSettings refitGeneratorSettings, + GeneratedCode outputFile) + { + var root = string.IsNullOrWhiteSpace(settingsFilePath) + ? string.Empty + : Path.GetDirectoryName(settingsFilePath) ?? string.Empty; + + var contractsFolder = Path.GetFullPath(Path.Combine(root, refitGeneratorSettings.ContractsOutputFolder!)); + return Path.Combine(contractsFolder, outputFile.Filename); + } + + private static bool IsDirectCliGeneration(string? settingsFilePath) => + string.IsNullOrWhiteSpace(settingsFilePath); + + private static bool HasExplicitCliOutputOverride(string? cliOutputPath) => + !string.IsNullOrWhiteSpace(cliOutputPath) && + cliOutputPath != DefaultOutputPath; + + private static string CombineWithSettingsRoot(string root, params string[] segments) + { + var combinedPath = Path.Combine(segments); + return !string.IsNullOrWhiteSpace(root) && !Path.IsPathRooted(combinedPath) + ? Path.Combine(root, combinedPath) + : combinedPath; + } +} diff --git a/src/Refitter.Core/RefitterSettingsLoader.cs b/src/Refitter.Core/RefitterSettingsLoader.cs index 4dfcc8ddc..26fc7d34b 100644 --- a/src/Refitter.Core/RefitterSettingsLoader.cs +++ b/src/Refitter.Core/RefitterSettingsLoader.cs @@ -57,6 +57,28 @@ public static void ResolveRelativeSpecPaths(RefitGeneratorSettings settings, str } } + /// + /// Applies default values to settings that are not explicitly set in a settings file. + /// + /// The path to the settings file. + /// The settings to apply defaults to. + public static void ApplyDefaults(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) + { + if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder)) + refitGeneratorSettings.GenerateMultipleFiles = true; + + if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder)) + refitGeneratorSettings.OutputFolder = RefitGeneratorSettings.DefaultOutputFolder; + + if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFilename)) + { + var refitterFileName = Path.GetFileNameWithoutExtension(settingsFilePath); + if (string.IsNullOrEmpty(refitterFileName)) + refitterFileName = "Output"; + refitGeneratorSettings.OutputFilename = $"{refitterFileName}.cs"; + } + } + /// /// Determines whether the specified path is an absolute HTTP or HTTPS URL. /// diff --git a/src/Refitter/Validation/OpenApiStats.cs b/src/Refitter.Core/Validation/OpenApiStats.cs similarity index 98% rename from src/Refitter/Validation/OpenApiStats.cs rename to src/Refitter.Core/Validation/OpenApiStats.cs index 0c8d5eaf0..bcfc815f5 100644 --- a/src/Refitter/Validation/OpenApiStats.cs +++ b/src/Refitter.Core/Validation/OpenApiStats.cs @@ -1,6 +1,6 @@ using Microsoft.OpenApi; -namespace Refitter.Validation; +namespace Refitter.Core.Validation; public class OpenApiStats : OpenApiVisitorBase { diff --git a/src/Refitter/Validation/OpenApiValidationException.cs b/src/Refitter.Core/Validation/OpenApiValidationException.cs similarity index 89% rename from src/Refitter/Validation/OpenApiValidationException.cs rename to src/Refitter.Core/Validation/OpenApiValidationException.cs index 62d286c3b..24c40c116 100644 --- a/src/Refitter/Validation/OpenApiValidationException.cs +++ b/src/Refitter.Core/Validation/OpenApiValidationException.cs @@ -1,4 +1,4 @@ -namespace Refitter.Validation; +namespace Refitter.Core.Validation; public class OpenApiValidationException : Exception { diff --git a/src/Refitter/Validation/OpenApiValidationResult.cs b/src/Refitter.Core/Validation/OpenApiValidationResult.cs similarity index 74% rename from src/Refitter/Validation/OpenApiValidationResult.cs rename to src/Refitter.Core/Validation/OpenApiValidationResult.cs index d76bd2824..c66af8d0e 100644 --- a/src/Refitter/Validation/OpenApiValidationResult.cs +++ b/src/Refitter.Core/Validation/OpenApiValidationResult.cs @@ -2,9 +2,9 @@ using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; -namespace Refitter.Validation; +namespace Refitter.Core.Validation; -[ExcludeFromCodeCoverage(Justification = "Simple data record used for CLI validation result passing")] +[ExcludeFromCodeCoverage] public record OpenApiValidationResult( OpenApiDiagnostic Diagnostics, OpenApiStats Statistics) diff --git a/src/Refitter/Validation/OpenApiValidator.cs b/src/Refitter.Core/Validation/OpenApiValidator.cs similarity index 93% rename from src/Refitter/Validation/OpenApiValidator.cs rename to src/Refitter.Core/Validation/OpenApiValidator.cs index c80eff379..9a7a69448 100644 --- a/src/Refitter/Validation/OpenApiValidator.cs +++ b/src/Refitter.Core/Validation/OpenApiValidator.cs @@ -1,7 +1,7 @@ using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; -namespace Refitter.Validation; +namespace Refitter.Core.Validation; public static class OpenApiValidator { diff --git a/src/Refitter.MSBuild/DefaultProcessRunner.cs b/src/Refitter.MSBuild/DefaultProcessRunner.cs deleted file mode 100644 index c5def27e1..000000000 --- a/src/Refitter.MSBuild/DefaultProcessRunner.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Diagnostics; - -namespace Refitter.MSBuild; - -/// -/// Default implementation of that uses -/// -public sealed class DefaultProcessRunner : IProcessRunner -{ - private static readonly int DefaultTimeoutMilliseconds = 300000; - - /// - /// Gets or sets the process timeout in milliseconds - /// - public int TimeoutMilliseconds { get; set; } = DefaultTimeoutMilliseconds; - - /// - /// Gets or sets the action to terminate a timed-out process - /// - public Action ProcessTerminator { get; set; } = DefaultTerminateProcess; - - /// - public ProcessExecutionResult Run( - ProcessStartInfo startInfo, - Action handleStandardOutput, - Action handleErrorOutput) - { - using var process = new Process(); - process.StartInfo = startInfo; - - process.ErrorDataReceived += (_, args) => handleErrorOutput(args.Data); - process.OutputDataReceived += (_, args) => handleStandardOutput(args.Data); - process.Start(); - process.BeginErrorReadLine(); - process.BeginOutputReadLine(); - - if (!process.WaitForExit(TimeoutMilliseconds)) - { - try - { - ProcessTerminator(process); - return new ProcessExecutionResult(true, -1); - } - catch (Exception ex) - { - return new ProcessExecutionResult(true, -1, ex); - } - } - - process.WaitForExit(); - return new ProcessExecutionResult(false, process.ExitCode); - } - - private static void DefaultTerminateProcess(Process process) => process.Kill(); -} diff --git a/src/Refitter.MSBuild/DefaultRuntimeResolver.cs b/src/Refitter.MSBuild/DefaultRuntimeResolver.cs deleted file mode 100644 index e180523d5..000000000 --- a/src/Refitter.MSBuild/DefaultRuntimeResolver.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; - -namespace Refitter.MSBuild; - -/// -/// Default implementation of that runs 'dotnet --list-runtimes' -/// -public sealed class DefaultRuntimeResolver : IRuntimeResolver -{ - private readonly IProcessRunner processRunner; - - /// - /// Gets or sets the timeout in milliseconds used for error message formatting - /// - public int TimeoutMilliseconds { get; set; } = RefitterGenerateTask.DefaultProcessTimeoutMilliseconds; - - /// - /// Initializes a new instance of the class. - /// - public DefaultRuntimeResolver(IProcessRunner processRunner) - { - this.processRunner = processRunner; - } - - /// - public List GetInstalledRuntimes() - { - var installedRuntimes = new List(); - var errorLines = new List(); - var processResult = processRunner.Run( - new ProcessStartInfo - { - FileName = "dotnet", - Arguments = "--list-runtimes", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - data => AddProcessOutputLine(data, installedRuntimes), - data => AddProcessOutputLine(data, errorLines)); - - if (processResult.TimedOut) - { - var timeoutDescription = FormatTimeout(TimeoutMilliseconds); - if (processResult.TerminationException is null) - { - throw new TimeoutException($"dotnet --list-runtimes timed out after {timeoutDescription}"); - } - - throw new TimeoutException( - $"dotnet --list-runtimes timed out after {timeoutDescription}. Failed to terminate timed-out process: {processResult.TerminationException.Message}", - processResult.TerminationException); - } - - if (processResult.ExitCode != 0) - { - var errorDetails = errorLines.Count > 0 - ? $"{Environment.NewLine}{string.Join(Environment.NewLine, errorLines)}" - : string.Empty; - throw new InvalidOperationException($"dotnet --list-runtimes exited with code {processResult.ExitCode}{errorDetails}"); - } - - return installedRuntimes; - } - - internal static string FormatTimeout(int timeoutMilliseconds) - { - if (timeoutMilliseconds < 1000) - return $"{timeoutMilliseconds} ms"; - - if (timeoutMilliseconds % 1000 == 0) - return $"{timeoutMilliseconds / 1000} seconds"; - - return $"{timeoutMilliseconds / 1000d:0.###} seconds"; - } - - private static void AddProcessOutputLine(string? outputLine, ICollection outputLines) - { - if (!string.IsNullOrWhiteSpace(outputLine)) - outputLines.Add(outputLine!); - } -} diff --git a/src/Refitter.MSBuild/IProcessRunner.cs b/src/Refitter.MSBuild/IProcessRunner.cs deleted file mode 100644 index f04cf1baf..000000000 --- a/src/Refitter.MSBuild/IProcessRunner.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Diagnostics; - -namespace Refitter.MSBuild; - -/// -/// Abstraction for running a process -/// -public interface IProcessRunner -{ - /// - /// Runs a process with the specified start info and output handlers - /// - ProcessExecutionResult Run( - ProcessStartInfo startInfo, - Action standardOutput, - Action standardError); -} diff --git a/src/Refitter.MSBuild/IRuntimeResolver.cs b/src/Refitter.MSBuild/IRuntimeResolver.cs deleted file mode 100644 index 0cce4bf89..000000000 --- a/src/Refitter.MSBuild/IRuntimeResolver.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace Refitter.MSBuild; - -/// -/// Abstraction for discovering installed .NET runtimes -/// -public interface IRuntimeResolver -{ - /// - /// Gets the list of installed .NET runtimes - /// - List GetInstalledRuntimes(); -} diff --git a/src/Refitter.MSBuild/ProcessExecutionResult.cs b/src/Refitter.MSBuild/ProcessExecutionResult.cs deleted file mode 100644 index 269fbdea0..000000000 --- a/src/Refitter.MSBuild/ProcessExecutionResult.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace Refitter.MSBuild; - -/// -/// Represents the result of executing a process -/// -public sealed class ProcessExecutionResult( - bool timedOut, - int exitCode, - Exception? terminationException = null) -{ - /// - /// Whether the process timed out - /// - public bool TimedOut { get; } = timedOut; - - /// - /// The exit code of the process - /// - public int ExitCode { get; } = exitCode; - - /// - /// Exception thrown during process termination, if any - /// - public Exception? TerminationException { get; } = terminationException; -} diff --git a/src/Refitter.MSBuild/Refitter.MSBuild.csproj b/src/Refitter.MSBuild/Refitter.MSBuild.csproj index eee5dcef5..f5fcdc33d 100644 --- a/src/Refitter.MSBuild/Refitter.MSBuild.csproj +++ b/src/Refitter.MSBuild/Refitter.MSBuild.csproj @@ -1,4 +1,5 @@ - + + netstandard2.0 @@ -10,6 +11,8 @@ A MSBuid task that uses Refitter for generating a REST API Client using the Refit library from OpenAPI specifications. true tasks + true + true @@ -19,14 +22,33 @@ ExcludeAssets="runtime"/> + + + all + + + - - - + + + + <_PackageFiles Include="bin\$(Configuration)\$(TargetFramework)\*.dll" + PackagePath="tasks\$(TargetFramework)"> + false + None + + <_PackageFiles Include="bin\$(Configuration)\$(TargetFramework)\*.pdb" + PackagePath="tasks\$(TargetFramework)"> + false + None + + + + diff --git a/src/Refitter.MSBuild/RefitterGenerateTask.cs b/src/Refitter.MSBuild/RefitterGenerateTask.cs index 33d6b2b6d..538fbb67a 100644 --- a/src/Refitter.MSBuild/RefitterGenerateTask.cs +++ b/src/Refitter.MSBuild/RefitterGenerateTask.cs @@ -1,38 +1,13 @@ using System.Diagnostics; -using System.Reflection; using Microsoft.Build.Framework; +using Refitter.Core; +using Refitter.Core.Validation; using MSBuildTask = Microsoft.Build.Utilities.Task; namespace Refitter.MSBuild; public class RefitterGenerateTask : MSBuildTask { - internal const string GeneratedFileMarker = "GeneratedFile: "; - - internal const int DefaultProcessTimeoutMilliseconds = 300000; - - private static readonly (string TargetFramework, string RuntimePrefix)[] PreferredRuntimeOrder = - [ - ("net10.0", "Microsoft.NETCore.App 10."), - ("net9.0", "Microsoft.NETCore.App 9."), - ("net8.0", "Microsoft.NETCore.App 8.") - ]; - - private static readonly string[] CompatibilityFallbackOrder = - [ - "net8.0", - "net9.0", - "net10.0" - ]; - - public IProcessRunner ProcessRunner { get; set; } - - public IRuntimeResolver RuntimeResolver { get; set; } - - public Func FileExists { get; set; } - - public int ProcessTimeoutMilliseconds { get; set; } = DefaultProcessTimeoutMilliseconds; - public string ProjectFileDirectory { get; set; } public bool DisableLogging { get; set; } @@ -44,17 +19,6 @@ private static readonly (string TargetFramework, string RuntimePrefix)[] Preferr [Output] public ITaskItem[] GeneratedFiles { get; set; } - public RefitterGenerateTask() - { - var processRunner = new DefaultProcessRunner - { - TimeoutMilliseconds = ProcessTimeoutMilliseconds - }; - ProcessRunner = processRunner; - RuntimeResolver = new DefaultRuntimeResolver(processRunner); - FileExists = System.IO.File.Exists; - } - public override bool Execute() { TryLogCommandLine($"Starting {nameof(RefitterGenerateTask)}"); @@ -75,189 +39,123 @@ public override bool Execute() foreach (var file in files) { TryLogCommandLine($"Processing {file}"); - var generated = TryExecuteRefitter(file, out var failed); - if (failed) + try { - hasErrors = true; - TryLogError($"Failed to generate code from {file}"); + var generated = ProcessRefitterFile(file); + if (generated.Count > 0) + { + generatedFiles.AddRange(generated); + } } - else if (generated != null) + catch (Exception e) { - generatedFiles.AddRange(generated); + hasErrors = true; + TryLogError($"Failed to generate code from {file}"); + TryLogErrorFromException(e); } } - GeneratedFiles = generatedFiles.Select(f => new Microsoft.Build.Utilities.TaskItem(f)).ToArray(); + GeneratedFiles = generatedFiles + .Select(f => new Microsoft.Build.Utilities.TaskItem(f)) + .ToArray(); + TryLogCommandLine($"Generated {GeneratedFiles.Length} files"); return !hasErrors; } - private List? TryExecuteRefitter(string file, out bool failed) + private List ProcessRefitterFile(string filePath) { - failed = false; - try - { - return StartProcess(file, out failed); - } - catch (Exception e) - { - failed = true; - TryLogErrorFromException(e); - return null; - } - } + var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath))!; + var json = File.ReadAllText(filePath); + var settings = RefitterSettingsLoader.Load(json, baseDirectory); + RefitterSettingsLoader.ApplyDefaults(filePath, settings); - private List StartProcess(string file, out bool failed) - { - failed = false; - var assembly = Assembly.GetExecutingAssembly(); - var packageFolder = Path.GetDirectoryName(assembly.Location); - var outputLines = new List(); + var generator = RefitGenerator.CreateAsync(settings) + .GetAwaiter().GetResult(); - List? installedRuntimes = null; - try - { - installedRuntimes = RuntimeResolver.GetInstalledRuntimes(); - } - catch (Exception exception) - { - TryLogCommandLine($"Failed to inspect installed .NET runtimes: {exception.Message}. Falling back to bundled Refitter runtime selection."); - } - - var refitterDll = ResolveRefitterDll(packageFolder, installedRuntimes, TryLogCommandLine, FileExists); - if (string.IsNullOrWhiteSpace(refitterDll) || !FileExists(refitterDll!)) - { - failed = true; - TryLogError("Unable to locate a bundled Refitter CLI runtime for the MSBuild task."); - return new(); - } - - var args = $"\"{refitterDll}\" --settings-file \"{file}\" --simple-output"; - if (DisableLogging) - { - args += " --no-logging"; - } - if (SkipValidation) - { - args += " --skip-validation"; - } - - TryLogCommandLine($"Starting dotnet {args}"); + var generatedFiles = new List(); - var startInfo = new ProcessStartInfo + if (settings.GenerateMultipleFiles) { - FileName = "dotnet", - Arguments = args, - WorkingDirectory = Path.GetDirectoryName(file)!, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - var processResult = ProcessRunner.Run( - startInfo, - data => HandleProcessStandardOutput(data, outputLines, outputLines, TryLogCommandLine), - data => HandleProcessErrorOutput(data, TryLogError)); - - if (processResult.TimedOut) - { - failed = true; - var timeoutDescription = FormatTimeout(ProcessTimeoutMilliseconds); - TryLogError( - processResult.TerminationException is null - ? $"Refitter process timed out after {timeoutDescription} and was terminated" - : $"Refitter process timed out after {timeoutDescription}. Failed to terminate timed-out process: {processResult.TerminationException.Message}"); - - return new(); + var output = generator.GenerateMultipleFiles(); + foreach (var outputFile in output.Files) + { + var outputPath = OutputPlanner.GetMultiFileOutputPath( + filePath, + cliOutputPath: null, + settings, + outputFile); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(outputPath, outputFile.Content); + generatedFiles.Add(Path.GetFullPath(outputPath)); + } } - - if (processResult.ExitCode != 0) + else { - failed = true; - TryLogError($"Refitter process exited with code {processResult.ExitCode}"); - return new List(); - } + var code = generator.Generate().Replace("\r\n", "\n"); + var outputPath = OutputPlanner.GetSingleFileOutputPath( + filePath, + cliOutputPath: null, + settings); - return ResolveGeneratedFiles(outputLines, file, out failed, TryLogError); - } + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); - internal static string? ResolveRefitterDll( - string? packageFolder, - IReadOnlyList? installedRuntimes, - Action logCommandLine, - Func fileExists) - { - if (string.IsNullOrWhiteSpace(packageFolder)) - { - return null; + File.WriteAllText(outputPath, code); + generatedFiles.Add(Path.GetFullPath(outputPath)); } - var bundledRuntimes = PreferredRuntimeOrder - .Select(candidate => new - { - candidate.TargetFramework, - candidate.RuntimePrefix, - Path = Path.GetFullPath(Path.Combine(packageFolder, "..", candidate.TargetFramework, "refitter.dll")), - }) - .ToArray(); - - if (installedRuntimes is not null) + if (!SkipValidation) { - var detectedRuntimes = installedRuntimes - .Where(installed => !string.IsNullOrWhiteSpace(installed)) - .ToArray(); + var openApiPaths = settings.OpenApiPaths is { Length: > 0 } + ? settings.OpenApiPaths + : settings.OpenApiPath is not null + ? [settings.OpenApiPath] + : []; - foreach (var runtime in bundledRuntimes.Where(runtime => fileExists(runtime.Path))) + foreach (var specPath in openApiPaths) { - if (detectedRuntimes.Any(installed => - installed.StartsWith(runtime.RuntimePrefix, StringComparison.Ordinal))) + if (!string.IsNullOrWhiteSpace(specPath)) { - logCommandLine($"Detected {GetDisplayFramework(runtime.TargetFramework)} runtime. Using {GetDisplayFramework(runtime.TargetFramework)} version of Refitter."); - return runtime.Path; + var validationResult = OpenApiValidator.Validate(specPath) + .GetAwaiter().GetResult(); + validationResult.ThrowIfInvalid(); } } } - foreach (var targetFramework in CompatibilityFallbackOrder) - { - var fallbackPath = bundledRuntimes - .First(runtime => runtime.TargetFramework == targetFramework) - .Path; - - if (fileExists(fallbackPath)) - { - logCommandLine($"Falling back to bundled {GetDisplayFramework(targetFramework)} version of Refitter."); - return fallbackPath; - } - } - - var coLocatedCli = Path.GetFullPath(Path.Combine(packageFolder, "refitter.dll")); - if (fileExists(coLocatedCli)) - { - logCommandLine("Falling back to co-located Refitter CLI."); - return coLocatedCli; - } - - return bundledRuntimes - .Select(runtime => runtime.Path) - .FirstOrDefault(); + return generatedFiles; } - private static string FormatTimeout(int timeoutMilliseconds) + internal static string[] FilterFiles(string[] files, string includePatterns, string projectFileDirectory) { - if (timeoutMilliseconds < 1000) - return $"{timeoutMilliseconds} ms"; + if (string.IsNullOrWhiteSpace(includePatterns)) + return files; - if (timeoutMilliseconds % 1000 == 0) - return $"{timeoutMilliseconds / 1000} seconds"; + var patterns = includePatterns.Split([';'], StringSplitOptions.RemoveEmptyEntries) + .Select(NormalizeIncludePattern) + .ToList(); - return $"{timeoutMilliseconds / 1000d:0.###} seconds"; - } + return files.Where(file => + { + var fileName = NormalizeIncludePattern(Path.GetFileName(file)); + var relativePath = string.IsNullOrWhiteSpace(projectFileDirectory) + ? fileName + : NormalizeIncludePattern(GetRelativePath(projectFileDirectory, file)); + var fullPath = NormalizeIncludePattern(Path.GetFullPath(file)); - private static string GetDisplayFramework(string targetFramework) => targetFramework.Replace("net", ".NET "); + return patterns.Any(pattern => + fileName.Equals(pattern, StringComparison.OrdinalIgnoreCase) || + relativePath.Equals(pattern, StringComparison.OrdinalIgnoreCase) || + fullPath.Equals(pattern, StringComparison.OrdinalIgnoreCase)); + }).ToArray(); + } private void TryLogErrorFromException(Exception e) { @@ -295,94 +193,6 @@ private void TryLogError(string text) } } - internal static string[] FilterFiles(string[] files, string includePatterns, string projectFileDirectory) - { - if (string.IsNullOrWhiteSpace(includePatterns)) - { - return files; - } - - var patterns = includePatterns.Split([';'], StringSplitOptions.RemoveEmptyEntries) - .Select(NormalizeIncludePattern) - .ToList(); - - return files.Where(file => - { - var fileName = NormalizeIncludePattern(Path.GetFileName(file)); - var relativePath = string.IsNullOrWhiteSpace(projectFileDirectory) - ? fileName - : NormalizeIncludePattern(GetRelativePath(projectFileDirectory, file)); - var fullPath = NormalizeIncludePattern(Path.GetFullPath(file)); - - return patterns.Any(pattern => - fileName.Equals(pattern, StringComparison.OrdinalIgnoreCase) || - relativePath.Equals(pattern, StringComparison.OrdinalIgnoreCase) || - fullPath.Equals(pattern, StringComparison.OrdinalIgnoreCase)); - }).ToArray(); - } - - internal static string? ParseGeneratedFilePath(string? outputLine) - { - var markerLine = outputLine ?? string.Empty; - if (string.IsNullOrWhiteSpace(markerLine)) - return null; - - if (!markerLine.StartsWith(GeneratedFileMarker, StringComparison.Ordinal)) - return null; - - var generatedFilePath = markerLine.Substring(GeneratedFileMarker.Length).Trim(); - return string.IsNullOrWhiteSpace(generatedFilePath) ? null : generatedFilePath; - } - - internal static void HandleProcessErrorOutput(string? outputLine, Action logError) - { - if (string.IsNullOrWhiteSpace(outputLine)) - return; - - logError(outputLine!); - } - - internal static void HandleProcessStandardOutput(string? outputLine, ICollection outputLines, object outputLinesLock, Action logCommandLine) - { - if (string.IsNullOrWhiteSpace(outputLine)) - return; - - lock (outputLinesLock) - { - outputLines.Add(outputLine!); - } - - logCommandLine(outputLine!); - } - - internal static List ResolveGeneratedFiles(IEnumerable outputLines, string settingsFilePath, out bool failed, Action logError) - { - var existingGeneratedFiles = ResolveGeneratedFiles(outputLines, settingsFilePath, out var errorMessage); - failed = errorMessage is not null; - if (failed) - { - logError(errorMessage!); - } - - return existingGeneratedFiles; - } - - internal static List ResolveGeneratedFiles(IEnumerable outputLines, string settingsFilePath, out string? errorMessage) - { - var existingGeneratedFiles = outputLines - .Select(ParseGeneratedFilePath) - .Where(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)) - .Select(path => path!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - errorMessage = existingGeneratedFiles.Count == 0 - ? $"Refitter did not report any generated files for {settingsFilePath}" - : null; - - return existingGeneratedFiles; - } - private static string NormalizeIncludePattern(string path) { var normalizedPath = path diff --git a/src/Refitter.Tests/GenerateCommandTests.cs b/src/Refitter.Tests/GenerateCommandTests.cs index 1ed881016..6bb535447 100644 --- a/src/Refitter.Tests/GenerateCommandTests.cs +++ b/src/Refitter.Tests/GenerateCommandTests.cs @@ -198,16 +198,6 @@ public void Command_Should_Have_Protected_ExecuteAsync_Method() method!.IsFamily.Should().BeTrue(); } - [Test] - public void FormatGeneratedFileMarker_Should_Emit_A_Task_Parseable_Full_Path() - { - var generatedFile = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Generated", "Petstore.cs")); - var outputLine = GenerateCommand.FormatGeneratedFileMarker(generatedFile); - - outputLine.Should().Be($"{GenerateCommand.GeneratedFileMarker}{generatedFile}"); - Refitter.MSBuild.RefitterGenerateTask.ParseGeneratedFilePath(outputLine).Should().Be(generatedFile); - } - [Test] public void GetOutputPath_Should_Root_Relative_To_SettingsFile_When_OutputFolder_Is_Default() { @@ -331,10 +321,7 @@ public void GetOutputPath_Should_Use_SettingsFileName_When_OutputFilename_Is_Mis }; // Apply defaults first (simulates what GenerateCommand does) - var applyMethod = typeof(GenerateCommand).GetMethod( - "ApplySettingsFileDefaults", - BindingFlags.NonPublic | BindingFlags.Static); - applyMethod!.Invoke(null, [settingsFilePath, refitSettings]); + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); var method = typeof(GenerateCommand).GetMethod( "GetOutputPath", @@ -408,7 +395,7 @@ public void GetOutputPath_For_MultipleFiles_WithSettingsFile_Should_Use_Explicit } [Test] - public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() + public void ApplyDefaults_Should_Set_Default_OutputFolder() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings @@ -416,18 +403,13 @@ public void ApplySettingsFileDefaults_Should_Set_Default_OutputFolder() OutputFolder = null!, }; - var method = typeof(GenerateCommand).GetMethod( - "ApplySettingsFileDefaults", - BindingFlags.NonPublic | BindingFlags.Static); - - method.Should().NotBeNull(); - method!.Invoke(null, [settingsFilePath, refitSettings]); + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); refitSettings.OutputFolder.Should().Be("./Generated"); } [Test] - public void ApplySettingsFileDefaults_Should_Preserve_Explicit_OutputFolder() + public void ApplyDefaults_Should_Preserve_Explicit_OutputFolder() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings @@ -435,17 +417,13 @@ public void ApplySettingsFileDefaults_Should_Preserve_Explicit_OutputFolder() OutputFolder = "./CustomFolder" }; - var method = typeof(GenerateCommand).GetMethod( - "ApplySettingsFileDefaults", - BindingFlags.NonPublic | BindingFlags.Static); - - method!.Invoke(null, [settingsFilePath, refitSettings]); + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); refitSettings.OutputFolder.Should().Be("./CustomFolder"); } [Test] - public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder() + public void ApplyDefaults_Should_Set_Default_When_Empty_OutputFolder() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); var refitSettings = new RefitGeneratorSettings @@ -453,11 +431,7 @@ public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder OutputFolder = string.Empty }; - var method = typeof(GenerateCommand).GetMethod( - "ApplySettingsFileDefaults", - BindingFlags.NonPublic | BindingFlags.Static); - - method!.Invoke(null, [settingsFilePath, refitSettings]); + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); refitSettings.OutputFolder.Should().Be("./Generated"); } @@ -466,20 +440,16 @@ public void ApplySettingsFileDefaults_Should_Set_Default_When_Empty_OutputFolder public void ResolveRelativeSpecPaths_Should_Normalize_Relative_OpenApiPaths_And_Preserve_Urls() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var baseDirectory = Path.GetDirectoryName(Path.GetFullPath(settingsFilePath))!; var refitSettings = new RefitGeneratorSettings { OpenApiPaths = ["specs/first.json", "https://example.com/openapi.json"] }; - var method = typeof(GenerateCommand).GetMethod( - "ResolveRelativeSpecPaths", - BindingFlags.NonPublic | BindingFlags.Static); - - method.Should().NotBeNull(); - method!.Invoke(null, [settingsFilePath, refitSettings]); + RefitterSettingsLoader.ResolveRelativeSpecPaths(refitSettings, baseDirectory); refitSettings.OpenApiPaths.Should().Equal( - Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "specs", "first.json")), + Path.GetFullPath(Path.Combine(baseDirectory, "specs", "first.json")), "https://example.com/openapi.json"); } diff --git a/src/Refitter.Tests/GenerationOrchestratorTests.cs b/src/Refitter.Tests/GenerationOrchestratorTests.cs index 2006a163a..9b51a9b2b 100644 --- a/src/Refitter.Tests/GenerationOrchestratorTests.cs +++ b/src/Refitter.Tests/GenerationOrchestratorTests.cs @@ -2,7 +2,7 @@ using FluentAssertions; using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; using TUnit.Core; namespace Refitter.Tests; diff --git a/src/Refitter.Tests/MSBuild/RefitterGenerateTaskTests.cs b/src/Refitter.Tests/MSBuild/RefitterGenerateTaskTests.cs index a9418e74d..ac172860a 100644 --- a/src/Refitter.Tests/MSBuild/RefitterGenerateTaskTests.cs +++ b/src/Refitter.Tests/MSBuild/RefitterGenerateTaskTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Reflection; using FluentAssertions; using Microsoft.Build.Framework; @@ -139,153 +138,6 @@ public void FilterFiles_Should_Handle_Project_Directory_With_Trailing_Separator( result.Should().ContainSingle().Which.Should().Be(Path.Combine(projectRoot, "apis", "petstore.refitter")); } - [Test] - public void ParseGeneratedFilePath_Should_Return_File_Path_From_Marker() - { - var generatedFile = Path.Combine("C:", "repo", "Generated", "Petstore.cs"); - - var result = RefitterGenerateTask.ParseGeneratedFilePath($"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}"); - - result.Should().Be(generatedFile); - } - - [Test] - public void ParseGeneratedFilePath_Should_Return_Null_For_Empty_Marker_Path() - { - var result = RefitterGenerateTask.ParseGeneratedFilePath(RefitterGenerateTask.GeneratedFileMarker); - - result.Should().BeNull(); - } - - [Test] - public void ParseGeneratedFilePath_Should_Return_Null_For_Whitespace_Output() - { - var result = RefitterGenerateTask.ParseGeneratedFilePath(" "); - - result.Should().BeNull(); - } - - [Test] - public void ParseGeneratedFilePath_Should_Return_Null_For_Null_Output() - { - var result = RefitterGenerateTask.ParseGeneratedFilePath(null); - - result.Should().BeNull(); - } - - [Test] - public void ParseGeneratedFilePath_Should_Ignore_Non_Marker_Output() - { - var result = RefitterGenerateTask.ParseGeneratedFilePath("Generated Output"); - - result.Should().BeNull(); - } - - [Test] - public void HandleProcessErrorOutput_Should_Ignore_Whitespace() - { - var messages = new List(); - - RefitterGenerateTask.HandleProcessErrorOutput(" ", messages.Add); - - messages.Should().BeEmpty(); - } - - [Test] - public void HandleProcessErrorOutput_Should_Log_Non_Whitespace() - { - var messages = new List(); - - RefitterGenerateTask.HandleProcessErrorOutput("stderr output", messages.Add); - - messages.Should().ContainSingle().Which.Should().Be("stderr output"); - } - - [Test] - public void HandleProcessStandardOutput_Should_Ignore_Whitespace() - { - var outputLines = new List(); - var logged = new List(); - - RefitterGenerateTask.HandleProcessStandardOutput(" ", outputLines, new object(), logged.Add); - - outputLines.Should().BeEmpty(); - logged.Should().BeEmpty(); - } - - [Test] - public void HandleProcessStandardOutput_Should_Record_And_Log_Output() - { - var outputLines = new List(); - var logged = new List(); - - RefitterGenerateTask.HandleProcessStandardOutput("Generated output", outputLines, new object(), logged.Add); - - outputLines.Should().ContainSingle().Which.Should().Be("Generated output"); - logged.Should().ContainSingle().Which.Should().Be("Generated output"); - } - - [Test] - public void ResolveGeneratedFiles_Should_Deduplicate_Duplicate_Markers() - { - var workspace = CreateWorkspace(); - - try - { - var generatedFile = Path.Combine(workspace, "Generated", "Petstore.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(generatedFile)!); - File.WriteAllText(generatedFile, "// generated"); - - var outputLines = new[] - { - "Generated Output", - $"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}", - $"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile.ToUpperInvariant()}" - }; - - var result = RefitterGenerateTask.ResolveGeneratedFiles(outputLines, "petstore.refitter", out var errorMessage); - - result.Should().ContainSingle().Which.Should().Be(generatedFile); - errorMessage.Should().BeNull(); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void ResolveGeneratedFiles_Should_Fail_When_No_Markers_Are_Reported() - { - var outputLines = new[] - { - "Generating code...", - "Generation completed successfully!" - }; - - var result = RefitterGenerateTask.ResolveGeneratedFiles(outputLines, "petstore.refitter", out var errorMessage); - - result.Should().BeEmpty(); - errorMessage.Should().Be("Refitter did not report any generated files for petstore.refitter"); - } - - [Test] - public void ResolveGeneratedFiles_Should_Log_Error_And_Set_Failed_When_No_Markers_Are_Reported() - { - var outputLines = new[] - { - "Generating code...", - "Generation completed successfully!" - }; - var errors = new List(); - - var result = RefitterGenerateTask.ResolveGeneratedFiles(outputLines, "petstore.refitter", out var failed, errors.Add); - - result.Should().BeEmpty(); - failed.Should().BeTrue(); - errors.Should().ContainSingle().Which.Should().Be("Refitter did not report any generated files for petstore.refitter"); - } - [Test] public void Execute_Should_Generate_Files_Reported_By_Refitter() { @@ -377,235 +229,31 @@ public void Execute_Should_Generate_Files_Reported_By_Refitter() } [Test] - public void Execute_Should_Return_False_When_Runtime_Discovery_Throws() + public void Execute_Should_Skip_Validation_When_SkipValidation_Is_True() { var workspace = CreateWorkspace(); try { - CreateRefitterSettingsFile(workspace); - var generatedFile = CreateGeneratedFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.RuntimeResolver = new DelegatingRuntimeResolver { Handler = () => throw new InvalidOperationException("boom") }; - task.FileExists = path => - path.EndsWith("refitter.dll", StringComparison.OrdinalIgnoreCase) || File.Exists(path); - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (startInfo, logOutput, _) => - { - startInfo.Arguments.Should().Contain("net8.0"); - logOutput($"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}"); - return new ProcessExecutionResult(false, 0); - } - }; - - var result = task.Execute(); - - result.Should().BeTrue(); - task.GeneratedFiles.Should().ContainSingle().Which.ItemSpec.Should().Be(generatedFile); - buildEngine.Messages.Should().Contain(message => message.Contains("Failed to inspect installed .NET runtimes", StringComparison.Ordinal)); - buildEngine.Messages.Should().Contain(message => message.Contains("Falling back to bundled .NET 8.0 version of Refitter.", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void ResolveRefitterDll_Should_Fall_Back_When_Preferred_Runtime_Binary_Is_Missing() - { - var packageFolder = Path.Combine("C:", "repo", "tasks"); - var messages = new List(); - - var result = RefitterGenerateTask.ResolveRefitterDll( - packageFolder, - ["Microsoft.NETCore.App 10.0.1", "Microsoft.NETCore.App 9.0.5"], - messages.Add, - path => path.Contains("net9.0", StringComparison.OrdinalIgnoreCase) || - path.Contains("net8.0", StringComparison.OrdinalIgnoreCase)); - - result.Should().Be(Path.GetFullPath(Path.Combine(packageFolder, "..", "net9.0", "refitter.dll"))); - messages.Should().Contain(message => message.Contains("Detected .NET 9.0 runtime", StringComparison.Ordinal)); - } - - [Test] - public void ResolveRefitterDll_Should_Return_Null_When_PackageFolder_Is_Blank() - { - var messages = new List(); - - var result = RefitterGenerateTask.ResolveRefitterDll(" ", ["Microsoft.NETCore.App 10.0.0"], messages.Add, _ => true); - - result.Should().BeNull(); - messages.Should().BeEmpty(); - } - - [Test] - public void ResolveRefitterDll_Should_Ignore_Whitespace_Runtime_Entries_When_Falling_Back() - { - var packageFolder = Path.Combine("C:", "repo", "tasks"); - var messages = new List(); - var net8Path = Path.GetFullPath(Path.Combine(packageFolder, "..", "net8.0", "refitter.dll")); - - var result = RefitterGenerateTask.ResolveRefitterDll( - packageFolder, - [" ", "Microsoft.NETCore.App 7.0.0"], - messages.Add, - path => string.Equals(path, net8Path, StringComparison.OrdinalIgnoreCase)); - - result.Should().Be(net8Path); - messages.Should().Contain(message => message.Contains("Falling back to bundled .NET 8.0 version of Refitter.", StringComparison.Ordinal)); - } - - [Test] - public void ResolveRefitterDll_Should_Fall_Back_To_CoLocated_Cli() - { - var packageFolder = Path.Combine("C:", "repo", "tasks"); - var messages = new List(); - var coLocatedCli = Path.GetFullPath(Path.Combine(packageFolder, "refitter.dll")); - - var result = RefitterGenerateTask.ResolveRefitterDll( - packageFolder, - ["Microsoft.NETCore.App 7.0.0"], - messages.Add, - path => string.Equals(path, coLocatedCli, StringComparison.OrdinalIgnoreCase)); - - result.Should().Be(coLocatedCli); - messages.Should().ContainSingle(message => message.Contains("Falling back to co-located Refitter CLI.", StringComparison.Ordinal)); - } - - [Test] - public void ResolveRefitterDll_Should_Return_First_Bundled_Path_When_No_Binaries_Exist() - { - var packageFolder = Path.Combine("C:", "repo", "tasks"); - var messages = new List(); - - var result = RefitterGenerateTask.ResolveRefitterDll(packageFolder, null, messages.Add, _ => false); - - result.Should().Be(Path.GetFullPath(Path.Combine(packageFolder, "..", "net10.0", "refitter.dll"))); - messages.Should().BeEmpty(); - } - - [Test] - public void GetInstalledDotnetRuntimes_Should_Throw_TimeoutException_When_Runtime_Discovery_Times_Out() - { - var processRunner = new DelegatingProcessRunner - { - Handler = (startInfo, _, _) => - { - startInfo.FileName.Should().Be("dotnet"); - startInfo.Arguments.Should().Be("--list-runtimes"); - return new ProcessExecutionResult(true, -1); - } - }; - - var resolver = new DefaultRuntimeResolver(processRunner) { TimeoutMilliseconds = 500 }; - var action = () => resolver.GetInstalledRuntimes(); - - action.Should().Throw() - .WithMessage("*dotnet --list-runtimes timed out after 500 ms*"); - } - - [Test] - public void GetInstalledDotnetRuntimes_Should_Include_Termination_Failure_When_Runtime_Discovery_Cannot_Be_Stopped() - { - var processRunner = new DelegatingProcessRunner - { - Handler = (startInfo, _, _) => - { - startInfo.FileName.Should().Be("dotnet"); - startInfo.Arguments.Should().Be("--list-runtimes"); - return new ProcessExecutionResult(true, -1, new InvalidOperationException("kill failed")); - } - }; - - var resolver = new DefaultRuntimeResolver(processRunner) { TimeoutMilliseconds = 500 }; - var action = () => resolver.GetInstalledRuntimes(); - - action.Should().Throw() - .WithMessage("*dotnet --list-runtimes timed out after 500 ms. Failed to terminate timed-out process: kill failed*") - .WithInnerException() - .WithMessage("kill failed"); - } - - [Test] - public void GetInstalledDotnetRuntimes_Should_Throw_When_Runtime_Discovery_Exits_NonZero() - { - var processRunner = new DelegatingProcessRunner - { - Handler = (startInfo, _, logError) => - { - startInfo.FileName.Should().Be("dotnet"); - startInfo.Arguments.Should().Be("--list-runtimes"); - logError("runtime probe failed"); - return new ProcessExecutionResult(false, 23); - } - }; - - var resolver = new DefaultRuntimeResolver(processRunner); - var action = () => resolver.GetInstalledRuntimes(); - - action.Should().Throw() - .WithMessage("*dotnet --list-runtimes exited with code 23*runtime probe failed*"); - } - - [Test] - public void GetInstalledDotnetRuntimes_Should_Throw_When_Runtime_Discovery_Exits_NonZero_Without_Error_Output() - { - var processRunner = new DelegatingProcessRunner - { - Handler = (startInfo, _, _) => - { - startInfo.FileName.Should().Be("dotnet"); - startInfo.Arguments.Should().Be("--list-runtimes"); - return new ProcessExecutionResult(false, 23); - } - }; - - var resolver = new DefaultRuntimeResolver(processRunner); - var action = () => resolver.GetInstalledRuntimes(); - - action.Should().Throw() - .WithMessage("dotnet --list-runtimes exited with code 23"); - } + var openApiPath = Path.Combine(workspace, "petstore.json"); + File.WriteAllText(openApiPath, "not-valid-json"); - [Test] - public void Execute_Should_Fall_Back_When_Runtime_Discovery_Times_Out() - { - var workspace = CreateWorkspace(); + var settingsPath = Path.Combine(workspace, "petstore.refitter"); + File.WriteAllText( + settingsPath, + """{"openApiPath": "petstore.json", "namespace": "Test"}"""); - try - { - CreateRefitterSettingsFile(workspace); - var generatedFile = CreateGeneratedFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessTimeoutMilliseconds = 500; - task.FileExists = path => - path.Contains("net8.0", StringComparison.OrdinalIgnoreCase) || File.Exists(path); - task.RuntimeResolver = new DelegatingRuntimeResolver - { - Handler = () => throw new TimeoutException("dotnet --list-runtimes timed out after 500 ms") - }; - task.ProcessRunner = new DelegatingProcessRunner + var task = new RefitterGenerateTask { - Handler = (startInfo, logOutput, _) => - { - startInfo.Arguments.Should().Contain("net8.0"); - logOutput($"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}"); - return new ProcessExecutionResult(false, 0); - } + BuildEngine = new RecordingBuildEngine(), + ProjectFileDirectory = workspace, + IncludePatterns = "petstore.refitter", + SkipValidation = true }; var result = task.Execute(); - result.Should().BeTrue(); - task.GeneratedFiles.Should().ContainSingle().Which.ItemSpec.Should().Be(generatedFile); - buildEngine.Messages.Should().Contain(message => message.Contains("Failed to inspect installed .NET runtimes", StringComparison.Ordinal)); - buildEngine.Messages.Should().Contain(message => message.Contains("Falling back to bundled .NET 8.0 version of Refitter.", StringComparison.Ordinal)); + result.Should().BeFalse(); } finally { @@ -614,128 +262,56 @@ public void Execute_Should_Fall_Back_When_Runtime_Discovery_Times_Out() } [Test] - public void Execute_Should_Use_DotNet9_Runtime_When_Available() + public void Execute_Should_Generate_Multiple_Files() { var workspace = CreateWorkspace(); try { - CreateRefitterSettingsFile(workspace); - var generatedFile = CreateGeneratedFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.RuntimeResolver = new DelegatingRuntimeResolver { Handler = () => ["Microsoft.NETCore.App 9.0.0"] }; - task.FileExists = path => - path.Contains("net9.0", StringComparison.OrdinalIgnoreCase) || - File.Exists(path); - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (startInfo, logOutput, _) => + var openApiPath = Path.Combine(workspace, "petstore.json"); + File.WriteAllText( + openApiPath, + """ { - startInfo.Arguments.Should().Contain("net9.0"); - logOutput($"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}"); - return new ProcessExecutionResult(false, 0); + "openapi": "3.0.0", + "info": { "title": "Petstore", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "tags": ["pets"], + "responses": { "200": { "description": "ok" } } + } + } + } } - }; - - var result = task.Execute(); - - result.Should().BeTrue(); - task.GeneratedFiles.Should().ContainSingle(); - task.GeneratedFiles.Single().ItemSpec.Should().Be(generatedFile); - buildEngine.Messages.Should().Contain(message => message.Contains("Detected .NET 9.0 runtime", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Fall_Back_To_DotNet8_Runtime_When_Newer_Runtimes_Are_Unavailable() - { - var workspace = CreateWorkspace(); + """); - try - { - CreateRefitterSettingsFile(workspace); - var generatedFile = CreateGeneratedFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.RuntimeResolver = new DelegatingRuntimeResolver { Handler = () => ["Microsoft.NETCore.App 8.0.0"] }; - task.FileExists = path => - path.Contains("net8.0", StringComparison.OrdinalIgnoreCase) || - File.Exists(path); - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (startInfo, logOutput, _) => + var settingsPath = Path.Combine(workspace, "petstore.refitter"); + File.WriteAllText( + settingsPath, + """ { - startInfo.Arguments.Should().Contain("net8.0"); - logOutput($"{RefitterGenerateTask.GeneratedFileMarker}{generatedFile}"); - return new ProcessExecutionResult(false, 0); + "openApiPath": "petstore.json", + "namespace": "Test", + "generateMultipleFiles": true, + "outputFolder": "./Generated" } - }; - - var result = task.Execute(); - - result.Should().BeTrue(); - task.GeneratedFiles.Should().ContainSingle(); - buildEngine.Messages.Should().Contain(message => message.Contains("Detected .NET 8.0 runtime", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Return_False_When_Refitter_Cli_Cannot_Be_Located() - { - var workspace = CreateWorkspace(); - - try - { - CreateRefitterSettingsFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.FileExists = _ => false; - - var result = task.Execute(); - - result.Should().BeFalse(); - task.GeneratedFiles.Should().BeEmpty(); - buildEngine.Errors.Should().Contain(message => message.Contains("Unable to locate a bundled Refitter CLI runtime for the MSBuild task.", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Log_Timeout_When_Process_Does_Not_Exit() - { - var workspace = CreateWorkspace(); - - try - { - CreateRefitterSettingsFile(workspace); + """); - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessRunner = new DelegatingProcessRunner + var task = new RefitterGenerateTask { - Handler = (_, _, _) => new ProcessExecutionResult(true, -1) + BuildEngine = new RecordingBuildEngine(), + ProjectFileDirectory = workspace, + IncludePatterns = "petstore.refitter", + SkipValidation = true }; var result = task.Execute(); - result.Should().BeFalse(); - task.GeneratedFiles.Should().BeEmpty(); - buildEngine.Errors.Should().Contain(message => message.Contains("timed out after 300 seconds", StringComparison.Ordinal)); + result.Should().BeTrue(); + task.GeneratedFiles.Should().NotBeEmpty(); + task.GeneratedFiles.Length.Should().BeGreaterThan(1); } finally { @@ -744,84 +320,52 @@ public void Execute_Should_Log_Timeout_When_Process_Does_Not_Exit() } [Test] - public void Execute_Should_Log_Millisecond_Timeout_Value() + public void Execute_Should_Apply_SettingsFileDefaults() { var workspace = CreateWorkspace(); try { - CreateRefitterSettingsFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessTimeoutMilliseconds = 500; - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (_, _, _) => new ProcessExecutionResult(true, -1) - }; - - var result = task.Execute(); - - result.Should().BeFalse(); - buildEngine.Errors.Should().Contain(message => message.Contains("timed out after 500 ms", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Log_When_Timed_Out_Process_Cannot_Be_Terminated() - { - var workspace = CreateWorkspace(); + var openApiPath = Path.Combine(workspace, "petstore.json"); + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Petstore", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); - try - { - CreateRefitterSettingsFile(workspace); + var settingsPath = Path.Combine(workspace, "petstore.refitter"); + File.WriteAllText( + settingsPath, + """{"openApiPath": "petstore.json", "namespace": "Test"}"""); - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessRunner = new DelegatingProcessRunner + var task = new RefitterGenerateTask { - Handler = (_, _, _) => new ProcessExecutionResult( - true, - -1, - new InvalidOperationException("termination failed")) + BuildEngine = new RecordingBuildEngine(), + ProjectFileDirectory = workspace, + IncludePatterns = "petstore.refitter", + SkipValidation = true }; var result = task.Execute(); - result.Should().BeFalse(); - buildEngine.Errors.Should().Contain(message => message.Contains("Failed to terminate timed-out process: termination failed", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Log_Configured_Timeout_Value() - { - var workspace = CreateWorkspace(); - - try - { - CreateRefitterSettingsFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessTimeoutMilliseconds = 1500; - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (_, _, _) => new ProcessExecutionResult(true, -1) - }; - - var result = task.Execute(); + result.Should().BeTrue(); + task.GeneratedFiles.Should().ContainSingle(); - result.Should().BeFalse(); - buildEngine.Errors.Should().Contain(message => message.Contains($"timed out after {1500 / 1000d:0.###} seconds", StringComparison.Ordinal)); + // Default outputFolder should be ./Generated and filename from settings file name + var generatedPath = task.GeneratedFiles.Single().ItemSpec; + generatedPath.Should().Contain("Generated"); + File.Exists(generatedPath).Should().BeTrue(); } finally { @@ -830,56 +374,57 @@ public void Execute_Should_Log_Configured_Timeout_Value() } [Test] - public void Execute_Should_Log_ProcessRunner_Exception_And_Return_False() + public void Execute_Should_Respect_ContractsOutputFolder() { var workspace = CreateWorkspace(); try { - CreateRefitterSettingsFile(workspace); - - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.FileExists = path => - path.Contains("net10.0", StringComparison.OrdinalIgnoreCase) || - File.Exists(path); - task.ProcessRunner = new DelegatingProcessRunner - { - Handler = (_, _, _) => throw new InvalidOperationException("boom") - }; - - var result = task.Execute(); - - result.Should().BeFalse(); - task.GeneratedFiles.Should().BeEmpty(); - buildEngine.Errors.Should().Contain(message => message.Contains("boom", StringComparison.Ordinal)); - } - finally - { - DeleteWorkspace(workspace); - } - } - - [Test] - public void Execute_Should_Log_When_Process_Exits_With_Non_Zero_Code() - { - var workspace = CreateWorkspace(); + var openApiPath = Path.Combine(workspace, "petstore.json"); + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Petstore", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "tags": ["pets"], + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); - try - { - CreateRefitterSettingsFile(workspace); + var settingsPath = Path.Combine(workspace, "petstore.refitter"); + File.WriteAllText( + settingsPath, + """ + { + "openApiPath": "petstore.json", + "namespace": "Test", + "generateMultipleFiles": true, + "outputFolder": "./Generated", + "contractsOutputFolder": "./Contracts" + } + """); - var buildEngine = new RecordingBuildEngine(); - var task = CreateTask(workspace, buildEngine); - task.ProcessRunner = new DelegatingProcessRunner + var task = new RefitterGenerateTask { - Handler = (_, _, _) => new ProcessExecutionResult(false, 23) + BuildEngine = new RecordingBuildEngine(), + ProjectFileDirectory = workspace, + IncludePatterns = "petstore.refitter", + SkipValidation = true }; var result = task.Execute(); - result.Should().BeFalse(); - buildEngine.Errors.Should().Contain(message => message.Contains("Refitter process exited with code 23", StringComparison.Ordinal)); + result.Should().BeTrue(); + var allGenerated = task.GeneratedFiles.Select(f => f.ItemSpec).ToArray(); + allGenerated.Should().Contain(f => f.Contains("Contracts")); } finally { @@ -887,39 +432,6 @@ public void Execute_Should_Log_When_Process_Exits_With_Non_Zero_Code() } } - [Test] - public void RunProcess_Should_Return_TimedOut_Result_When_Process_Exceeds_Timeout() - { - var runner = new DefaultProcessRunner { TimeoutMilliseconds = 1 }; - - var result = runner.Run( - CreateSleepProcessStartInfo(), - _ => { }, - _ => { }); - - result.TimedOut.Should().BeTrue(); - result.TerminationException.Should().BeNull(); - } - - [Test] - public void RunProcess_Should_Return_Termination_Exception_When_Kill_Fails_After_Timeout() - { - var runner = new DefaultProcessRunner - { - TimeoutMilliseconds = 1, - ProcessTerminator = _ => throw new InvalidOperationException("kill failed") - }; - - var result = runner.Run( - CreateSleepProcessStartInfo(), - _ => { }, - _ => { }); - - result.TimedOut.Should().BeTrue(); - result.TerminationException.Should().BeOfType() - .Which.Message.Should().Be("kill failed"); - } - [Test] public void TryLogCommandLine_Should_Swallow_BuildEngine_Exceptions() { @@ -965,31 +477,6 @@ private static string CreateWorkspace() return workspace; } - private static string CreateRefitterSettingsFile(string workspace) - { - var settingsPath = Path.Combine(workspace, "petstore.refitter"); - File.WriteAllText(settingsPath, "{}"); - return settingsPath; - } - - private static string CreateGeneratedFile(string workspace) - { - var generatedFile = Path.Combine(workspace, "Generated", "Petstore.cs"); - Directory.CreateDirectory(Path.GetDirectoryName(generatedFile)!); - File.WriteAllText(generatedFile, "// generated"); - return generatedFile; - } - - private static RefitterGenerateTask CreateTask(string workspace, IBuildEngine? buildEngine = null) => - new() - { - BuildEngine = buildEngine ?? new RecordingBuildEngine(), - ProjectFileDirectory = workspace, - IncludePatterns = "petstore.refitter", - DisableLogging = true, - SkipValidation = true - }; - private static void InvokePrivateMethod(RefitterGenerateTask task, string methodName, params object[] arguments) { var method = typeof(RefitterGenerateTask).GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); @@ -997,32 +484,6 @@ private static void InvokePrivateMethod(RefitterGenerateTask task, string method method!.Invoke(task, arguments); } - private static ProcessStartInfo CreateSleepProcessStartInfo() - { - if (OperatingSystem.IsWindows()) - { - return new ProcessStartInfo - { - FileName = "powershell", - Arguments = "-NoLogo -NoProfile -Command \"Start-Sleep -Seconds 1\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - } - - return new ProcessStartInfo - { - FileName = "bash", - Arguments = "-lc \"sleep 1\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - } - private static void DeleteWorkspace(string workspace) { if (Directory.Exists(workspace)) @@ -1031,24 +492,6 @@ private static void DeleteWorkspace(string workspace) } } - private sealed class DelegatingProcessRunner : IProcessRunner - { - public Func, Action, ProcessExecutionResult> Handler { get; set; } - - public ProcessExecutionResult Run( - ProcessStartInfo startInfo, - Action standardOutput, - Action standardError) => - Handler(startInfo, standardOutput, standardError); - } - - private sealed class DelegatingRuntimeResolver : IRuntimeResolver - { - public Func> Handler { get; set; } - - public List GetInstalledRuntimes() => Handler(); - } - private sealed class RecordingBuildEngine : IBuildEngine { public List Messages { get; } = []; diff --git a/src/Refitter.Tests/OpenApi/OpenApiStatsTests.cs b/src/Refitter.Tests/OpenApi/OpenApiStatsTests.cs index 4ca4355ef..0407b5807 100644 --- a/src/Refitter.Tests/OpenApi/OpenApiStatsTests.cs +++ b/src/Refitter.Tests/OpenApi/OpenApiStatsTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using Microsoft.OpenApi; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter.Tests.OpenApi; diff --git a/src/Refitter.Tests/OpenApi/OpenApiValidationExceptionTests.cs b/src/Refitter.Tests/OpenApi/OpenApiValidationExceptionTests.cs index 1d2918df2..4ef6731fb 100644 --- a/src/Refitter.Tests/OpenApi/OpenApiValidationExceptionTests.cs +++ b/src/Refitter.Tests/OpenApi/OpenApiValidationExceptionTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using Microsoft.OpenApi.Reader; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter.Tests.OpenApi; diff --git a/src/Refitter.Tests/OpenApi/OpenApiValidationResultTests.cs b/src/Refitter.Tests/OpenApi/OpenApiValidationResultTests.cs index 00a4f16f6..7e6bb4741 100644 --- a/src/Refitter.Tests/OpenApi/OpenApiValidationResultTests.cs +++ b/src/Refitter.Tests/OpenApi/OpenApiValidationResultTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using Microsoft.OpenApi.Reader; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter.Tests.OpenApi; diff --git a/src/Refitter.Tests/OpenApi/OpenApiValidatorTests.cs b/src/Refitter.Tests/OpenApi/OpenApiValidatorTests.cs index b7a14d4c0..35a3a4e05 100644 --- a/src/Refitter.Tests/OpenApi/OpenApiValidatorTests.cs +++ b/src/Refitter.Tests/OpenApi/OpenApiValidatorTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using Refitter.Tests.Resources; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter.Tests.OpenApi; diff --git a/src/Refitter.Tests/OutputPlannerTests.cs b/src/Refitter.Tests/OutputPlannerTests.cs index 659145979..4960e7c94 100644 --- a/src/Refitter.Tests/OutputPlannerTests.cs +++ b/src/Refitter.Tests/OutputPlannerTests.cs @@ -11,41 +11,47 @@ public class OutputPlannerTests [Test] public void SingleFile_DirectCli_Explicit_Output_Is_Used() { - var settings = new Settings { OutputPath = Path.Combine("GeneratedCode", "Petstore.cs") }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", OutputFilename = "Output.cs" }; - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings) + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath: null, + cliOutputPath: Path.Combine("GeneratedCode", "Petstore.cs"), + refitSettings) .Should().Be(Path.Combine("GeneratedCode", "Petstore.cs")); } [Test] public void SingleFile_DirectCli_Default_Uses_DefaultOutputPath() { - var settings = new Settings { OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", OutputFilename = "Output.cs" }; - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings) - .Should().Be(Settings.DefaultOutputPath); + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath: null, + cliOutputPath: OutputPlanner.DefaultOutputPath, + refitSettings) + .Should().Be(OutputPlanner.DefaultOutputPath); } [Test] public void SingleFile_SettingsFile_Roots_Relative_To_Settings_Directory() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", OutputFilename = "Output.cs" }; var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./Generated", "Output.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void MultiFile_DirectCli_Combines_Output_Directory_And_Filename() { - var settings = new Settings { OutputPath = Path.Combine("GeneratedCode", "MultipleFiles") }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated" }; - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath: null, + cliOutputPath: Path.Combine("GeneratedCode", "MultipleFiles"), + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(Path.Combine("GeneratedCode", "MultipleFiles", "RefitInterfaces.cs")); } @@ -53,15 +59,14 @@ public void MultiFile_DirectCli_Combines_Output_Directory_And_Filename() public void MultiFile_SettingsFile_Roots_Explicit_Cli_Override() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings - { - SettingsFilePath = settingsFilePath, - OutputPath = Path.Combine("GeneratedCode", "MultipleFiles") - }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated" }; var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "GeneratedCode", "MultipleFiles", "RefitInterfaces.cs"); - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath, + cliOutputPath: Path.Combine("GeneratedCode", "MultipleFiles"), + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(expected); } @@ -105,12 +110,11 @@ public void ShouldReroute_Is_False_When_Contracts_Folder_Empty() public void GetContractsOutputPath_Roots_Absolute_Under_Settings_Directory() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath }; var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; var outputFile = new GeneratedCode(TypenameConstants.Contracts, "// code"); var expectedFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./Contracts")); - OutputPlanner.GetContractsOutputPath(settings, refitSettings, outputFile) + OutputPlanner.GetContractsOutputPath(settingsFilePath, refitSettings, outputFile) .Should().Be(Path.Combine(expectedFolder, ContractsFileName)); } @@ -118,31 +122,33 @@ public void GetContractsOutputPath_Roots_Absolute_Under_Settings_Directory() public void PlanMultipleFiles_Reroutes_Only_The_Contracts_File() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", ContractsOutputFolder = "./Contracts" }; var interfaces = new GeneratedCode("RefitInterfaces", "// interfaces"); var contracts = new GeneratedCode(TypenameConstants.Contracts, "// contracts"); var generatorOutput = new GeneratorOutput(new List { interfaces, contracts }); - var planned = OutputPlanner.PlanMultipleFiles(settings, refitSettings, generatorOutput); + var planned = OutputPlanner.PlanMultipleFiles(settingsFilePath, cliOutputPath: null, refitSettings, generatorOutput); planned.Should().HaveCount(2); planned[0].Content.Should().Be("// interfaces"); planned[1].Content.Should().Be("// contracts"); var contractsFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./Contracts")); - planned[0].Path.Should().Be(OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, interfaces)); + planned[0].Path.Should().Be(OutputPlanner.GetMultiFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings, interfaces)); planned[1].Path.Should().Be(Path.Combine(contractsFolder, ContractsFileName)); } [Test] public void PlanSingleFile_Preserves_Content() { - var settings = new Settings { OutputPath = Path.Combine("Generated", "Api.cs") }; var refitSettings = new RefitGeneratorSettings(); - var planned = OutputPlanner.PlanSingleFile(settings, refitSettings, "// generated"); + var planned = OutputPlanner.PlanSingleFile( + settingsFilePath: null, + cliOutputPath: Path.Combine("Generated", "Api.cs"), + refitSettings, + "// generated"); planned.Content.Should().Be("// generated"); planned.Path.Should().Be(Path.Combine("Generated", "Api.cs")); @@ -152,81 +158,86 @@ public void PlanSingleFile_Preserves_Content() public void SingleFile_SettingsFile_CliOverride_Output_Uses_CliPath() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings - { - SettingsFilePath = settingsFilePath, - OutputPath = Path.Combine("CustomCli", "Override.cs") - }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", OutputFilename = "Output.cs" }; var expected = Path.Combine( Path.GetDirectoryName(settingsFilePath)!, "CustomCli", "Override.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath, + cliOutputPath: Path.Combine("CustomCli", "Override.cs"), + refitSettings) + .Should().Be(expected); } [Test] public void SingleFile_SettingsFile_NoOutputOverride_Uses_SettingsFile_OutputFolder_And_Filename() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./CustomFolder", OutputFilename = "CustomOutput.cs" }; var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./CustomFolder", "CustomOutput.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void SingleFile_SettingsFile_DefaultOutputFolder_Uses_DefaultFolder() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFilename = "ApiClient.cs" }; var expected = Path.Combine( Path.GetDirectoryName(settingsFilePath)!, RefitGeneratorSettings.DefaultOutputFolder, "ApiClient.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void SingleFile_SettingsFile_Rooted_Path_Does_Not_Combine_With_Root() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = Path.GetTempPath() }; var expected = Path.Combine(Path.GetTempPath(), "Output.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void SingleFile_DirectCli_Defaults_To_DefaultOutputPath() { - var settings = new Settings { SettingsFilePath = null, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings(); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(Settings.DefaultOutputPath); + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath: null, + cliOutputPath: OutputPlanner.DefaultOutputPath, + refitSettings) + .Should().Be(OutputPlanner.DefaultOutputPath); } [Test] public void SingleFile_DirectCli_Empty_SettingsFilePath_Defaults() { - var settings = new Settings { SettingsFilePath = string.Empty, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings(); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(Settings.DefaultOutputPath); + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath: string.Empty, + cliOutputPath: OutputPlanner.DefaultOutputPath, + refitSettings) + .Should().Be(OutputPlanner.DefaultOutputPath); } [Test] public void MultiFile_DirectCli_Default_Output_Uses_Current_Directory() { - var settings = new Settings { OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated" }; - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath: null, + cliOutputPath: OutputPlanner.DefaultOutputPath, + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(Path.Combine(".", "RefitInterfaces.cs")); } @@ -234,11 +245,10 @@ public void MultiFile_DirectCli_Default_Output_Uses_Current_Directory() public void MultiFile_SettingsFile_Uses_RefitGeneratorSettings_OutputFolder() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./ApiClient" }; var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./ApiClient", "RefitInterfaces.cs"); - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + OutputPlanner.GetMultiFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(expected); } @@ -246,11 +256,10 @@ public void MultiFile_SettingsFile_Uses_RefitGeneratorSettings_OutputFolder() public void MultiFile_SettingsFile_Rooted_OutputFolder_Does_Not_Combine_With_Root() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = Path.GetTempPath() }; var expected = Path.Combine(Path.GetTempPath(), "RefitInterfaces.cs"); - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + OutputPlanner.GetMultiFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(expected); } @@ -266,12 +275,14 @@ public void ShouldReroute_Is_False_When_Contracts_Folder_Equals_DefaultOutputFol [Test] public void GetContractsOutputPath_Without_SettingsFilePath_Uses_ContractsFolder_Directly() { - var settings = new Settings(); var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; var outputFile = new GeneratedCode(TypenameConstants.Contracts, "// code"); var contractsFolder = Path.GetFullPath("./Contracts"); - OutputPlanner.GetContractsOutputPath(settings, refitSettings, outputFile) + OutputPlanner.GetContractsOutputPath( + settingsFilePath: null, + refitSettings, + outputFile) .Should().Be(Path.Combine(contractsFolder, TypenameConstants.Contracts + ".cs")); } @@ -279,7 +290,6 @@ public void GetContractsOutputPath_Without_SettingsFilePath_Uses_ContractsFolder public void SingleFile_SettingsFile_NullOutputFilename_Uses_Default() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = "./CustomFolder", @@ -290,14 +300,13 @@ public void SingleFile_SettingsFile_NullOutputFilename_Uses_Default() Path.GetDirectoryName(settingsFilePath)!, "./CustomFolder", "Output.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void SingleFile_SettingsFile_NullOutputFolder_Uses_Filename_Directly() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; var refitSettings = new RefitGeneratorSettings { OutputFolder = null!, @@ -307,26 +316,196 @@ public void SingleFile_SettingsFile_NullOutputFolder_Uses_Filename_Directly() var expected = Path.Combine( Path.GetDirectoryName(settingsFilePath)!, "ApiClient.cs"); - OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings).Should().Be(expected); } [Test] public void MultiFile_SettingsFile_NullOutputFolder_Uses_Filename_Directly() { var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); - var settings = new Settings + var refitSettings = new RefitGeneratorSettings { - SettingsFilePath = settingsFilePath, - OutputPath = Settings.DefaultOutputPath + OutputFolder = null!, }; + + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath, + cliOutputPath: null, + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(Path.Combine( + Path.GetDirectoryName(settingsFilePath)!, + "RefitInterfaces.cs")); + } + + [Test] + public void SingleFile_BareFilename_SettingsFilePath_Handles_Empty_Root() + { + var settingsFilePath = "petstore.refitter"; var refitSettings = new RefitGeneratorSettings { - OutputFolder = null!, + OutputFolder = "./Generated", + OutputFilename = "ApiClient.cs" + }; + + var expected = Path.Combine("./Generated", "ApiClient.cs"); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings) + .Should().Be(expected); + } + + [Test] + public void SingleFile_BareFilename_SettingsFilePath_With_CliOverride_Roots_To_CliPath() + { + var settingsFilePath = "petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = "ApiClient.cs" + }; + + OutputPlanner.GetSingleFileOutputPath( + settingsFilePath, + cliOutputPath: Path.Combine("CustomCli", "Override.cs"), + refitSettings) + .Should().Be(Path.Combine("CustomCli", "Override.cs")); + } + + [Test] + public void MultiFile_BareFilename_SettingsFilePath_Handles_Empty_Root() + { + var settingsFilePath = "petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./ApiClient" + }; + + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath, + cliOutputPath: null, + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(Path.Combine("./ApiClient", "RefitInterfaces.cs")); + } + + [Test] + public void PlanMultipleFiles_DirectCli_No_SettingsFilePath() + { + var refitSettings = new RefitGeneratorSettings(); + var generatorOutput = new GeneratorOutput(new List + { + new("RefitInterfaces", "// interfaces"), + new(TypenameConstants.Contracts, "// contracts") + }); + + var planned = OutputPlanner.PlanMultipleFiles( + settingsFilePath: null, + cliOutputPath: OutputPlanner.DefaultOutputPath, + refitSettings, + generatorOutput); + + planned.Should().HaveCount(2); + planned[0].Path.Should().Be(Path.Combine(".", "RefitInterfaces.cs")); + planned[1].Path.Should().Be(Path.Combine(".", $"{TypenameConstants.Contracts}.cs")); + } + + [Test] + public void GetContractsOutputPath_With_Empty_String_SettingsFilePath() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; + var outputFile = new GeneratedCode(TypenameConstants.Contracts, "// code"); + + var contractsFolder = Path.GetFullPath("./Contracts"); + OutputPlanner.GetContractsOutputPath( + settingsFilePath: string.Empty, + refitSettings, + outputFile) + .Should().Be(Path.Combine(contractsFolder, TypenameConstants.Contracts + ".cs")); + } + + [Test] + public void SingleFile_SettingsFile_WhitespaceOutputFolder_Uses_Filename_Directly() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = " ", + OutputFilename = "ApiClient.cs" + }; + + var expected = Path.Combine( + Path.GetDirectoryName(settingsFilePath)!, + "ApiClient.cs"); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings) + .Should().Be(expected); + } + + [Test] + public void SingleFile_SettingsFile_EmptyOutputFilename_Uses_Empty_String() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./CustomFolder", + OutputFilename = string.Empty + }; + + var expected = Path.Combine( + Path.GetDirectoryName(settingsFilePath)!, + "./CustomFolder", + string.Empty); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings) + .Should().Be(expected); + } + + [Test] + public void SingleFile_SettingsFile_RootDir_As_SettingsFilePath() + { + var rootDir = Path.GetPathRoot(Path.GetTempPath())!; + var settingsFilePath = Path.Combine(rootDir, "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./Generated", + OutputFilename = "ApiClient.cs" }; - OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + var expected = Path.Combine(rootDir, "./Generated", "ApiClient.cs"); + OutputPlanner.GetSingleFileOutputPath(settingsFilePath, cliOutputPath: null, refitSettings) + .Should().Be(expected); + } + + [Test] + public void MultiFile_SettingsFile_Empty_OutputFolder_Uses_Filename_Directly() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = string.Empty + }; + + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath, + cliOutputPath: null, + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) .Should().Be(Path.Combine( Path.GetDirectoryName(settingsFilePath)!, "RefitInterfaces.cs")); } + + [Test] + public void MultiFile_BareFilename_SettingsFile_With_CliOverride_Roots_To_CliPath() + { + var settingsFilePath = "petstore.refitter"; + var refitSettings = new RefitGeneratorSettings + { + OutputFolder = "./ApiClient" + }; + + OutputPlanner.GetMultiFileOutputPath( + settingsFilePath, + cliOutputPath: Path.Combine("CustomCli", "MultipleFiles"), + refitSettings, + new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(Path.Combine("CustomCli", "MultipleFiles", "RefitInterfaces.cs")); + } } diff --git a/src/Refitter.Tests/RichGenerationReporterTests.cs b/src/Refitter.Tests/RichGenerationReporterTests.cs index 7b678825b..16b2d6c20 100644 --- a/src/Refitter.Tests/RichGenerationReporterTests.cs +++ b/src/Refitter.Tests/RichGenerationReporterTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; using TUnit.Core; namespace Refitter.Tests; diff --git a/src/Refitter.Tests/Scenarios/SettingsFileOutputPathTests.cs b/src/Refitter.Tests/Scenarios/SettingsFileOutputPathTests.cs index f15d5fbb4..6515fa94b 100644 --- a/src/Refitter.Tests/Scenarios/SettingsFileOutputPathTests.cs +++ b/src/Refitter.Tests/Scenarios/SettingsFileOutputPathTests.cs @@ -163,12 +163,7 @@ private static string GetOutputPath(Settings settings, RefitGeneratorSettings re 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]); + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitGeneratorSettings); } private static string CreateTempDirectory() diff --git a/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs b/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs index f0fe0bdc7..a82ba7222 100644 --- a/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs +++ b/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs @@ -115,4 +115,99 @@ public void IsUrl_Detects_Http_And_Https(string path, bool expected) { RefitterSettingsLoader.IsUrl(path).Should().Be(expected); } + + [Test] + public void ApplyDefaults_Should_Enable_GenerateMultipleFiles_When_ContractsOutputFolder_Is_Set() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + ContractsOutputFolder = "./Contracts" + }; + + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); + + refitSettings.GenerateMultipleFiles.Should().BeTrue(); + refitSettings.OutputFolder.Should().Be(RefitGeneratorSettings.DefaultOutputFolder); + } + + [Test] + public void ApplyDefaults_Should_Fallback_To_Output_When_SettingsFilePath_Has_No_Filename() + { + var refitSettings = new RefitGeneratorSettings(); + + RefitterSettingsLoader.ApplyDefaults(null!, refitSettings); + refitSettings.OutputFilename.Should().Be("Output.cs"); + + refitSettings.OutputFilename = null!; + RefitterSettingsLoader.ApplyDefaults(string.Empty, refitSettings); + refitSettings.OutputFilename.Should().Be("Output.cs"); + } + + [Test] + public void ApplyDefaults_Should_Fallback_To_Output_When_OutputFilename_Is_Whitespace() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFilename = " " + }; + + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); + + refitSettings.OutputFilename.Should().Be("petstore.cs"); + } + + [Test] + public void ApplyDefaults_Should_Preserve_Existing_OutputFilename() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings + { + OutputFilename = "CustomClient.cs" + }; + + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); + + refitSettings.OutputFilename.Should().Be("CustomClient.cs"); + } + + [Test] + public void ApplyDefaults_Should_Not_Set_GenerateMultipleFiles_When_No_ContractsFolder() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var refitSettings = new RefitGeneratorSettings(); + + RefitterSettingsLoader.ApplyDefaults(settingsFilePath, refitSettings); + + refitSettings.GenerateMultipleFiles.Should().BeFalse(); + } + + [Test] + public void ResolveRelativeSpecPaths_Null_OpenApiPaths_Does_Not_Throw() + { + var settings = new RefitGeneratorSettings + { + OpenApiPath = null, + OpenApiPaths = null + }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().BeNull(); + settings.OpenApiPaths.Should().BeNull(); + } + + [Test] + public void ResolveRelativeSpecPaths_Empty_OpenApiPath_Does_Not_Resolve() + { + var settings = new RefitGeneratorSettings + { + OpenApiPath = string.Empty + }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().Be(string.Empty); + } } diff --git a/src/Refitter.Tests/SimpleGenerationReporterTests.cs b/src/Refitter.Tests/SimpleGenerationReporterTests.cs index d740ad766..4ea577d45 100644 --- a/src/Refitter.Tests/SimpleGenerationReporterTests.cs +++ b/src/Refitter.Tests/SimpleGenerationReporterTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; using TUnit.Core; namespace Refitter.Tests; diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index bdb217c1a..3ccf1e17d 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -70,7 +70,7 @@ protected override async Task ExecuteAsync( if (!string.IsNullOrWhiteSpace(settings.OpenApiPath)) refitGeneratorSettings.OpenApiPath = settings.OpenApiPath; - ApplySettingsFileDefaults(settings.SettingsFilePath, refitGeneratorSettings); + RefitterSettingsLoader.ApplyDefaults(settings.SettingsFilePath, refitGeneratorSettings); } else { @@ -90,30 +90,20 @@ private static RefitGeneratorSettings CreateRefitGeneratorSettings(Settings sett SettingsMapper.Map(settings); private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) => - OutputPlanner.GetSingleFileOutputPath(settings, refitGeneratorSettings); + OutputPlanner.GetSingleFileOutputPath( + settings.SettingsFilePath, + settings.OutputPath, + refitGeneratorSettings); private string GetOutputPath( Settings settings, RefitGeneratorSettings refitGeneratorSettings, GeneratedCode outputFile) => - OutputPlanner.GetMultiFileOutputPath(settings, refitGeneratorSettings, outputFile); - - internal static void ApplySettingsFileDefaults(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) - { - if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder)) - refitGeneratorSettings.GenerateMultipleFiles = true; - - if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder)) - refitGeneratorSettings.OutputFolder = RefitGeneratorSettings.DefaultOutputFolder; - - if (string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFilename)) - { - var refitterFileName = Path.GetFileNameWithoutExtension(settingsFilePath); - if (string.IsNullOrEmpty(refitterFileName)) - refitterFileName = "Output"; - refitGeneratorSettings.OutputFilename = $"{refitterFileName}.cs"; - } - } + OutputPlanner.GetMultiFileOutputPath( + settings.SettingsFilePath, + settings.OutputPath, + refitGeneratorSettings, + outputFile); internal static string FormatGeneratedFileMarker(string outputPath) => $"{GeneratedFileMarker}{Path.GetFullPath(outputPath)}"; diff --git a/src/Refitter/GenerationOrchestrator.cs b/src/Refitter/GenerationOrchestrator.cs index 5a3b0ff86..4fb8d3d52 100644 --- a/src/Refitter/GenerationOrchestrator.cs +++ b/src/Refitter/GenerationOrchestrator.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter; @@ -91,7 +91,11 @@ private static async Task WriteSingleFile( await reporter.ReportSingleFileGenerationProgressAsync(); var code = generator.Generate().ReplaceLineEndings(); - var planned = OutputPlanner.PlanSingleFile(settings, refitGeneratorSettings, code); + var planned = OutputPlanner.PlanSingleFile( + settings.SettingsFilePath, + settings.OutputPath, + refitGeneratorSettings, + code); var fileName = Path.GetFileName(planned.Path); var directory = Path.GetDirectoryName(planned.Path) ?? ""; @@ -117,7 +121,8 @@ private static async Task WriteMultipleFiles( generator.GenerateMultipleFiles); var planned = OutputPlanner.PlanMultipleFiles( - settings, + settings.SettingsFilePath, + settings.OutputPath, refitGeneratorSettings, generatorOutput); @@ -182,7 +187,7 @@ private static async Task ValidateOpenApiSpecs( private static async Task ValidateOpenApiSpec(string openApiPath, IGenerationReporter reporter) { var validationResult = await reporter.ValidateWithProgressAsync( - () => Validation.OpenApiValidator.Validate(openApiPath)); + () => Refitter.Core.Validation.OpenApiValidator.Validate(openApiPath)); if (!validationResult.IsValid) { diff --git a/src/Refitter/IGenerationReporter.cs b/src/Refitter/IGenerationReporter.cs index 81bc0424a..65559b958 100644 --- a/src/Refitter/IGenerationReporter.cs +++ b/src/Refitter/IGenerationReporter.cs @@ -1,6 +1,6 @@ using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter; diff --git a/src/Refitter/OutputPlanner.cs b/src/Refitter/OutputPlanner.cs deleted file mode 100644 index ae3c71880..000000000 --- a/src/Refitter/OutputPlanner.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Refitter.Core; - -namespace Refitter; - -/// -/// A single file the generator intends to write: its resolved output path and content. -/// -internal sealed record PlannedFile(string Path, string Content); - -/// -/// Pure output-path planning for the CLI. Owns every rule that decides -/// where generated code is written — direct CLI output, settings-file -/// rooting, the #1021 CLI override, and the contracts-output-folder reroute — -/// without touching the filesystem or the console. -/// -internal static class OutputPlanner -{ - public static PlannedFile PlanSingleFile( - Settings settings, - RefitGeneratorSettings refitGeneratorSettings, - string code) => - new(GetSingleFileOutputPath(settings, refitGeneratorSettings), code); - - public static IReadOnlyList PlanMultipleFiles( - Settings settings, - RefitGeneratorSettings refitGeneratorSettings, - GeneratorOutput generatorOutput) - { - var planned = new List(generatorOutput.Files.Count); - foreach (var outputFile in generatorOutput.Files) - { - var path = ShouldRerouteToContractsFolder(refitGeneratorSettings, outputFile) - ? GetContractsOutputPath(settings, refitGeneratorSettings, outputFile) - : GetMultiFileOutputPath(settings, refitGeneratorSettings, outputFile); - - planned.Add(new PlannedFile(path, outputFile.Content)); - } - - return planned; - } - - public static string GetSingleFileOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) - { - // Direct CLI invocation (no settings file) - if (UsesDirectCliOutput(settings)) - { - return settings.OutputPath!; - } - - if (UsesDirectCliDefaults(settings)) - { - return Settings.DefaultOutputPath; - } - - // Settings file mode - var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) - ? string.Empty - : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; - - // Check if CLI explicitly overrides output (#1021 fix) - var cliOverridesOutput = !string.IsNullOrWhiteSpace(settings.OutputPath) && - settings.OutputPath != Settings.DefaultOutputPath; - - string outputPath; - if (cliOverridesOutput) - { - // CLI --output overrides settings file - outputPath = settings.OutputPath!; - } - else - { - // Use settings file output folder and filename - var filename = refitGeneratorSettings.OutputFilename ?? "Output.cs"; - outputPath = !string.IsNullOrWhiteSpace(refitGeneratorSettings.OutputFolder) - ? Path.Combine(refitGeneratorSettings.OutputFolder, filename) - : filename; - } - - // Root the output path relative to settings file location if not already rooted - if (!string.IsNullOrWhiteSpace(root) && !Path.IsPathRooted(outputPath)) - { - outputPath = Path.Combine(root, outputPath); - } - - return outputPath; - } - - public static string GetMultiFileOutputPath( - Settings settings, - 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; - - var outputFolder = HasExplicitCliOutputOverride(settings) - ? settings.OutputPath - : refitGeneratorSettings.OutputFolder; - - if (!string.IsNullOrWhiteSpace(outputFolder)) - { - return CombineWithSettingsRoot(root, outputFolder, outputFile.Filename); - } - - return CombineWithSettingsRoot(root, outputFile.Filename); - } - - public static bool ShouldRerouteToContractsFolder( - RefitGeneratorSettings refitGeneratorSettings, - GeneratedCode outputFile) => - !string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder) - && refitGeneratorSettings.ContractsOutputFolder != RefitGeneratorSettings.DefaultOutputFolder - && outputFile.Filename == $"{TypenameConstants.Contracts}.cs"; - - public static string GetContractsOutputPath( - Settings settings, - RefitGeneratorSettings refitGeneratorSettings, - GeneratedCode outputFile) - { - var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) - ? string.Empty - : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; - - var contractsFolder = Path.GetFullPath(Path.Combine(root, refitGeneratorSettings.ContractsOutputFolder!)); - return Path.Combine(contractsFolder, 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 bool HasExplicitCliOutputOverride(Settings settings) => - !string.IsNullOrWhiteSpace(settings.OutputPath) && - settings.OutputPath != Settings.DefaultOutputPath; - - private static string CombineWithSettingsRoot(string root, params string[] segments) - { - var combinedPath = Path.Combine(segments); - return !string.IsNullOrWhiteSpace(root) && !Path.IsPathRooted(combinedPath) - ? Path.Combine(root, combinedPath) - : combinedPath; - } -} diff --git a/src/Refitter/RichGenerationReporter.cs b/src/Refitter/RichGenerationReporter.cs index f52b1dc4e..fb4f4b775 100644 --- a/src/Refitter/RichGenerationReporter.cs +++ b/src/Refitter/RichGenerationReporter.cs @@ -1,6 +1,6 @@ using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; using Spectre.Console; namespace Refitter; diff --git a/src/Refitter/SettingsValidator.cs b/src/Refitter/SettingsValidator.cs index d53f0b2e0..75b1a4d82 100644 --- a/src/Refitter/SettingsValidator.cs +++ b/src/Refitter/SettingsValidator.cs @@ -85,7 +85,7 @@ private static ValidationResult ValidateFilePath(Settings settings, out RefitGen return fileAndOutputResult; } - GenerateCommand.ApplySettingsFileDefaults(settings.SettingsFilePath!, refitGeneratorSettings); + RefitterSettingsLoader.ApplyDefaults(settings.SettingsFilePath!, refitGeneratorSettings); GenerateCommand.ResolveRelativeSpecPaths(settings.SettingsFilePath!, refitGeneratorSettings); refitSettings = refitGeneratorSettings; diff --git a/src/Refitter/SimpleGenerationReporter.cs b/src/Refitter/SimpleGenerationReporter.cs index a7742ff39..981d4752e 100644 --- a/src/Refitter/SimpleGenerationReporter.cs +++ b/src/Refitter/SimpleGenerationReporter.cs @@ -1,6 +1,6 @@ using Microsoft.OpenApi; using Refitter.Core; -using Refitter.Validation; +using Refitter.Core.Validation; namespace Refitter; diff --git a/test/MSBuild/build.ps1 b/test/MSBuild/build.ps1 index 9ddb792e8..9a04ff75a 100644 --- a/test/MSBuild/build.ps1 +++ b/test/MSBuild/build.ps1 @@ -10,7 +10,6 @@ Remove-Item Output.cs -ErrorAction SilentlyContinue # Issue #998: Should not exi dotnet restore ../../src/Refitter.slnx dotnet clean -c release ../../src/Refitter.slnx -dotnet build -c release ../../src/Refitter/Refitter.csproj dotnet build -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj dotnet pack -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj -o . dotnet add package .\Refitter.MSBuild.1.0.0.nupkg --source . diff --git a/test/MSBuild/build.sh b/test/MSBuild/build.sh index aa9579ac2..6df1ed1fa 100755 --- a/test/MSBuild/build.sh +++ b/test/MSBuild/build.sh @@ -8,7 +8,6 @@ rm -f Refitter.MSBuild.*.nupkg rm -f Petstore.cs dotnet restore ../../src/Refitter.slnx dotnet clean -c release ../../src/Refitter.slnx -dotnet build -c release ../../src/Refitter/Refitter.csproj dotnet build -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj dotnet pack -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj -o .