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();
+ }
+ }
+}