diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5fe1f9ca..ccbd3913 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -257,3 +257,6 @@ dotnet run --project src/Refitter --configuration Release --framework net9.0 -- - Commit messages should be brief, clear and descriptive of the changes made - Avoid using multi-line commit messages; if necessary, use bullet points for clarity - Commit changes in small logical units to facilitate easier code review and debugging +- **AI agents MUST commit after every logical unit of work** — do not accumulate all changes into a single end-of-task commit. Commit each independently-releasable unit (e.g. new file, new feature, refactor step) as soon as it is ready and building. +- **NEVER add a co-author to commits.** Do not include `Co-authored-by:` trailers or any attribution to AI tools in commit messages. Commits are owned by the developer. +- This commit policy is automatic and applies to all agentic work — do not wait for the user to request it. diff --git a/src/Refitter.Core/RefitterSettingsLoader.cs b/src/Refitter.Core/RefitterSettingsLoader.cs new file mode 100644 index 00000000..4dfcc8dd --- /dev/null +++ b/src/Refitter.Core/RefitterSettingsLoader.cs @@ -0,0 +1,68 @@ +namespace Refitter.Core; + +/// +/// Loads from the contents of a .refitter settings file. +/// +/// +/// This module deliberately operates on already-read JSON text plus the directory the settings +/// file lives in, rather than reading the file itself. File reading stays with each consumer +/// (the CLI reads from disk; the source generator reads via Roslyn's AdditionalText), which +/// keeps filesystem APIs out of the analyzer assembly and preserves incremental-generation semantics. +/// +public static class RefitterSettingsLoader +{ + /// + /// Deserializes the contents of a .refitter settings file and resolves any relative + /// OpenAPI specification paths against the directory the settings file lives in. + /// + /// The raw contents of the .refitter settings file. + /// + /// The directory the settings file lives in. Relative + /// and entries are resolved against this directory. + /// + /// The deserialized settings with relative specification paths resolved to absolute paths. + /// Thrown when cannot be deserialized. + public static RefitGeneratorSettings Load(string json, string baseDirectory) + { + var settings = Serializer.Deserialize(json); + ResolveRelativeSpecPaths(settings, baseDirectory); + return settings; + } + + /// + /// Resolves any relative OpenAPI specification paths on against + /// . URLs and already-rooted paths are left untouched. + /// + /// The settings whose specification paths should be resolved. + /// The directory to resolve relative paths against. + public static void ResolveRelativeSpecPaths(RefitGeneratorSettings settings, string baseDirectory) + { + if (!string.IsNullOrWhiteSpace(settings.OpenApiPath) && + !IsUrl(settings.OpenApiPath!) && + !Path.IsPathRooted(settings.OpenApiPath)) + { + settings.OpenApiPath = Path.GetFullPath(Path.Combine(baseDirectory, settings.OpenApiPath!)); + } + + if (settings.OpenApiPaths is { Length: > 0 }) + { + for (var i = 0; i < settings.OpenApiPaths.Length; i++) + { + var path = settings.OpenApiPaths[i]; + if (!IsUrl(path) && !Path.IsPathRooted(path)) + { + settings.OpenApiPaths[i] = Path.GetFullPath(Path.Combine(baseDirectory, path)); + } + } + } + } + + /// + /// Determines whether the specified path is an absolute HTTP or HTTPS URL. + /// + /// The path to inspect. + /// when the path is an HTTP or HTTPS URL; otherwise . + public static bool IsUrl(string path) => + Uri.TryCreate(path, UriKind.Absolute, out var uriResult) && + (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); +} diff --git a/src/Refitter.SourceGenerator.Tests/SourceGeneratorPackageReferenceTests.cs b/src/Refitter.SourceGenerator.Tests/SourceGeneratorPackageReferenceTests.cs index f6ec7001..904f3d7e 100644 --- a/src/Refitter.SourceGenerator.Tests/SourceGeneratorPackageReferenceTests.cs +++ b/src/Refitter.SourceGenerator.Tests/SourceGeneratorPackageReferenceTests.cs @@ -65,6 +65,8 @@ public void Packed_SourceGenerator_Package_Should_Only_Ship_Its_Own_Analyzer_Ass private static string PackSourceGeneratorPackage(string workspace, string version) { + using var buildLock = AcquireBuildLock(); + var repoRoot = GetRepositoryRoot(); var projectFile = Path.Combine(repoRoot, "src", "Refitter.SourceGenerator", "Refitter.SourceGenerator.csproj"); var packageOutputPath = Path.Combine(workspace, "packages"); @@ -125,6 +127,25 @@ private static void BuildSourceGeneratorProject( process.ExitCode.Should().Be(0, $"dotnet build should succeed before pack{Environment.NewLine}{output}{Environment.NewLine}{error}"); } + private static FileStream AcquireBuildLock() + { + var lockFilePath = Path.Combine(Path.GetTempPath(), "refitter-source-generator-package-tests.lock"); + var timeout = TimeSpan.FromMinutes(2); + var startedAt = DateTime.UtcNow; + + while (true) + { + try + { + return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) when (DateTime.UtcNow - startedAt < timeout) + { + Thread.Sleep(TimeSpan.FromMilliseconds(100)); + } + } + } + private static string CreateWorkspace() { var workspace = Path.Combine(AppContext.BaseDirectory, "SourceGeneratorPackageReferenceTests", Guid.NewGuid().ToString("N")); diff --git a/src/Refitter.SourceGenerator/RefitterSourceGenerator.cs b/src/Refitter.SourceGenerator/RefitterSourceGenerator.cs index 5d31ff9e..2a21f19c 100644 --- a/src/Refitter.SourceGenerator/RefitterSourceGenerator.cs +++ b/src/Refitter.SourceGenerator/RefitterSourceGenerator.cs @@ -159,31 +159,7 @@ internal static GeneratedCode GenerateCode( private static void ResolveRelativeSpecPaths(string settingsFilePath, RefitGeneratorSettings settings) { var settingsFileDirectory = Path.GetDirectoryName(Path.GetFullPath(settingsFilePath)) ?? string.Empty; - - if (!string.IsNullOrWhiteSpace(settings.OpenApiPath) && - !IsUrl(settings.OpenApiPath) && - !Path.IsPathRooted(settings.OpenApiPath)) - { - settings.OpenApiPath = Path.GetFullPath(Path.Combine(settingsFileDirectory, settings.OpenApiPath)); - } - - if (settings.OpenApiPaths is { Length: > 0 }) - { - for (var i = 0; i < settings.OpenApiPaths.Length; i++) - { - var path = settings.OpenApiPaths[i]; - if (!IsUrl(path) && !Path.IsPathRooted(path)) - { - settings.OpenApiPaths[i] = Path.GetFullPath(Path.Combine(settingsFileDirectory, path)); - } - } - } - } - - private static bool IsUrl(string path) - { - return Uri.TryCreate(path, UriKind.Absolute, out var uriResult) && - (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, settingsFileDirectory); } internal static GeneratedDiagnostic CreateNoRefitterFilesFoundDiagnostic() => diff --git a/src/Refitter.Tests/FileWriterTests.cs b/src/Refitter.Tests/FileWriterTests.cs new file mode 100644 index 00000000..11d5aa00 --- /dev/null +++ b/src/Refitter.Tests/FileWriterTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using TUnit.Core; + +namespace Refitter.Tests; + +public class FileWriterTests +{ + private static readonly string TestRoot = + Path.Combine(Path.GetTempPath(), "refitter-filewriter-tests"); + + [Test] + public async Task WriteAsync_Writes_Content_To_Existing_Directory() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"refitter-fw-{Guid.NewGuid()}.cs"); + const string content = "// generated code"; + + try + { + await FileWriter.WriteAsync(tempFile, content); + + File.Exists(tempFile).Should().BeTrue(); + (await File.ReadAllTextAsync(tempFile)).Should().Be(content); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Test] + public async Task WriteAsync_Creates_Missing_Directory() + { + var subDir = Path.Combine(TestRoot, Guid.NewGuid().ToString(), "nested"); + var filePath = Path.Combine(subDir, "Output.cs"); + const string content = "// code in new directory"; + + try + { + await FileWriter.WriteAsync(filePath, content); + + File.Exists(filePath).Should().BeTrue(); + (await File.ReadAllTextAsync(filePath)).Should().Be(content); + } + finally + { + if (Directory.Exists(subDir)) + Directory.Delete(subDir, recursive: true); + } + } + + [Test] + public async Task WriteAsync_PlannedFile_Writes_Content() + { + var tempFile = Path.Combine(Path.GetTempPath(), $"refitter-fw-planned-{Guid.NewGuid()}.cs"); + const string content = "// planned content"; + + try + { + await FileWriter.WriteAsync(new PlannedFile(tempFile, content)); + + File.Exists(tempFile).Should().BeTrue(); + (await File.ReadAllTextAsync(tempFile)).Should().Be(content); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Test] + public async Task WriteAsync_PlannedFile_Creates_Missing_Directory() + { + var subDir = Path.Combine(TestRoot, Guid.NewGuid().ToString(), "planned"); + var filePath = Path.Combine(subDir, "Planned.cs"); + const string content = "// planned in new dir"; + + try + { + await FileWriter.WriteAsync(new PlannedFile(filePath, content)); + + File.Exists(filePath).Should().BeTrue(); + (await File.ReadAllTextAsync(filePath)).Should().Be(content); + } + finally + { + if (Directory.Exists(subDir)) + Directory.Delete(subDir, recursive: true); + } + } +} diff --git a/src/Refitter.Tests/OpenApiStatisticsFormatterTests.cs b/src/Refitter.Tests/OpenApiStatisticsFormatterTests.cs new file mode 100644 index 00000000..c68fd762 --- /dev/null +++ b/src/Refitter.Tests/OpenApiStatisticsFormatterTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using TUnit.Core; + +namespace Refitter.Tests; + +public class OpenApiStatisticsFormatterTests +{ + [Test] + public void Parse_Returns_Empty_For_Empty_String() + { + OpenApiStatisticsFormatter.Parse(string.Empty).Should().BeEmpty(); + } + + [Test] + public void Parse_Returns_Empty_When_No_Lines_Start_With_Dash() + { + OpenApiStatisticsFormatter.Parse("Paths: 5\nOperations: 10").Should().BeEmpty(); + } + + [Test] + public void Parse_Returns_Label_Value_Pairs_From_Dash_Lines() + { + var statistics = $"- Paths: 5{Environment.NewLine}- Operations: 10{Environment.NewLine}- Schemas: 20"; + + var result = OpenApiStatisticsFormatter.Parse(statistics); + + result.Should().HaveCount(3); + result[0].Should().Be(("Paths", "5")); + result[1].Should().Be(("Operations", "10")); + result[2].Should().Be(("Schemas", "20")); + } + + [Test] + public void Parse_Skips_Lines_Not_Starting_With_Dash() + { + var statistics = $"Summary:{Environment.NewLine}- Paths: 5{Environment.NewLine}Footer"; + + var result = OpenApiStatisticsFormatter.Parse(statistics); + + result.Should().HaveCount(1); + result[0].Should().Be(("Paths", "5")); + } + + [Test] + public void Parse_Skips_Lines_Without_Colon_Separator() + { + var statistics = $"- ValidLine: 1{Environment.NewLine}- NoColon"; + + var result = OpenApiStatisticsFormatter.Parse(statistics); + + result.Should().HaveCount(1); + result[0].Should().Be(("ValidLine", "1")); + } + + [Test] + [Arguments("Paths", "📝")] + [Arguments("Operations", "⚡")] + [Arguments("Parameters", "📝")] + [Arguments("Request Bodies", "📤")] + [Arguments("Responses", "📥")] + [Arguments("Links", "🔗")] + [Arguments("Callbacks", "🔄")] + [Arguments("Schemas", "📋")] + [Arguments("Unknown Metric", "📊")] + public void GetIcon_Returns_Expected_Icon(string label, string expectedIcon) + { + OpenApiStatisticsFormatter.GetIcon(label).Should().Be(expectedIcon); + } + + [Test] + [Arguments("Paths", "API endpoints defined")] + [Arguments("Operations", "HTTP operations available")] + [Arguments("Parameters", "Input parameters defined")] + [Arguments("Request Bodies", "Request body schemas")] + [Arguments("Responses", "Response schemas defined")] + [Arguments("Links", "Operation links")] + [Arguments("Callbacks", "Callback definitions")] + [Arguments("Schemas", "Data schemas defined")] + [Arguments("Unknown Metric", "API specification metric")] + public void GetDescription_Returns_Expected_Description(string label, string expectedDescription) + { + OpenApiStatisticsFormatter.GetDescription(label).Should().Be(expectedDescription); + } +} diff --git a/src/Refitter.Tests/OutputPlannerTests.cs b/src/Refitter.Tests/OutputPlannerTests.cs new file mode 100644 index 00000000..0e3d7d9e --- /dev/null +++ b/src/Refitter.Tests/OutputPlannerTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using Refitter.Core; +using TUnit.Core; + +namespace Refitter.Tests; + +public class OutputPlannerTests +{ + private static readonly string ContractsFileName = $"{TypenameConstants.Contracts}.cs"; + + [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) + .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); + } + + [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); + } + + [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")) + .Should().Be(Path.Combine("GeneratedCode", "MultipleFiles", "RefitInterfaces.cs")); + } + + [Test] + 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")) + .Should().Be(expected); + } + + [Test] + public void ShouldReroute_Is_True_For_Contracts_File_With_Custom_Folder() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; + + OutputPlanner.ShouldRerouteToContractsFolder(refitSettings, new GeneratedCode(TypenameConstants.Contracts, "// code")) + .Should().BeTrue(); + } + + [Test] + public void ShouldReroute_Is_False_For_Default_Contracts_Folder() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = RefitGeneratorSettings.DefaultOutputFolder }; + + OutputPlanner.ShouldRerouteToContractsFolder(refitSettings, new GeneratedCode(TypenameConstants.Contracts, "// code")) + .Should().BeFalse(); + } + + [Test] + public void ShouldReroute_Is_False_For_Non_Contracts_File() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; + + OutputPlanner.ShouldRerouteToContractsFolder(refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + .Should().BeFalse(); + } + + [Test] + public void ShouldReroute_Is_False_When_Contracts_Folder_Empty() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = string.Empty }; + + OutputPlanner.ShouldRerouteToContractsFolder(refitSettings, new GeneratedCode(TypenameConstants.Contracts, "// code")) + .Should().BeFalse(); + } + + [Test] + 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) + .Should().Be(Path.Combine(expectedFolder, ContractsFileName)); + } + + [Test] + 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); + + 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[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"); + + planned.Content.Should().Be("// generated"); + planned.Path.Should().Be(Path.Combine("Generated", "Api.cs")); + } +} diff --git a/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs b/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs new file mode 100644 index 00000000..f0fe0bdc --- /dev/null +++ b/src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs @@ -0,0 +1,118 @@ +using FluentAssertions; +using Refitter.Core; + +namespace Refitter.Tests.SettingsFile; + +public class RefitterSettingsLoaderTests +{ + private static readonly string BaseDirectory = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "refitter-loader-tests")); + + [Test] + public void Load_Deserializes_Settings() + { + const string json = """{"namespace": "Acme.Api", "openApiPath": "https://example.com/openapi.json"}"""; + + var settings = RefitterSettingsLoader.Load(json, BaseDirectory); + + settings.Namespace.Should().Be("Acme.Api"); + } + + [Test] + public void Load_Throws_On_Invalid_Json() + { + var act = () => RefitterSettingsLoader.Load("{ not valid json", BaseDirectory); + + act.Should().Throw(); + } + + [Test] + public void Resolves_Relative_OpenApiPath_Against_BaseDirectory() + { + var settings = new RefitGeneratorSettings { OpenApiPath = "specs/openapi.json" }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().Be(Path.GetFullPath(Path.Combine(BaseDirectory, "specs/openapi.json"))); + } + + [Test] + public void Leaves_Url_OpenApiPath_Untouched() + { + const string url = "https://example.com/openapi.json"; + var settings = new RefitGeneratorSettings { OpenApiPath = url }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().Be(url); + } + + [Test] + public void Leaves_Rooted_OpenApiPath_Untouched() + { + var rooted = Path.GetFullPath(Path.Combine(BaseDirectory, "absolute", "openapi.json")); + var settings = new RefitGeneratorSettings { OpenApiPath = rooted }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().Be(rooted); + } + + [Test] + public void Resolves_Relative_Entries_In_OpenApiPaths_And_Preserves_Urls() + { + const string url = "https://example.com/openapi.json"; + var settings = new RefitGeneratorSettings + { + OpenApiPaths = new[] { "specs/a.json", url } + }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPaths![0].Should().Be(Path.GetFullPath(Path.Combine(BaseDirectory, "specs/a.json"))); + settings.OpenApiPaths![1].Should().Be(url); + } + + [Test] + public void Leaves_Rooted_Entry_In_OpenApiPaths_Untouched() + { + var rootedPath = Path.GetFullPath(Path.Combine(BaseDirectory, "absolute", "spec.json")); + var settings = new RefitGeneratorSettings + { + OpenApiPaths = new[] { rootedPath } + }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPaths![0].Should().Be(rootedPath); + } + + [Test] + public void Does_Not_Modify_OpenApiPath_When_Null_Or_Empty() + { + var settings = new RefitGeneratorSettings { OpenApiPath = null }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPath.Should().BeNull(); + } + + [Test] + public void Does_Not_Modify_OpenApiPaths_When_Empty_Array() + { + var settings = new RefitGeneratorSettings { OpenApiPaths = Array.Empty() }; + + RefitterSettingsLoader.ResolveRelativeSpecPaths(settings, BaseDirectory); + + settings.OpenApiPaths.Should().BeEmpty(); + } + + [Test] + [Arguments("https://example.com/openapi.json", true)] + [Arguments("http://example.com/openapi.json", true)] + [Arguments("specs/openapi.json", false)] + [Arguments("ftp://example.com/openapi.json", false)] + public void IsUrl_Detects_Http_And_Https(string path, bool expected) + { + RefitterSettingsLoader.IsUrl(path).Should().Be(expected); + } +} diff --git a/src/Refitter.Tests/SimpleGenerationReporterTests.cs b/src/Refitter.Tests/SimpleGenerationReporterTests.cs new file mode 100644 index 00000000..d740ad76 --- /dev/null +++ b/src/Refitter.Tests/SimpleGenerationReporterTests.cs @@ -0,0 +1,235 @@ +using FluentAssertions; +using Microsoft.OpenApi; +using Refitter.Core; +using Refitter.Validation; +using TUnit.Core; + +namespace Refitter.Tests; + +/// +/// Smoke tests for . These exercise every method +/// for line/branch coverage; the output is not asserted because TUnit prohibits +/// redirecting Console.Out (TUnit0055). The methods only write to Console so they +/// don't throw — a clean run is the success criterion. +/// +public class SimpleGenerationReporterTests +{ + [Test] + public void ReportHeader_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportHeader("1.2.3"); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSupportKey_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportSupportKey("TEST-KEY-123"); + + act.Should().NotThrow(); + } + + [Test] + public async Task ReportSingleFileGenerationProgressAsync_Does_Not_Throw() + { + await new SimpleGenerationReporter().ReportSingleFileGenerationProgressAsync(); + } + + [Test] + public void ReportSingleFileOutput_Does_Not_Throw() + { + var act = () => + new SimpleGenerationReporter().ReportSingleFileOutput("Output.cs", "/tmp", "12 KB", 300); + + act.Should().NotThrow(); + } + + [Test] + public async Task GenerateMultipleFilesWithProgressAsync_Invokes_Generator_And_Returns_Result() + { + var called = false; + var result = await new SimpleGenerationReporter() + .GenerateMultipleFilesWithProgressAsync(() => + { + called = true; + return new GeneratorOutput([]); + }); + + called.Should().BeTrue(); + result.Should().NotBeNull(); + } + + [Test] + public void BeginMultiFileOutput_AddFile_And_Complete_Do_Not_Throw() + { + var act = () => + { + var report = new SimpleGenerationReporter().BeginMultiFileOutput(); + report.AddFile("Api.cs", "/tmp", "5 KB", 100); + report.Complete(1, "5 KB", 100); + }; + + act.Should().NotThrow(); + } + + [Test] + public void ReportFileWritten_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportFileWritten("/output/Api.cs"); + + act.Should().NotThrow(); + } + + [Test] + public async Task ValidateWithProgressAsync_Invokes_Validator() + { + var called = false; + await new SimpleGenerationReporter().ValidateWithProgressAsync(async () => + { + called = true; + return await Task.FromResult(new OpenApiValidationResult(new Microsoft.OpenApi.Reader.OpenApiDiagnostic(), new OpenApiStats())); + }); + + called.Should().BeTrue(); + } + + [Test] + public void ReportValidationFailed_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportValidationFailed(); + + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationDiagnostic_Error_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportValidationDiagnostic( + new OpenApiError("field", "Something went wrong"), + isError: true); + + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationDiagnostic_Warning_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportValidationDiagnostic( + new OpenApiError("field", "A warning"), + isError: false); + + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationStatistics_Does_Not_Throw() + { + var result = new OpenApiValidationResult( + new Microsoft.OpenApi.Reader.OpenApiDiagnostic(), + new OpenApiStats()); + + var act = () => new SimpleGenerationReporter().ReportValidationStatistics(result); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSuccess_SingleFile_Does_Not_Throw() + { + var act = () => + new SimpleGenerationReporter().ReportSuccess(TimeSpan.FromMilliseconds(1234), multipleFiles: false); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSuccess_MultipleFiles_Does_Not_Throw() + { + var act = () => + new SimpleGenerationReporter().ReportSuccess(TimeSpan.FromMilliseconds(1234), multipleFiles: true); + + act.Should().NotThrow(); + } + + [Test] + public void ReportDonationBanner_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportDonationBanner(); + + act.Should().NotThrow(); + } + + [Test] + public void ReportConfigurationWarnings_Does_Not_Throw() + { + var warnings = new List<(string Title, string Description)> + { + ("Title1", "Desc1"), + ("Title2", "Desc2") + }; + + var act = () => new SimpleGenerationReporter().ReportConfigurationWarnings(warnings); + + act.Should().NotThrow(); + } + + [Test] + public void ReportAllPathsFilteredWarning_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter() + .ReportAllPathsFilteredWarning(["^/pets", "^/users"]); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSettingsFileGenerated_Does_Not_Throw() + { + var act = () => + new SimpleGenerationReporter().ReportSettingsFileGenerated("/tmp/petstore.refitter"); + + act.Should().NotThrow(); + } + + [Test] + public void ReportGenerationFailed_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportGenerationFailed(); + + act.Should().NotThrow(); + } + + [Test] + public void ReportUnsupportedVersion_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportUnsupportedVersion("1.0"); + + act.Should().NotThrow(); + } + + [Test] + public void ReportExceptionDetails_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter() + .ReportExceptionDetails(new InvalidOperationException("boom")); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSkipValidationSuggestion_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportSkipValidationSuggestion(); + + act.Should().NotThrow(); + } + + [Test] + public void ReportSupportHelp_Does_Not_Throw() + { + var act = () => new SimpleGenerationReporter().ReportSupportHelp(); + + act.Should().NotThrow(); + } +} diff --git a/src/Refitter/FileWriter.cs b/src/Refitter/FileWriter.cs new file mode 100644 index 00000000..8f43e2a7 --- /dev/null +++ b/src/Refitter/FileWriter.cs @@ -0,0 +1,22 @@ +namespace Refitter; + +/// +/// Thin filesystem sink for planned output. Owns the "ensure directory exists, +/// then write the file" mechanics so the command code no longer interleaves +/// directory creation with reporting. The GeneratedFile: marker stays +/// with the caller (see ) +/// because it is observable stdout, not a filesystem concern. +/// +internal static class FileWriter +{ + public static async Task WriteAsync(string path, string content) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllTextAsync(path, content); + } + + public static Task WriteAsync(PlannedFile file) => WriteAsync(file.Path, file.Content); +} diff --git a/src/Refitter/GenerateCommand.cs b/src/Refitter/GenerateCommand.cs index 7f28f226..992acef9 100644 --- a/src/Refitter/GenerateCommand.cs +++ b/src/Refitter/GenerateCommand.cs @@ -11,20 +11,15 @@ namespace Refitter; [ExcludeFromCodeCoverage] public sealed class GenerateCommand : AsyncCommand { - private const string AsciiArt = -""" - ██████╗ ███████╗███████╗██╗████████╗████████╗███████╗██████╗ - ██╔══██╗██╔════╝██╔════╝██║╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ - ██████╔╝█████╗ █████╗ ██║ ██║ ██║ █████╗ ██████╔╝ - ██╔══██╗██╔══╝ ██╔══╝ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ - ██║ ██║███████╗██║ ██║ ██║ ██║ ███████╗██║ ██║ - ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ -"""; - internal const string GeneratedFileMarker = "GeneratedFile: "; - private static readonly string Crlf = Environment.NewLine; private RefitGeneratorSettings? _cachedSettings; + private IGenerationReporter _reporter = new RichGenerationReporter(); + + private static IGenerationReporter CreateReporter(Settings settings) => + settings.SimpleOutput + ? new SimpleGenerationReporter() + : new RichGenerationReporter(); protected override ValidationResult Validate(CommandContext context, Settings settings) { @@ -49,6 +44,8 @@ protected override async Task ExecuteAsync(CommandContext context, Settings { RefitGeneratorSettings refitGeneratorSettings; + _reporter = CreateReporter(settings); + try { // Use cached settings from Validate() if available @@ -80,39 +77,14 @@ protected override async Task ExecuteAsync(CommandContext context, Settings version += " (local build)"; // Header with branding - if (settings.SimpleOutput) - { - Console.WriteLine(); - Console.WriteLine($"Refitter v{version}"); - Console.WriteLine("OpenAPI to Refit Interface Generator"); - Console.WriteLine(); - } - else - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold cyan]{AsciiArt}[/]"); - AnsiConsole.MarkupLine($"[bold cyan]╔═══════════════════════════════════════════════════════════════╗[/]"); - AnsiConsole.MarkupLine($"[bold cyan]║[/] [bold white]🚀 Refitter v{version,-48}[/] [bold cyan]║[/]"); - AnsiConsole.MarkupLine($"[bold cyan]║[/] [dim] OpenAPI to Refit Interface Generator[/]{new string(' ', 22)} [bold cyan]║[/]"); - AnsiConsole.MarkupLine($"[bold cyan]╚═══════════════════════════════════════════════════════════════╝[/]"); - AnsiConsole.WriteLine(); - } + _reporter.ReportHeader(version); // Support information var supportKey = settings.NoLogging ? "Unavailable when logging is disabled" : SupportInformation.GetSupportKey(); - if (settings.SimpleOutput) - { - Console.WriteLine($"Support key: {supportKey}"); - Console.WriteLine(); - } - else - { - AnsiConsole.MarkupLine($"[dim]🔑 Support key: {supportKey}[/]"); - AnsiConsole.WriteLine(); - } + _reporter.ReportSupportKey(supportKey); if (context.Arguments.Any(a => a.Equals("--version", StringComparison.OrdinalIgnoreCase)) || context.Arguments.Any(a => a.Equals("-v", StringComparison.OrdinalIgnoreCase))) @@ -132,13 +104,13 @@ protected override async Task ExecuteAsync(CommandContext context, Settings if (refitGeneratorSettings.OpenApiPaths == null || refitGeneratorSettings.OpenApiPaths.Length == 0) { var specPath = refitGeneratorSettings.OpenApiPath!; - await ValidateOpenApiSpec(specPath, settings); + await ValidateOpenApiSpec(specPath, _reporter); } else { foreach (var specPath in refitGeneratorSettings.OpenApiPaths) { - await ValidateOpenApiSpec(specPath, settings); + await ValidateOpenApiSpec(specPath, _reporter); } } } @@ -158,55 +130,16 @@ protected override async Task ExecuteAsync(CommandContext context, Settings if (refitGeneratorSettings.IncludePathMatches.Length > 0 && generator.OpenApiDocument.Paths.Count == 0) { - Console.WriteLine("⚠️ WARNING: All API paths were filtered out by --match-path patterns. ⚠️"); - Console.WriteLine($" Match Patterns used: {string.Join(", ", refitGeneratorSettings.IncludePathMatches)}"); - Console.WriteLine(); - Console.WriteLine(" This could indicate that:"); - Console.WriteLine(" 1. The regex patterns don't match any available paths"); - Console.WriteLine(" 2. There's a syntax error in the regex patterns"); - Console.WriteLine(" 3. The patterns were corrupted by command line interpretation"); - Console.WriteLine(); - Console.WriteLine(" This commonly happens when using the Windows Command Prompt (CMD) instead of PowerShell."); - Console.WriteLine(" The ^ character in regex patterns is interpreted as an escape character in CMD."); - Console.WriteLine(); - Console.WriteLine(" Solutions:"); - Console.WriteLine(" 1. Use PowerShell instead of CMD"); - Console.WriteLine(" 2. In CMD, escape the ^ character or use different quoting"); - Console.WriteLine(" 3. Use a .refitter settings file instead of command line arguments"); - Console.WriteLine(); + _reporter.ReportAllPathsFilteredWarning(refitGeneratorSettings.IncludePathMatches); } // Success summary with performance metrics stopwatch.Stop(); - if (settings.SimpleOutput) - { - Console.WriteLine("Generation completed successfully!"); - Console.WriteLine($"Duration: {stopwatch.Elapsed:mm\\:ss\\.ffff}"); - Console.WriteLine($"Performance: {(refitGeneratorSettings.GenerateMultipleFiles ? "Multi-file" : "Single-file")} generation"); - Console.WriteLine(); - } - else - { - var successPanel = new Panel( - $"[bold green]✅ Generation completed successfully![/]\n\n" + - $"[dim]📊 Duration:[/] [green]{stopwatch.Elapsed:mm\\:ss\\.ffff}[/]\n" + - $"[dim]🚀 Performance:[/] [green]{(refitGeneratorSettings.GenerateMultipleFiles ? "Multi-file" : "Single-file")} generation[/]" - ) - .BorderColor(Color.Green) - .RoundedBorder() - .Header("[bold green]🎉 Success[/]") - .HeaderAlignment(Justify.Center); - - AnsiConsole.Write(successPanel); - AnsiConsole.WriteLine(); - } + _reporter.ReportSuccess(stopwatch.Elapsed, refitGeneratorSettings.GenerateMultipleFiles); if (!settings.NoBanner) { - if (settings.SimpleOutput) - SimpleDonationBanner(); - else - DonationBanner(); + _reporter.ReportDonationBanner(); } ShowWarnings(refitGeneratorSettings, settings); @@ -215,103 +148,24 @@ protected override async Task ExecuteAsync(CommandContext context, Settings catch (Exception exception) { // Error summary panel - if (settings.SimpleOutput) - { - Console.WriteLine("Generation failed!"); - Console.WriteLine(); - } - else - { - var errorPanel = new Panel("[bold red]❌ Generation failed![/]") - .BorderColor(Color.Red) - .RoundedBorder() - .Header("[bold red]🚨 Error[/]") - .HeaderAlignment(Justify.Center); - - AnsiConsole.Write(errorPanel); - AnsiConsole.WriteLine(); - } + _reporter.ReportGenerationFailed(); if (exception is OpenApiUnsupportedSpecVersionException unsupportedSpecVersionException) { - if (settings.SimpleOutput) - { - Console.WriteLine($"Unsupported OpenAPI version: {unsupportedSpecVersionException.SpecificationVersion}"); - Console.WriteLine(); - } - else - { - var versionPanel = new Panel( - $"[bold red]🚫 Unsupported OpenAPI version: {unsupportedSpecVersionException.SpecificationVersion}[/]" - ) - .BorderColor(Color.Red) - .RoundedBorder(); - - AnsiConsole.Write(versionPanel); - AnsiConsole.WriteLine(); - } + _reporter.ReportUnsupportedVersion(unsupportedSpecVersionException.SpecificationVersion); } if (exception is not OpenApiValidationException) { - if (settings.SimpleOutput) - { - Console.WriteLine("Exception Details:"); - Console.WriteLine(exception.ToString()); - Console.WriteLine(); - } - else - { - AnsiConsole.MarkupLine("[bold red]🐛 Exception Details:[/]"); - AnsiConsole.WriteException(exception); - AnsiConsole.WriteLine(); - } + _reporter.ReportExceptionDetails(exception); } if (!settings.SkipValidation) { - if (settings.SimpleOutput) - { - Console.WriteLine("Suggestion"); - Console.WriteLine("Try using the --skip-validation argument."); - Console.WriteLine(); - } - else - { - var suggestionPanel = new Panel( - "💡 Try using the --skip-validation argument." - ) - .BorderColor(Color.Yellow) - .RoundedBorder() - .Header("Suggestion"); - - AnsiConsole.Write(suggestionPanel); - AnsiConsole.WriteLine(); - } + _reporter.ReportSkipValidationSuggestion(); } - if (settings.SimpleOutput) - { - Console.WriteLine("Support"); - Console.WriteLine("Need Help?"); - Console.WriteLine(); - Console.WriteLine("Report an issue: https://github.com/christianhelle/refitter/issues"); - Console.WriteLine(); - } - else - { - var helpPanel = new Panel( - "🆘 Need Help?\n\n" + - "🐛 Report an issue: https://github.com/christianhelle/refitter/issues" - ) - .BorderColor(Color.Yellow) - .RoundedBorder() - .Header("Support") - .HeaderAlignment(Justify.Center); - - AnsiConsole.Write(helpPanel); - AnsiConsole.WriteLine(); - } + _reporter.ReportSupportHelp(); await Analytics.LogError(exception, settings); return exception.HResult; @@ -374,70 +228,25 @@ private static RefitGeneratorSettings CreateRefitGeneratorSettings(Settings sett GenerateJsonSerializerContext = settings.GenerateJsonSerializerContext, }; } - private static async Task WriteSingleFile( + private async Task WriteSingleFile( RefitGenerator generator, Settings settings, RefitGeneratorSettings refitGeneratorSettings) { - // Show progress while generating - if (settings.SimpleOutput) - { - Console.WriteLine("Generating code..."); - } - else - { - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green bold")) - .StartAsync("[yellow]🔧 Generating code...[/]", async _ => - { - await Task.Delay(100); // Brief delay to show spinner - }); - } + await _reporter.ReportSingleFileGenerationProgressAsync(); var code = generator.Generate().ReplaceLineEndings(); - var outputPath = GetOutputPath(settings, refitGeneratorSettings); + var planned = OutputPlanner.PlanSingleFile(settings, refitGeneratorSettings, code); - var fileName = Path.GetFileName(outputPath); - var directory = Path.GetDirectoryName(outputPath) ?? ""; + var fileName = Path.GetFileName(planned.Path); + var directory = Path.GetDirectoryName(planned.Path) ?? ""; var sizeFormatted = FormatFileSize(code.Length); var lines = code.Split('\n').Length; - if (settings.SimpleOutput) - { - Console.WriteLine("Generated Output"); - Console.WriteLine($"File: {fileName}"); - Console.WriteLine($"Directory: {directory}"); - Console.WriteLine($"Size: {sizeFormatted}"); - Console.WriteLine($"Lines: {lines:N0}"); - Console.WriteLine(); - } - else - { - // Create a table for better formatting - var table = new Table() - .RoundedBorder() - .BorderColor(Color.Yellow) - .AddColumn(new TableColumn("[bold yellow]📁 Generated Output[/]").Centered()); - - table.AddRow($"[bold white]📄 File:[/] [cyan]{fileName}[/]"); - table.AddRow($"[bold white]📂 Directory:[/] [dim]{directory}[/]"); - table.AddRow($"[bold white]📊 Size:[/] [green]{sizeFormatted}[/]"); - table.AddRow($"[bold white]📝 Lines:[/] [green]{lines:N0}[/]"); - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - } - - var outputDirectory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrWhiteSpace(outputDirectory) && !Directory.Exists(outputDirectory)) - Directory.CreateDirectory(outputDirectory); + _reporter.ReportSingleFileOutput(fileName, directory, sizeFormatted, lines); - await File.WriteAllTextAsync(outputPath, code); - if (settings.SimpleOutput) - { - WriteGeneratedFileMarker(outputPath); - } + await FileWriter.WriteAsync(planned); + _reporter.ReportFileWritten(planned.Path); } private static string FormatFileSize(long bytes) @@ -459,155 +268,36 @@ private async Task WriteMultipleFiles( Settings settings, RefitGeneratorSettings refitGeneratorSettings) { - // Show progress while generating - GeneratorOutput generatorOutput; - if (settings.SimpleOutput) - { - Console.WriteLine("Generating multiple files..."); - generatorOutput = generator.GenerateMultipleFiles(); - } - else - { - generatorOutput = await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green bold")) - .StartAsync("[yellow]🔧 Generating multiple files...[/]", async _ => - { - await Task.Delay(100); // Brief delay to show spinner - return generator.GenerateMultipleFiles(); - }); - } + var generatorOutput = await _reporter.GenerateMultipleFilesWithProgressAsync(generator.GenerateMultipleFiles); + var planned = OutputPlanner.PlanMultipleFiles(settings, refitGeneratorSettings, generatorOutput); var totalSize = 0L; var totalLines = 0; - Table? table = null; + var report = _reporter.BeginMultiFileOutput(); - if (settings.SimpleOutput) + for (var i = 0; i < generatorOutput.Files.Count; i++) { - Console.WriteLine("Generated Output Files"); - Console.WriteLine($"{"File",-30} {"Size",-10} {"Lines",-10}"); - Console.WriteLine(new string('-', 55)); - } - else - { - // Create a table for better formatting - table = new Table() - .RoundedBorder() - .BorderColor(Color.Yellow) - .AddColumn(new TableColumn("[bold white]📄 File[/]").LeftAligned()) - .AddColumn(new TableColumn("[bold white]📂 Directory[/]").LeftAligned()) - .AddColumn(new TableColumn("[bold white]📊 Size[/]").RightAligned()) - .AddColumn(new TableColumn("[bold white]📝 Lines[/]").RightAligned()); - - table.Title = new TableTitle("[bold yellow]📁 Generated Output Files[/]"); - } - - foreach (var outputFile in generatorOutput.Files) - { - if ( - !string.IsNullOrWhiteSpace(refitGeneratorSettings.ContractsOutputFolder) - && refitGeneratorSettings.ContractsOutputFolder != RefitGeneratorSettings.DefaultOutputFolder - && outputFile.Filename == $"{TypenameConstants.Contracts}.cs" - ) - { - var root = string.IsNullOrWhiteSpace(settings.SettingsFilePath) - ? string.Empty - : Path.GetDirectoryName(settings.SettingsFilePath) ?? string.Empty; - - var contractsFolder = Path.GetFullPath(Path.Combine(root, refitGeneratorSettings.ContractsOutputFolder)); - if (!string.IsNullOrWhiteSpace(contractsFolder) && !Directory.Exists(contractsFolder)) - Directory.CreateDirectory(contractsFolder); - - var contractsFile = Path.Combine(contractsFolder ?? "./", outputFile.Filename); - var sizeFormatted = FormatFileSize(outputFile.Content.Length); - var contractsDir = Path.GetDirectoryName(contractsFile) ?? ""; - var lines = outputFile.Content.Split('\n').Length; - - if (settings.SimpleOutput) - { - Console.WriteLine($"{outputFile.Filename,-30} {sizeFormatted,-10} {lines,-10:N0}"); - } - else - { - table?.AddRow( - $"[cyan]{outputFile.Filename}[/]", - $"[dim]{contractsDir}[/]", - $"[green]{sizeFormatted}[/]", - $"[green]{lines:N0}[/]" - ); - } - - totalSize += outputFile.Content.Length; - totalLines += lines; - - await File.WriteAllTextAsync(contractsFile, outputFile.Content); - if (settings.SimpleOutput) - { - WriteGeneratedFileMarker(contractsFile); - } - continue; - } - - var code = outputFile.Content; - var outputPath = GetOutputPath(settings, refitGeneratorSettings, outputFile); - var formattedSize = FormatFileSize(code.Length); - var outputDirectory = Path.GetDirectoryName(outputPath) ?? ""; - var fileLines = code.Split('\n').Length; + var outputFile = generatorOutput.Files[i]; + var plannedFile = planned[i]; - if (settings.SimpleOutput) - { - Console.WriteLine($"{outputFile.Filename,-30} {formattedSize,-10} {fileLines,-10:N0}"); - } - else - { - table?.AddRow( - $"[cyan]{outputFile.Filename}[/]", - $"[dim]{outputDirectory}[/]", - $"[green]{formattedSize}[/]", - $"[green]{fileLines:N0}[/]" - ); - } + var size = outputFile.Content.Length; + var lines = outputFile.Content.Split('\n').Length; + var directory = Path.GetDirectoryName(plannedFile.Path) ?? ""; - totalSize += code.Length; - totalLines += fileLines; + report.AddFile(outputFile.Filename, directory, FormatFileSize(size), lines); - var fileDirectory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrWhiteSpace(fileDirectory) && !Directory.Exists(fileDirectory)) - Directory.CreateDirectory(fileDirectory); + totalSize += size; + totalLines += lines; - await File.WriteAllTextAsync(outputPath, code); - if (settings.SimpleOutput) - { - WriteGeneratedFileMarker(outputPath); - } + await FileWriter.WriteAsync(plannedFile); + _reporter.ReportFileWritten(plannedFile.Path); } - if (settings.SimpleOutput) - { - Console.WriteLine(new string('-', 55)); - Console.WriteLine($"{"Total (" + generatorOutput.Files.Count + " files)",-30} {FormatFileSize(totalSize),-10} {totalLines,-10:N0}"); - Console.WriteLine(); - } - else - { - // Add summary row - table?.AddEmptyRow(); - table?.AddRow( - $"[bold yellow]📊 Total ({generatorOutput.Files.Count} files)[/]", - "[dim]---[/]", - $"[bold green]{FormatFileSize(totalSize)}[/]", - $"[bold green]{totalLines:N0}[/]" - ); - - if (table != null) - { - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - } - } + report.Complete(generatorOutput.Files.Count, FormatFileSize(totalSize), totalLines); } - private static void ShowWarnings(RefitGeneratorSettings refitGeneratorSettings, Settings settings) + + private void ShowWarnings(RefitGeneratorSettings refitGeneratorSettings, Settings settings) { var warnings = new List<(string title, string description)>(); @@ -632,113 +322,11 @@ private static void ShowWarnings(RefitGeneratorSettings refitGeneratorSettings, if (warnings.Any()) { - if (settings.SimpleOutput) - { - Console.WriteLine("Configuration Warnings"); - foreach (var (title, description) in warnings) - { - Console.WriteLine($"Warning: {title}"); - Console.WriteLine($"Description: {description}"); - Console.WriteLine(); - } - } - else - { - var table = new Table() - .RoundedBorder() - .BorderColor(Color.Orange3) - .AddColumn(new TableColumn("[bold white]Warning[/]").LeftAligned()) - .AddColumn(new TableColumn("[bold white]Description[/]").LeftAligned()); - - table.Title = new TableTitle("[bold yellow]⚠️ Configuration Warnings[/]"); - - foreach (var (title, description) in warnings) - { - table.AddRow( - $"[bold orange3]{title}[/]", - $"[orange3]{description}[/]" - ); - } - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - } - } - } - private static void DonationBanner() - { - var panel = new Panel( - "[yellow]💖 [bold]Enjoying Refitter?[/] Consider supporting the project![/]\n\n" + - "[cyan]🎯 Sponsor:[/] [link]https://github.com/sponsors/christianhelle[/]\n" + - "[yellow]☕ Buy me a coffee:[/] [link]https://www.buymeacoffee.com/christianhelle[/]\n\n" + - "[red]🐛 Found an issue?[/] [link]https://github.com/christianhelle/refitter/issues[/]" - ) - .BorderColor(Color.Grey) - .RoundedBorder() - .Header("[bold dim]💝 Support[/]") - .HeaderAlignment(Justify.Center); - - AnsiConsole.Write(panel); - AnsiConsole.WriteLine(); - } - - private static void SimpleDonationBanner() - { - Console.WriteLine("Support"); - Console.WriteLine("Enjoying Refitter? Consider supporting the project!"); - Console.WriteLine(); - Console.WriteLine("Sponsor: https://github.com/sponsors/christianhelle"); - Console.WriteLine("Buy me a coffee: https://www.buymeacoffee.com/christianhelle"); - Console.WriteLine(); - Console.WriteLine("Found an issue? https://github.com/christianhelle/refitter/issues"); - Console.WriteLine(); - } - - private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) - { - // Direct CLI invocation (no settings file) - if (UsesDirectCliOutput(settings)) - { - return settings.OutputPath!; + _reporter.ReportConfigurationWarnings(warnings); } - - 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; } + private static string GetOutputPath(Settings settings, RefitGeneratorSettings refitGeneratorSettings) => + OutputPlanner.GetSingleFileOutputPath(settings, refitGeneratorSettings); internal static void ApplySettingsFileDefaults(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) { @@ -765,233 +353,35 @@ internal static void ApplySettingsFileDefaults(string settingsFilePath, RefitGen private string GetOutputPath( 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); - } - - 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; - } + GeneratedCode outputFile) => + OutputPlanner.GetMultiFileOutputPath(settings, refitGeneratorSettings, outputFile); internal static string FormatGeneratedFileMarker(string outputPath) => $"{GeneratedFileMarker}{Path.GetFullPath(outputPath)}"; - private static void WriteGeneratedFileMarker(string outputPath) => - Console.WriteLine(FormatGeneratedFileMarker(outputPath)); - - private static async Task ValidateOpenApiSpec(string openApiPath, Settings settings) + private static async Task ValidateOpenApiSpec(string openApiPath, IGenerationReporter reporter) { - OpenApiValidationResult validationResult; - if (settings.SimpleOutput) - { - Console.WriteLine("Validating OpenAPI specification..."); - validationResult = await Validation.OpenApiValidator.Validate(openApiPath); - } - else - { - validationResult = await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("cyan bold")) - .StartAsync("[cyan]🔍 Validating OpenAPI specification...[/]", async _ => - { - return await Validation.OpenApiValidator.Validate(openApiPath); - }); - } + var validationResult = await reporter.ValidateWithProgressAsync( + () => Validation.OpenApiValidator.Validate(openApiPath)); if (!validationResult.IsValid) { - if (settings.SimpleOutput) - { - Console.WriteLine(); - Console.WriteLine("OpenAPI validation failed!"); - Console.WriteLine(); - } - else - { - AnsiConsole.WriteLine(); - var errorPanel = new Panel("[red]❌ OpenAPI validation failed![/]") - .BorderColor(Color.Red) - .RoundedBorder(); - AnsiConsole.Write(errorPanel); - AnsiConsole.WriteLine(); - } + reporter.ReportValidationFailed(); foreach (var error in validationResult.Diagnostics.Errors) { - TryWriteLine(error, "red", "Error", settings.SimpleOutput); + reporter.ReportValidationDiagnostic(error, isError: true); } foreach (var warning in validationResult.Diagnostics.Warnings) { - TryWriteLine(warning, "yellow", "Warning", settings.SimpleOutput); + reporter.ReportValidationDiagnostic(warning, isError: false); } validationResult.ThrowIfInvalid(); } - if (settings.SimpleOutput) - { - Console.WriteLine("OpenAPI Analysis Results"); - var stats = validationResult.Statistics.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in stats) - { - if (line.Trim().StartsWith("-")) - { - var parts = line.Trim().TrimStart('-').Trim().Split(':', 2); - if (parts.Length == 2) - { - var label = parts[0].Trim(); - var value = parts[1].Trim(); - var description = GetStatsDescription(label); - Console.WriteLine($"{label}: {value} - {description}"); - } - } - } - Console.WriteLine(); - } - else - { - // Create statistics table - var table = new Table() - .RoundedBorder() - .BorderColor(Color.Blue) - .AddColumn(new TableColumn("[bold white]📊 Metric[/]").LeftAligned()) - .AddColumn(new TableColumn("[bold white]📈 Count[/]").RightAligned()) - .AddColumn(new TableColumn("[bold white]📝 Details[/]").LeftAligned()); - - table.Title = new TableTitle("[bold cyan]📊 OpenAPI Analysis Results[/]"); - - var stats = validationResult.Statistics.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in stats) - { - if (line.Trim().StartsWith("-")) - { - var parts = line.Trim().TrimStart('-').Trim().Split(':', 2); - if (parts.Length == 2) - { - var label = parts[0].Trim(); - var value = parts[1].Trim(); - var icon = GetStatsIcon(label); - var description = GetStatsDescription(label); - - table.AddRow( - $"{icon} [bold]{label}[/]", - $"[green]{value}[/]", - $"[dim]{description}[/]" - ); - } - } - } - - AnsiConsole.Write(table); - AnsiConsole.WriteLine(); - } - } - private static string GetStatsIcon(string label) - { - return label.ToLowerInvariant() switch - { - var l when l.Contains("path") => "📝", - var l when l.Contains("operation") => "⚡", - var l when l.Contains("parameter") => "📝", - var l when l.Contains("request") => "📤", - var l when l.Contains("response") => "📥", - var l when l.Contains("link") => "🔗", - var l when l.Contains("callback") => "🔄", - var l when l.Contains("schema") => "📋", - _ => "📊" - }; - } - - private static string GetStatsDescription(string label) - { - return label.ToLowerInvariant() switch - { - var l when l.Contains("path") => "API endpoints defined", - var l when l.Contains("operation") => "HTTP operations available", - var l when l.Contains("parameter") => "Input parameters defined", - var l when l.Contains("request") => "Request body schemas", - var l when l.Contains("response") => "Response schemas defined", - var l when l.Contains("link") => "Operation links", - var l when l.Contains("callback") => "Callback definitions", - var l when l.Contains("schema") => "Data schemas defined", - _ => "API specification metric" - }; - } - - private static void TryWriteLine( - OpenApiError error, - string color, - string label, - bool simpleOutput = false) - { - if (simpleOutput) - { - Console.WriteLine($"{label}:{Crlf}{error}{Crlf}"); - return; - } - - try - { - AnsiConsole.MarkupLine($"[{color}]{label}:{Crlf}{error}{Crlf}[/]"); - } - catch - { - var originalColor = Console.ForegroundColor; - Console.ForegroundColor = color switch - { - "red" => ConsoleColor.Red, - "yellow" => ConsoleColor.Yellow, - _ => originalColor - }; - - Console.WriteLine($"{label}:{Crlf}{error}{Crlf}"); - - Console.ForegroundColor = originalColor; - } + reporter.ReportValidationStatistics(validationResult); } internal static async Task WriteRefitterSettingsFile(Settings settings, RefitGeneratorSettings refitGeneratorSettings) @@ -1005,28 +395,7 @@ internal static async Task WriteRefitterSettingsFile(Settings settings, RefitGen var json = Serializer.Serialize(refitGeneratorSettings); await File.WriteAllTextAsync(settingsFilePath, json); - if (settings.SimpleOutput) - { - Console.WriteLine($"Settings file written to: {settingsFilePath}"); - Console.WriteLine(); - } - else - { - var fileName = Path.GetFileName(settingsFilePath); - var directory = Path.GetDirectoryName(settingsFilePath) ?? ""; - - var panel = new Panel( - $"[bold white]📄 File:[/] [cyan]{fileName}[/]\n" + - $"[bold white]📂 Directory:[/] [dim]{directory}[/]" - ) - .BorderColor(Color.Green) - .RoundedBorder() - .Header("[bold green]💾 Settings File Generated[/]") - .HeaderAlignment(Justify.Center); - - AnsiConsole.Write(panel); - AnsiConsole.WriteLine(); - } + CreateReporter(settings).ReportSettingsFileGenerated(settingsFilePath); } internal static string DetermineSettingsFilePath(Settings settings) @@ -1052,34 +421,9 @@ internal static string DetermineSettingsFilePath(Settings settings) internal static void ResolveRelativeSpecPaths(string settingsFilePath, RefitGeneratorSettings refitGeneratorSettings) { var settingsFileDirectory = Path.GetDirectoryName(Path.GetFullPath(settingsFilePath)) ?? string.Empty; - - // Resolve OpenApiPath if it's a relative path - if (!string.IsNullOrWhiteSpace(refitGeneratorSettings.OpenApiPath) && - !IsUrl(refitGeneratorSettings.OpenApiPath) && - !Path.IsPathRooted(refitGeneratorSettings.OpenApiPath)) - { - refitGeneratorSettings.OpenApiPath = Path.GetFullPath( - Path.Combine(settingsFileDirectory, refitGeneratorSettings.OpenApiPath)); - } - - // Resolve all OpenApiPaths entries if they're relative paths - if (refitGeneratorSettings.OpenApiPaths is { Length: > 0 }) - { - for (var i = 0; i < refitGeneratorSettings.OpenApiPaths.Length; i++) - { - var path = refitGeneratorSettings.OpenApiPaths[i]; - if (!IsUrl(path) && !Path.IsPathRooted(path)) - { - refitGeneratorSettings.OpenApiPaths[i] = Path.GetFullPath( - Path.Combine(settingsFileDirectory, path)); - } - } - } + RefitterSettingsLoader.ResolveRelativeSpecPaths(refitGeneratorSettings, settingsFileDirectory); } - internal static bool IsUrl(string path) - { - return Uri.TryCreate(path, UriKind.Absolute, out var uriResult) && - (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); - } + internal static bool IsUrl(string path) => + RefitterSettingsLoader.IsUrl(path); } diff --git a/src/Refitter/IGenerationReporter.cs b/src/Refitter/IGenerationReporter.cs new file mode 100644 index 00000000..324e4c12 --- /dev/null +++ b/src/Refitter/IGenerationReporter.cs @@ -0,0 +1,77 @@ +using Microsoft.OpenApi; +using Refitter.Core; +using Refitter.Validation; + +namespace Refitter; + +/// +/// The console presentation seam for the generate command. Every piece of +/// human-facing output the CLI produces flows through one of these calls, so the +/// command/runner orchestration no longer branches on --simple-output. +/// Two adapters implement it: (plain text, +/// machine-friendly) and (Spectre.Console). +/// +internal interface IGenerationReporter +{ + void ReportHeader(string version); + + void ReportSupportKey(string supportKey); + + Task ReportSingleFileGenerationProgressAsync(); + + void ReportSingleFileOutput(string fileName, string directory, string sizeFormatted, int lines); + + Task GenerateMultipleFilesWithProgressAsync(Func generate); + + IMultiFileOutputReport BeginMultiFileOutput(); + + /// + /// Emits the machine-readable GeneratedFile: marker after a file is + /// written. Only the simple reporter produces output; the rich reporter is a no-op. + /// + void ReportFileWritten(string outputPath); + + Task ValidateWithProgressAsync(Func> validate); + + void ReportValidationFailed(); + + void ReportValidationDiagnostic(OpenApiError error, bool isError); + + void ReportValidationStatistics(OpenApiValidationResult validationResult); + + void ReportSuccess(TimeSpan duration, bool multipleFiles); + + void ReportDonationBanner(); + + void ReportConfigurationWarnings(IReadOnlyList<(string Title, string Description)> warnings); + + /// + /// Emitted when every API path was filtered out by --match-path patterns. + /// The output is identical in both reporters (plain text in each). + /// + void ReportAllPathsFilteredWarning(IReadOnlyList matchPatterns); + + void ReportSettingsFileGenerated(string settingsFilePath); + + void ReportGenerationFailed(); + + void ReportUnsupportedVersion(string specificationVersion); + + void ReportExceptionDetails(Exception exception); + + void ReportSkipValidationSuggestion(); + + void ReportSupportHelp(); +} + +/// +/// Stateful sub-report for the multi-file output listing. The simple reporter +/// prints each row as it is added (interleaved with file writes); the rich +/// reporter buffers rows into a table rendered on . +/// +internal interface IMultiFileOutputReport +{ + void AddFile(string fileName, string directory, string sizeFormatted, int lines); + + void Complete(int fileCount, string totalSizeFormatted, int totalLines); +} diff --git a/src/Refitter/OpenApiStatisticsFormatter.cs b/src/Refitter/OpenApiStatisticsFormatter.cs new file mode 100644 index 00000000..cd846434 --- /dev/null +++ b/src/Refitter/OpenApiStatisticsFormatter.cs @@ -0,0 +1,54 @@ +namespace Refitter; + +/// +/// Parses the textual OpenAPI statistics produced during validation into +/// label/value pairs and maps each metric to its display icon and description. +/// Shared by the simple and rich reporters so the parsing rules live in one place. +/// +internal static class OpenApiStatisticsFormatter +{ + public static IReadOnlyList<(string Label, string Value)> Parse(string statistics) + { + var result = new List<(string Label, string Value)>(); + var lines = statistics.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (!line.Trim().StartsWith("-")) + continue; + + var parts = line.Trim().TrimStart('-').Trim().Split(':', 2); + if (parts.Length == 2) + result.Add((parts[0].Trim(), parts[1].Trim())); + } + + return result; + } + + public static string GetIcon(string label) => + label.ToLowerInvariant() switch + { + var l when l.Contains("path") => "📝", + var l when l.Contains("operation") => "⚡", + var l when l.Contains("parameter") => "📝", + var l when l.Contains("request") => "📤", + var l when l.Contains("response") => "📥", + var l when l.Contains("link") => "🔗", + var l when l.Contains("callback") => "🔄", + var l when l.Contains("schema") => "📋", + _ => "📊" + }; + + public static string GetDescription(string label) => + label.ToLowerInvariant() switch + { + var l when l.Contains("path") => "API endpoints defined", + var l when l.Contains("operation") => "HTTP operations available", + var l when l.Contains("parameter") => "Input parameters defined", + var l when l.Contains("request") => "Request body schemas", + var l when l.Contains("response") => "Response schemas defined", + var l when l.Contains("link") => "Operation links", + var l when l.Contains("callback") => "Callback definitions", + var l when l.Contains("schema") => "Data schemas defined", + _ => "API specification metric" + }; +} diff --git a/src/Refitter/OutputPlanner.cs b/src/Refitter/OutputPlanner.cs new file mode 100644 index 00000000..ae3c7188 --- /dev/null +++ b/src/Refitter/OutputPlanner.cs @@ -0,0 +1,161 @@ +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 new file mode 100644 index 00000000..f52b1dc4 --- /dev/null +++ b/src/Refitter/RichGenerationReporter.cs @@ -0,0 +1,354 @@ +using Microsoft.OpenApi; +using Refitter.Core; +using Refitter.Validation; +using Spectre.Console; + +namespace Refitter; + +/// +/// Spectre.Console reporter used for the default rich CLI experience: ASCII-art +/// banner, spinners, panels, and tables. Every method mirrors the original +/// rich-output branch of the generate command. +/// +internal sealed class RichGenerationReporter : IGenerationReporter +{ + private static readonly string Crlf = Environment.NewLine; + + private const string AsciiArt = +""" + ██████╗ ███████╗███████╗██╗████████╗████████╗███████╗██████╗ + ██╔══██╗██╔════╝██╔════╝██║╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ + ██████╔╝█████╗ █████╗ ██║ ██║ ██║ █████╗ ██████╔╝ + ██╔══██╗██╔══╝ ██╔══╝ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ + ██║ ██║███████╗██║ ██║ ██║ ██║ ███████╗██║ ██║ + ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +"""; + + public void ReportHeader(string version) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold cyan]{AsciiArt}[/]"); + AnsiConsole.MarkupLine($"[bold cyan]╔═══════════════════════════════════════════════════════════════╗[/]"); + AnsiConsole.MarkupLine($"[bold cyan]║[/] [bold white]🚀 Refitter v{version,-48}[/] [bold cyan]║[/]"); + AnsiConsole.MarkupLine($"[bold cyan]║[/] [dim] OpenAPI to Refit Interface Generator[/]{new string(' ', 22)} [bold cyan]║[/]"); + AnsiConsole.MarkupLine($"[bold cyan]╚═══════════════════════════════════════════════════════════════╝[/]"); + AnsiConsole.WriteLine(); + } + + public void ReportSupportKey(string supportKey) + { + AnsiConsole.MarkupLine($"[dim]🔑 Support key: {supportKey}[/]"); + AnsiConsole.WriteLine(); + } + + public async Task ReportSingleFileGenerationProgressAsync() + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green bold")) + .StartAsync("[yellow]🔧 Generating code...[/]", async _ => + { + await Task.Delay(100); // Brief delay to show spinner + }); + } + + public void ReportSingleFileOutput(string fileName, string directory, string sizeFormatted, int lines) + { + var table = new Table() + .RoundedBorder() + .BorderColor(Color.Yellow) + .AddColumn(new TableColumn("[bold yellow]📁 Generated Output[/]").Centered()); + + table.AddRow($"[bold white]📄 File:[/] [cyan]{fileName}[/]"); + table.AddRow($"[bold white]📂 Directory:[/] [dim]{directory}[/]"); + table.AddRow($"[bold white]📊 Size:[/] [green]{sizeFormatted}[/]"); + table.AddRow($"[bold white]📝 Lines:[/] [green]{lines:N0}[/]"); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + + public async Task GenerateMultipleFilesWithProgressAsync(Func generate) + { + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green bold")) + .StartAsync("[yellow]🔧 Generating multiple files...[/]", async _ => + { + await Task.Delay(100); // Brief delay to show spinner + return generate(); + }); + } + + public IMultiFileOutputReport BeginMultiFileOutput() => new RichMultiFileOutputReport(); + + public void ReportFileWritten(string outputPath) + { + // Rich output does not emit the machine-readable marker. + } + + public async Task ValidateWithProgressAsync(Func> validate) + { + return await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("cyan bold")) + .StartAsync("[cyan]🔍 Validating OpenAPI specification...[/]", async _ => + { + return await validate(); + }); + } + + public void ReportValidationFailed() + { + AnsiConsole.WriteLine(); + var errorPanel = new Panel("[red]❌ OpenAPI validation failed![/]") + .BorderColor(Color.Red) + .RoundedBorder(); + AnsiConsole.Write(errorPanel); + AnsiConsole.WriteLine(); + } + + public void ReportValidationDiagnostic(OpenApiError error, bool isError) + { + var color = isError ? "red" : "yellow"; + var label = isError ? "Error" : "Warning"; + + try + { + AnsiConsole.MarkupLine($"[{color}]{label}:{Crlf}{error}{Crlf}[/]"); + } + catch + { + var originalColor = Console.ForegroundColor; + Console.ForegroundColor = color switch + { + "red" => ConsoleColor.Red, + "yellow" => ConsoleColor.Yellow, + _ => originalColor + }; + + Console.WriteLine($"{label}:{Crlf}{error}{Crlf}"); + + Console.ForegroundColor = originalColor; + } + } + + public void ReportValidationStatistics(OpenApiValidationResult validationResult) + { + var table = new Table() + .RoundedBorder() + .BorderColor(Color.Blue) + .AddColumn(new TableColumn("[bold white]📊 Metric[/]").LeftAligned()) + .AddColumn(new TableColumn("[bold white]📈 Count[/]").RightAligned()) + .AddColumn(new TableColumn("[bold white]📝 Details[/]").LeftAligned()); + + table.Title = new TableTitle("[bold cyan]📊 OpenAPI Analysis Results[/]"); + + foreach (var (label, value) in OpenApiStatisticsFormatter.Parse(validationResult.Statistics.ToString())) + { + var icon = OpenApiStatisticsFormatter.GetIcon(label); + var description = OpenApiStatisticsFormatter.GetDescription(label); + + table.AddRow( + $"{icon} [bold]{label}[/]", + $"[green]{value}[/]", + $"[dim]{description}[/]" + ); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + + public void ReportSuccess(TimeSpan duration, bool multipleFiles) + { + var successPanel = new Panel( + $"[bold green]✅ Generation completed successfully![/]\n\n" + + $"[dim]📊 Duration:[/] [green]{duration:mm\\:ss\\.ffff}[/]\n" + + $"[dim]🚀 Performance:[/] [green]{(multipleFiles ? "Multi-file" : "Single-file")} generation[/]" + ) + .BorderColor(Color.Green) + .RoundedBorder() + .Header("[bold green]🎉 Success[/]") + .HeaderAlignment(Justify.Center); + + AnsiConsole.Write(successPanel); + AnsiConsole.WriteLine(); + } + + public void ReportDonationBanner() + { + var panel = new Panel( + "[yellow]💖 [bold]Enjoying Refitter?[/] Consider supporting the project![/]\n\n" + + "[cyan]🎯 Sponsor:[/] [link]https://github.com/sponsors/christianhelle[/]\n" + + "[yellow]☕ Buy me a coffee:[/] [link]https://www.buymeacoffee.com/christianhelle[/]\n\n" + + "[red]🐛 Found an issue?[/] [link]https://github.com/christianhelle/refitter/issues[/]" + ) + .BorderColor(Color.Grey) + .RoundedBorder() + .Header("[bold dim]💝 Support[/]") + .HeaderAlignment(Justify.Center); + + AnsiConsole.Write(panel); + AnsiConsole.WriteLine(); + } + + public void ReportConfigurationWarnings(IReadOnlyList<(string Title, string Description)> warnings) + { + var table = new Table() + .RoundedBorder() + .BorderColor(Color.Orange3) + .AddColumn(new TableColumn("[bold white]Warning[/]").LeftAligned()) + .AddColumn(new TableColumn("[bold white]Description[/]").LeftAligned()); + + table.Title = new TableTitle("[bold yellow]⚠️ Configuration Warnings[/]"); + + foreach (var (title, description) in warnings) + { + table.AddRow( + $"[bold orange3]{title}[/]", + $"[orange3]{description}[/]" + ); + } + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + + public void ReportAllPathsFilteredWarning(IReadOnlyList matchPatterns) + { + Console.WriteLine("⚠️ WARNING: All API paths were filtered out by --match-path patterns. ⚠️"); + Console.WriteLine($" Match Patterns used: {string.Join(", ", matchPatterns)}"); + Console.WriteLine(); + Console.WriteLine(" This could indicate that:"); + Console.WriteLine(" 1. The regex patterns don't match any available paths"); + Console.WriteLine(" 2. There's a syntax error in the regex patterns"); + Console.WriteLine(" 3. The patterns were corrupted by command line interpretation"); + Console.WriteLine(); + Console.WriteLine(" This commonly happens when using the Windows Command Prompt (CMD) instead of PowerShell."); + Console.WriteLine(" The ^ character in regex patterns is interpreted as an escape character in CMD."); + Console.WriteLine(); + Console.WriteLine(" Solutions:"); + Console.WriteLine(" 1. Use PowerShell instead of CMD"); + Console.WriteLine(" 2. In CMD, escape the ^ character or use different quoting"); + Console.WriteLine(" 3. Use a .refitter settings file instead of command line arguments"); + Console.WriteLine(); + } + + public void ReportSettingsFileGenerated(string settingsFilePath) + { + var fileName = Path.GetFileName(settingsFilePath); + var directory = Path.GetDirectoryName(settingsFilePath) ?? ""; + + var panel = new Panel( + $"[bold white]📄 File:[/] [cyan]{fileName}[/]\n" + + $"[bold white]📂 Directory:[/] [dim]{directory}[/]" + ) + .BorderColor(Color.Green) + .RoundedBorder() + .Header("[bold green]💾 Settings File Generated[/]") + .HeaderAlignment(Justify.Center); + + AnsiConsole.Write(panel); + AnsiConsole.WriteLine(); + } + + public void ReportGenerationFailed() + { + var errorPanel = new Panel("[bold red]❌ Generation failed![/]") + .BorderColor(Color.Red) + .RoundedBorder() + .Header("[bold red]🚨 Error[/]") + .HeaderAlignment(Justify.Center); + + AnsiConsole.Write(errorPanel); + AnsiConsole.WriteLine(); + } + + public void ReportUnsupportedVersion(string specificationVersion) + { + var versionPanel = new Panel( + $"[bold red]🚫 Unsupported OpenAPI version: {specificationVersion}[/]" + ) + .BorderColor(Color.Red) + .RoundedBorder(); + + AnsiConsole.Write(versionPanel); + AnsiConsole.WriteLine(); + } + + public void ReportExceptionDetails(Exception exception) + { + AnsiConsole.MarkupLine("[bold red]🐛 Exception Details:[/]"); + AnsiConsole.WriteException(exception); + AnsiConsole.WriteLine(); + } + + public void ReportSkipValidationSuggestion() + { + var suggestionPanel = new Panel( + "💡 Try using the --skip-validation argument." + ) + .BorderColor(Color.Yellow) + .RoundedBorder() + .Header("Suggestion"); + + AnsiConsole.Write(suggestionPanel); + AnsiConsole.WriteLine(); + } + + public void ReportSupportHelp() + { + var helpPanel = new Panel( + "🆘 Need Help?\n\n" + + "🐛 Report an issue: https://github.com/christianhelle/refitter/issues" + ) + .BorderColor(Color.Yellow) + .RoundedBorder() + .Header("Support") + .HeaderAlignment(Justify.Center); + + AnsiConsole.Write(helpPanel); + AnsiConsole.WriteLine(); + } + + private sealed class RichMultiFileOutputReport : IMultiFileOutputReport + { + private readonly Table table; + + public RichMultiFileOutputReport() + { + table = new Table() + .RoundedBorder() + .BorderColor(Color.Yellow) + .AddColumn(new TableColumn("[bold white]📄 File[/]").LeftAligned()) + .AddColumn(new TableColumn("[bold white]📂 Directory[/]").LeftAligned()) + .AddColumn(new TableColumn("[bold white]📊 Size[/]").RightAligned()) + .AddColumn(new TableColumn("[bold white]📝 Lines[/]").RightAligned()); + + table.Title = new TableTitle("[bold yellow]📁 Generated Output Files[/]"); + } + + public void AddFile(string fileName, string directory, string sizeFormatted, int lines) => + table.AddRow( + $"[cyan]{fileName}[/]", + $"[dim]{directory}[/]", + $"[green]{sizeFormatted}[/]", + $"[green]{lines:N0}[/]" + ); + + public void Complete(int fileCount, string totalSizeFormatted, int totalLines) + { + table.AddEmptyRow(); + table.AddRow( + $"[bold yellow]📊 Total ({fileCount} files)[/]", + "[dim]---[/]", + $"[bold green]{totalSizeFormatted}[/]", + $"[bold green]{totalLines:N0}[/]" + ); + + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + } +} diff --git a/src/Refitter/SimpleGenerationReporter.cs b/src/Refitter/SimpleGenerationReporter.cs new file mode 100644 index 00000000..a7742ff3 --- /dev/null +++ b/src/Refitter/SimpleGenerationReporter.cs @@ -0,0 +1,199 @@ +using Microsoft.OpenApi; +using Refitter.Core; +using Refitter.Validation; + +namespace Refitter; + +/// +/// Plain-text reporter used with --simple-output. Produces deterministic, +/// machine-parseable console output (including the GeneratedFile: markers). +/// Every method mirrors the original simple-output branch of the generate command. +/// +internal sealed class SimpleGenerationReporter : IGenerationReporter +{ + private static readonly string Crlf = Environment.NewLine; + + public void ReportHeader(string version) + { + Console.WriteLine(); + Console.WriteLine($"Refitter v{version}"); + Console.WriteLine("OpenAPI to Refit Interface Generator"); + Console.WriteLine(); + } + + public void ReportSupportKey(string supportKey) + { + Console.WriteLine($"Support key: {supportKey}"); + Console.WriteLine(); + } + + public Task ReportSingleFileGenerationProgressAsync() + { + Console.WriteLine("Generating code..."); + return Task.CompletedTask; + } + + public void ReportSingleFileOutput(string fileName, string directory, string sizeFormatted, int lines) + { + Console.WriteLine("Generated Output"); + Console.WriteLine($"File: {fileName}"); + Console.WriteLine($"Directory: {directory}"); + Console.WriteLine($"Size: {sizeFormatted}"); + Console.WriteLine($"Lines: {lines:N0}"); + Console.WriteLine(); + } + + public Task GenerateMultipleFilesWithProgressAsync(Func generate) + { + Console.WriteLine("Generating multiple files..."); + return Task.FromResult(generate()); + } + + public IMultiFileOutputReport BeginMultiFileOutput() => new SimpleMultiFileOutputReport(); + + public void ReportFileWritten(string outputPath) => + Console.WriteLine(GenerateCommand.FormatGeneratedFileMarker(outputPath)); + + public async Task ValidateWithProgressAsync(Func> validate) + { + Console.WriteLine("Validating OpenAPI specification..."); + return await validate(); + } + + public void ReportValidationFailed() + { + Console.WriteLine(); + Console.WriteLine("OpenAPI validation failed!"); + Console.WriteLine(); + } + + public void ReportValidationDiagnostic(OpenApiError error, bool isError) + { + var label = isError ? "Error" : "Warning"; + Console.WriteLine($"{label}:{Crlf}{error}{Crlf}"); + } + + public void ReportValidationStatistics(OpenApiValidationResult validationResult) + { + Console.WriteLine("OpenAPI Analysis Results"); + foreach (var (label, value) in OpenApiStatisticsFormatter.Parse(validationResult.Statistics.ToString())) + { + var description = OpenApiStatisticsFormatter.GetDescription(label); + Console.WriteLine($"{label}: {value} - {description}"); + } + + Console.WriteLine(); + } + + public void ReportSuccess(TimeSpan duration, bool multipleFiles) + { + Console.WriteLine("Generation completed successfully!"); + Console.WriteLine($"Duration: {duration:mm\\:ss\\.ffff}"); + Console.WriteLine($"Performance: {(multipleFiles ? "Multi-file" : "Single-file")} generation"); + Console.WriteLine(); + } + + public void ReportDonationBanner() + { + Console.WriteLine("Support"); + Console.WriteLine("Enjoying Refitter? Consider supporting the project!"); + Console.WriteLine(); + Console.WriteLine("Sponsor: https://github.com/sponsors/christianhelle"); + Console.WriteLine("Buy me a coffee: https://www.buymeacoffee.com/christianhelle"); + Console.WriteLine(); + Console.WriteLine("Found an issue? https://github.com/christianhelle/refitter/issues"); + Console.WriteLine(); + } + + public void ReportConfigurationWarnings(IReadOnlyList<(string Title, string Description)> warnings) + { + Console.WriteLine("Configuration Warnings"); + foreach (var (title, description) in warnings) + { + Console.WriteLine($"Warning: {title}"); + Console.WriteLine($"Description: {description}"); + Console.WriteLine(); + } + } + + public void ReportAllPathsFilteredWarning(IReadOnlyList matchPatterns) + { + Console.WriteLine("⚠️ WARNING: All API paths were filtered out by --match-path patterns. ⚠️"); + Console.WriteLine($" Match Patterns used: {string.Join(", ", matchPatterns)}"); + Console.WriteLine(); + Console.WriteLine(" This could indicate that:"); + Console.WriteLine(" 1. The regex patterns don't match any available paths"); + Console.WriteLine(" 2. There's a syntax error in the regex patterns"); + Console.WriteLine(" 3. The patterns were corrupted by command line interpretation"); + Console.WriteLine(); + Console.WriteLine(" This commonly happens when using the Windows Command Prompt (CMD) instead of PowerShell."); + Console.WriteLine(" The ^ character in regex patterns is interpreted as an escape character in CMD."); + Console.WriteLine(); + Console.WriteLine(" Solutions:"); + Console.WriteLine(" 1. Use PowerShell instead of CMD"); + Console.WriteLine(" 2. In CMD, escape the ^ character or use different quoting"); + Console.WriteLine(" 3. Use a .refitter settings file instead of command line arguments"); + Console.WriteLine(); + } + + public void ReportSettingsFileGenerated(string settingsFilePath) + { + Console.WriteLine($"Settings file written to: {settingsFilePath}"); + Console.WriteLine(); + } + + public void ReportGenerationFailed() + { + Console.WriteLine("Generation failed!"); + Console.WriteLine(); + } + + public void ReportUnsupportedVersion(string specificationVersion) + { + Console.WriteLine($"Unsupported OpenAPI version: {specificationVersion}"); + Console.WriteLine(); + } + + public void ReportExceptionDetails(Exception exception) + { + Console.WriteLine("Exception Details:"); + Console.WriteLine(exception.ToString()); + Console.WriteLine(); + } + + public void ReportSkipValidationSuggestion() + { + Console.WriteLine("Suggestion"); + Console.WriteLine("Try using the --skip-validation argument."); + Console.WriteLine(); + } + + public void ReportSupportHelp() + { + Console.WriteLine("Support"); + Console.WriteLine("Need Help?"); + Console.WriteLine(); + Console.WriteLine("Report an issue: https://github.com/christianhelle/refitter/issues"); + Console.WriteLine(); + } + + private sealed class SimpleMultiFileOutputReport : IMultiFileOutputReport + { + public SimpleMultiFileOutputReport() + { + Console.WriteLine("Generated Output Files"); + Console.WriteLine($"{"File",-30} {"Size",-10} {"Lines",-10}"); + Console.WriteLine(new string('-', 55)); + } + + public void AddFile(string fileName, string directory, string sizeFormatted, int lines) => + Console.WriteLine($"{fileName,-30} {sizeFormatted,-10} {lines,-10:N0}"); + + public void Complete(int fileCount, string totalSizeFormatted, int totalLines) + { + Console.WriteLine(new string('-', 55)); + Console.WriteLine($"{"Total (" + fileCount + " files)",-30} {totalSizeFormatted,-10} {totalLines,-10:N0}"); + Console.WriteLine(); + } + } +}