Skip to content
3 changes: 3 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
68 changes: 68 additions & 0 deletions src/Refitter.Core/RefitterSettingsLoader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace Refitter.Core;

/// <summary>
/// Loads <see cref="RefitGeneratorSettings"/> from the contents of a <c>.refitter</c> settings file.
/// </summary>
/// <remarks>
/// 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 <c>AdditionalText</c>), which
/// keeps filesystem APIs out of the analyzer assembly and preserves incremental-generation semantics.
/// </remarks>
public static class RefitterSettingsLoader
{
/// <summary>
/// Deserializes the contents of a <c>.refitter</c> settings file and resolves any relative
/// OpenAPI specification paths against the directory the settings file lives in.
/// </summary>
/// <param name="json">The raw contents of the <c>.refitter</c> settings file.</param>
/// <param name="baseDirectory">
/// The directory the settings file lives in. Relative <see cref="RefitGeneratorSettings.OpenApiPath"/>
/// and <see cref="RefitGeneratorSettings.OpenApiPaths"/> entries are resolved against this directory.
/// </param>
/// <returns>The deserialized settings with relative specification paths resolved to absolute paths.</returns>
/// <exception cref="System.Text.Json.JsonException">Thrown when <paramref name="json"/> cannot be deserialized.</exception>
public static RefitGeneratorSettings Load(string json, string baseDirectory)
{
var settings = Serializer.Deserialize<RefitGeneratorSettings>(json);
ResolveRelativeSpecPaths(settings, baseDirectory);
return settings;
Comment on lines +27 to +29
Comment on lines +25 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In System.Text.Json for modern .NET, what does JsonSerializer.Deserialize("null")return for a reference typeT? Does it throw, or return null?

💡 Result:

In System.Text.Json, calling JsonSerializer.Deserialize("null") for a reference type T will return null [1][2]. It does not throw an exception because "null" is considered valid JSON [1][2]. This behavior occurs because the JSON string "null" is a valid JSON value that represents a null reference in.NET [1][2]. Consequently, the deserializer returns a null reference of type T [2]. This is why the return type of the Deserialize method is marked as nullable (T?) in the.NET API documentation; the method must account for the possibility of returning null when the input JSON is explicitly "null" [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant parts of the file to confirm behavior for null settings and OpenApiPaths handling
FILE="src/Refitter.Core/RefitterSettingsLoader.cs"

echo "=== File: $FILE ==="
stat -c "%s bytes" "$FILE" 2>/dev/null || true
echo
sed -n '1,200p' "$FILE" | nl -ba

Repository: christianhelle/refitter

Length of output: 188


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/Refitter.Core/RefitterSettingsLoader.cs"

echo "=== ls/exists ==="
ls -l "$FILE" || true
echo

echo "=== methods via rg ==="
rg -n "static\s+RefitGeneratorSettings\s+Load|ResolveRelativeSpecPaths|IsUrl" "$FILE" || true
echo

echo "=== lines 1-120 ==="
sed -n '1,120p' "$FILE" | cat
echo

echo "=== lines 20-80 ==="
sed -n '20,80p' "$FILE" | cat
echo

echo "=== lines 80-200 (if any) ==="
sed -n '80,200p' "$FILE" | cat

Repository: christianhelle/refitter

Length of output: 6611


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden --no-ignore-vcs "class\s+Serializer|static\s+class\s+Serializer|Serializer\.Deserialize<" src/Refitter.Core

Repository: christianhelle/refitter

Length of output: 372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,200p' src/Refitter.Core/Serializer.cs | cat

echo "=== RefitGeneratorSettings type ==="
rg -n "class\s+RefitGeneratorSettings|record\s+RefitGeneratorSettings|struct\s+RefitGeneratorSettings" -S src/Refitter.Core
# Print the matching file(s)
fd -e cs -a "RefitGeneratorSettings" src/Refitter.Core | head -n 20
# Fallback: search OpenApiPaths property definition
echo "=== OpenApiPaths property ==="
rg -n "OpenApiPaths" src/Refitter.Core -S

Repository: christianhelle/refitter

Length of output: 3606


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/Refitter.Core/Settings/RefitGeneratorSettings.cs"
sed -n '1,220p' "$FILE" | cat

Repository: christianhelle/refitter

Length of output: 9193


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n --hidden --no-ignore-vcs "RefitterSettingsLoader\.Load\(" -S src

Repository: christianhelle/refitter

Length of output: 360


🌐 Web query:

In .NET, what happens when calling System.IO.Path.IsPathRooted with a null string argument? Does it throw or return false?

💡 Result:

Calling System.IO.Path.IsPathRooted with a null string argument throws an ArgumentNullException; it does not return false. [1][2] In the current.NET implementation, the string overload uses ArgumentNullException.ThrowIfNull(path) before checking whether the path contains a root. [1] Microsoft Learn’s API docs also state that the method’s string overload throws ArgumentNullException when path is null. [2]

Citations:


🌐 Web query:

In .NET, what happens when calling System.IO.Path.Combine with a null segment (e.g., Path.Combine("base", (string)null))? Does it throw ArgumentNullException?

💡 Result:

Yes—System.IO.Path.Combine throws ArgumentNullException when any path segment argument it receives is null (including calls like Path.Combine("base", (string)null)). Microsoft’s API docs state that path1 or path2 is null as a parameter error for Path.Combine, which corresponds to ArgumentNullException.[1][2] In other words: - Path.Combine("base", (string)null) => ArgumentNullException (for the null segment argument, e.g., path2). [1][2] Note: This is for the string-based overloads described in the docs (Combine(String, String), and the multi-parameter overloads similarly validate their inputs).

Citations:


🌐 Web query:

In .NET, what does Uri.TryCreate return when the input string is null? Does it throw?

💡 Result:

Uri.TryCreate(string? uriString, UriKind uriKind, out Uri? result) returns false when the input string is null, and it does not throw. Reason: the Microsoft Learn documentation describes TryCreate as returning a bool indicating success and not throwing for invalid URIs, and the reference source implementation checks if (uriString is null) { return null; }, which causes TryCreate to fail and leave result unset/null. [1][2] So for null input, expect: - Return value: false [1] - out parameter: null (uninitialized / null) [1][2] - Exceptions: none [1]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/Refitter.Tests/SettingsFile/RefitterSettingsLoaderTests.cs"
ls -l "$FILE" || true
sed -n '1,200p' "$FILE" | cat

Repository: christianhelle/refitter

Length of output: 2992


Guard against null deserialized settings and null elements in OpenApiPaths

  • Load dereferences settings even when the JSON payload is the valid literal "null": Serializer.Deserialize<RefitGeneratorSettings>("null") returns null, so ResolveRelativeSpecPaths(settings, ...) throws NullReferenceException instead of a JsonException.
  • ResolveRelativeSpecPaths iterates settings.OpenApiPaths without handling null/blank elements; a null element will reach Path.IsPathRooted(path) and throw ArgumentNullException.
Proposed fix
 public static RefitGeneratorSettings Load(string json, string baseDirectory)
 {
-    var settings = Serializer.Deserialize<RefitGeneratorSettings>(json);
+    var settings = Serializer.Deserialize<RefitGeneratorSettings>(json)
+        ?? throw new System.Text.Json.JsonException("The .refitter settings payload must deserialize to an object.");
     ResolveRelativeSpecPaths(settings, baseDirectory);
     return settings;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static RefitGeneratorSettings Load(string json, string baseDirectory)
{
var settings = Serializer.Deserialize<RefitGeneratorSettings>(json);
ResolveRelativeSpecPaths(settings, baseDirectory);
return settings;
public static RefitGeneratorSettings Load(string json, string baseDirectory)
{
var settings = Serializer.Deserialize<RefitGeneratorSettings>(json)
?? throw new System.Text.Json.JsonException("The .refitter settings payload must deserialize to an object.");
ResolveRelativeSpecPaths(settings, baseDirectory);
return settings;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Refitter.Core/RefitterSettingsLoader.cs` around lines 25 - 29, The Load
method must guard against a null result from
Serializer.Deserialize<RefitGeneratorSettings>(json) and throw a JsonException
(or rethrow a JsonException-like error) when json deserializes to null; update
Load to check if settings is null and throw accordingly before calling
ResolveRelativeSpecPaths. Also harden ResolveRelativeSpecPaths to handle a null
or empty settings.OpenApiPaths collection and to skip null/whitespace elements
(check for settings.OpenApiPaths == null and use string.IsNullOrWhiteSpace(path)
before calling Path.IsPathRooted(path)), so null list or null entries do not
cause ArgumentNullException.

}

/// <summary>
/// Resolves any relative OpenAPI specification paths on <paramref name="settings"/> against
/// <paramref name="baseDirectory"/>. URLs and already-rooted paths are left untouched.
/// </summary>
/// <param name="settings">The settings whose specification paths should be resolved.</param>
/// <param name="baseDirectory">The directory to resolve relative paths against.</param>
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));
}
Comment on lines +47 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In .NET, what do Path.IsPathRooted(null), Path.Combine(baseDirectory, ""), and Path.Combine(baseDirectory, null) do?

💡 Result:

In.NET, the behaviors for these Path methods are as follows: Path.IsPathRooted(null) Calling Path.IsPathRooted with a null argument throws an ArgumentNullException [1][2][3]. Path.Combine(baseDirectory, "") If the second argument is an empty string (""), Path.Combine returns the baseDirectory (optionally with a directory separator character appended if it is missing) [4][5][6]. It does not throw an exception for an empty string [6]. Path.Combine(baseDirectory, null) Calling Path.Combine with a null argument for any of its path parameters throws an ArgumentNullException [7]. Unlike the empty string, a null value is not treated as a valid path component [6][7].

Citations:


Skip null/whitespace entries in OpenApiPaths (in ResolveRelativeSpecPaths)
Path.IsPathRooted(null) throws ArgumentNullException, and empty/whitespace values get normalized into baseDirectory via Path.Combine(baseDirectory, ""), so malformed entries are rewritten instead of being left for validation.

Proposed fix
         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))
+                if (string.IsNullOrWhiteSpace(path) || IsUrl(path) || Path.IsPathRooted(path))
                 {
-                    settings.OpenApiPaths[i] = Path.GetFullPath(Path.Combine(baseDirectory, path));
+                    continue;
                 }
+
+                settings.OpenApiPaths[i] = Path.GetFullPath(Path.Combine(baseDirectory, path));
             }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Refitter.Core/RefitterSettingsLoader.cs` around lines 47 - 55, In
ResolveRelativeSpecPaths, skip null or whitespace entries in
settings.OpenApiPaths before calling Path.IsPathRooted or Path.Combine: add a
guard like if (string.IsNullOrWhiteSpace(path)) continue; so you don't call
Path.IsPathRooted(null) or normalize empty strings into baseDirectory; keep the
IsUrl(path) check and only update settings.OpenApiPaths[i] when path is
non-empty, non-URL and non-rooted.

}
}
}

/// <summary>
/// Determines whether the specified path is an absolute HTTP or HTTPS URL.
/// </summary>
/// <param name="path">The path to inspect.</param>
/// <returns><see langword="true"/> when the path is an HTTP or HTTPS URL; otherwise <see langword="false"/>.</returns>
public static bool IsUrl(string path) =>
Uri.TryCreate(path, UriKind.Absolute, out var uriResult) &&
(uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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"));
Expand Down
26 changes: 1 addition & 25 deletions src/Refitter.SourceGenerator/RefitterSourceGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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() =>
Expand Down
92 changes: 92 additions & 0 deletions src/Refitter.Tests/FileWriterTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
84 changes: 84 additions & 0 deletions src/Refitter.Tests/OpenApiStatisticsFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading