Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using OpenRewrite.CSharp.Rpc;

namespace OpenRewrite.Tests.Rpc;

/// <summary>
/// When a local NuGet feed (<c>~/.nuget/local-feed</c>) exists for cross-repo
/// development, the server makes it additive to the recipe project's
/// <c>nuget.config</c>: it creates one (public + local feed) when none exists,
/// and otherwise only appends the local feed — preserving a caller-written
/// config (e.g. an exclusive configured feed) untouched.
/// </summary>
public class RecipesNuGetConfigTests
{
private const string LocalFeed = "/home/dev/.nuget/local-feed";

[Fact]
public void CreatesPublicPlusLocalFeedWhenNoConfigExists()
{
string xml = RewriteRpcServer.BuildRecipesNuGetConfig(null, LocalFeed);

Assert.Contains("api.nuget.org", xml);
Assert.Contains(LocalFeed, xml);
}

[Fact]
public void AppendsOnlyLocalFeedToExistingConfig()
{
// A caller-written exclusive config: nuget.org cleared, one configured feed.
string existing = """
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="internal" value="http://localhost:9091/nuget-all" allowInsecureConnections="true" />
</packageSources>
</configuration>
""";

string xml = RewriteRpcServer.BuildRecipesNuGetConfig(existing, LocalFeed);

// Caller's exclusivity and feed are preserved...
Assert.Contains("<clear", xml);
Assert.Contains("http://localhost:9091/nuget-all", xml);
// ...local feed is added...
Assert.Contains(LocalFeed, xml);
// ...and nuget.org is NOT reintroduced.
Assert.DoesNotContain("api.nuget.org", xml);
}

[Fact]
public void IsIdempotentWhenLocalFeedAlreadyPresent()
{
string existing = $"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="local-feed" value="{LocalFeed}" />
</packageSources>
</configuration>
""";

string xml = RewriteRpcServer.BuildRecipesNuGetConfig(existing, LocalFeed);

int occurrences = xml.Split(LocalFeed).Length - 1;
Assert.Equal(1, occurrences);
}
}
5 changes: 4 additions & 1 deletion rewrite-csharp/csharp/OpenRewrite.Tool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
var logFile = args.FirstOrDefault(a => a.StartsWith("--log-file="))
?.Substring("--log-file=".Length);

var recipeInstallDir = args.FirstOrDefault(a => a.StartsWith("--recipe-install-dir="))
?.Substring("--recipe-install-dir=".Length);

var loggerConfig = new LoggerConfiguration();
if (logFile != null)
{
Expand All @@ -43,5 +46,5 @@
Log.Debug("<< Parser warmup ({Elapsed})", sw.Elapsed);

Log.Information("Starting RPC server");
await RewriteRpcServer.RunAsync();
await RewriteRpcServer.RunAsync(recipeInstallDir: recipeInstallDir);
Log.Information("RPC server exited");
69 changes: 60 additions & 9 deletions rewrite-csharp/csharp/OpenRewrite/CSharp/Rpc/RewriteRpcServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using System.Diagnostics;
using System.Reflection;
using System.Runtime.Loader;
using System.Xml.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
Expand Down Expand Up @@ -53,6 +54,7 @@ public class RewriteRpcServer
private readonly ConcurrentDictionary<string, object?> _recipeAccumulators = new();
private readonly ConcurrentDictionary<string, ExecutionContext> _executionContexts = new();
private string? _recipesProjectDir;
private readonly string? _recipeInstallDir;
private JsonRpc? _jsonRpc;
private DotNetBuildContext? _buildContext;

Expand Down Expand Up @@ -88,9 +90,10 @@ public void Connect(JsonRpc jsonRpc)
jsonRpc.StartListening();
}

public RewriteRpcServer(RecipeMarketplace marketplace)
public RewriteRpcServer(RecipeMarketplace marketplace, string? recipeInstallDir = null)
{
_marketplace = marketplace;
_recipeInstallDir = recipeInstallDir;

// Register type name overrides for nagoya types that don't match Java names
RpcSendQueue.RegisterJavaTypeName(typeof(CsLambda),
Expand Down Expand Up @@ -636,7 +639,11 @@ private string EnsureRecipesProject()
return existing;
}

_recipesProjectDir = Path.Combine(Path.GetTempPath(), "rewrite-recipes", Guid.NewGuid().ToString("N")[..8]);
// Use the caller-supplied recipe install directory when provided (so a
// co-located NuGet.config is found by dotnet's project-directory config
// walk); otherwise fall back to a unique temp directory.
_recipesProjectDir = _recipeInstallDir
?? Path.Combine(Path.GetTempPath(), "rewrite-recipes", Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(_recipesProjectDir);

var csprojPath = Path.Combine(_recipesProjectDir, "Recipes.csproj");
Expand All @@ -649,26 +656,69 @@ private string EnsureRecipesProject()
</Project>
""");

// Add local NuGet feed as a package source if it exists, so that
// locally-published SDK snapshots are discovered alongside nuget.org
// For local cross-repo development, make the local NuGet feed additive to
// whatever config already lives in the project dir. A caller (e.g. the Moderne
// CLI) may have written its own nuget.config there — possibly an exclusive
// configured feed — so we must not clobber it: append only the local feed when
// a config is present, and create the standalone dev default (public + local
// feed) only when none exists. No-ops in production, where local-feed is absent.
var localFeed = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".nuget", "local-feed");
if (Directory.Exists(localFeed))
{
var nugetConfig = Path.Combine(_recipesProjectDir, "nuget.config");
File.WriteAllText(nugetConfig, $"""
var existing = File.Exists(nugetConfig) ? File.ReadAllText(nugetConfig) : null;
File.WriteAllText(nugetConfig, BuildRecipesNuGetConfig(existing, localFeed));
}

return csprojPath;
}

/// <summary>
/// Produces the recipe project's <c>nuget.config</c> with the local development
/// feed present. When <paramref name="existingConfigXml"/> is null/empty, creates a
/// standalone config with nuget.org + the local feed. Otherwise the caller already
/// wrote a config (possibly an exclusive configured feed): only the local feed is
/// appended to <c>&lt;packageSources&gt;</c>, preserving the caller's sources and any
/// <c>&lt;clear/&gt;</c>, and idempotently (no duplicate if already present).
/// </summary>
internal static string BuildRecipesNuGetConfig(string? existingConfigXml, string localFeedPath)
{
if (string.IsNullOrWhiteSpace(existingConfigXml))
{
return $"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="local-feed" value="{localFeed}" />
<add key="local-feed" value="{localFeedPath}" />
</packageSources>
</configuration>
""");
""";
}

return csprojPath;
var doc = XDocument.Parse(existingConfigXml);
var configuration = doc.Element("configuration")
?? throw new InvalidOperationException("nuget.config is missing its <configuration> root");
var packageSources = configuration.Element("packageSources");
if (packageSources == null)
{
packageSources = new XElement("packageSources");
configuration.Add(packageSources);
}

bool alreadyPresent = packageSources.Elements("add").Any(e =>
string.Equals((string?)e.Attribute("value"), localFeedPath, StringComparison.OrdinalIgnoreCase));
if (!alreadyPresent)
{
packageSources.Add(new XElement("add",
new XAttribute("key", "local-feed"),
new XAttribute("value", localFeedPath)));
}

var declaration = doc.Declaration ?? new XDeclaration("1.0", "utf-8", null);
return declaration + Environment.NewLine + doc.Root!.ToString();
}

private static void RunDotnet(string arguments)
Expand Down Expand Up @@ -1218,6 +1268,7 @@ public override Marker VisitMarker(Marker marker, int p)
}

public static async Task RunAsync(RecipeMarketplace? marketplace = null,
string? recipeInstallDir = null,
CancellationToken cancellationToken = default)
{
marketplace ??= new RecipeMarketplace();
Expand Down Expand Up @@ -1246,7 +1297,7 @@ public static async Task RunAsync(RecipeMarketplace? marketplace = null,
var handler = new HeaderDelimitedMessageHandler(outputStream, inputStream, formatter);
using var jsonRpc = new StringErrorDataJsonRpc(handler);

var server = new RewriteRpcServer(marketplace);
var server = new RewriteRpcServer(marketplace, recipeInstallDir);
server._jsonRpc = jsonRpc;
_current = server;
// Allow concurrent request dispatch so reentrant callbacks don't deadlock.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ public static class Builder implements Supplier<CSharpRewriteRpc> {
private Duration timeout = Duration.ofSeconds(60);
private boolean traceRpcMessages;
private @Nullable Path workingDirectory;
private @Nullable Path recipeInstallDir;
private @Nullable Path profileOutputPath;

public Builder marketplace(RecipeMarketplace marketplace) {
Expand Down Expand Up @@ -278,6 +279,20 @@ public Builder workingDirectory(@Nullable Path workingDirectory) {
return this;
}

/**
* Directory in which the server creates its recipe project (where
* {@code dotnet add package} resolves recipe NuGet packages). Set this so a
* caller-written {@code NuGet.config} co-located in the directory is honored
* by dotnet's project-directory config discovery.
*
* @param recipeInstallDir The directory to create the recipe project in
* @return This builder
*/
public Builder recipeInstallDir(@Nullable Path recipeInstallDir) {
this.recipeInstallDir = recipeInstallDir;
return this;
}

/**
* Enable .NET EventPipe profiling on the C# process. Captures CPU sampling,
* GC events, sampled allocations, and runtime counters into a {@code .nettrace}
Expand Down Expand Up @@ -310,7 +325,8 @@ public CSharpRewriteRpc get() {
dotnetPath.toString(),
csharpServerEntry.toAbsolutePath().normalize().toString(),
log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(),
traceRpcMessages ? "--trace-rpc-messages" : null
traceRpcMessages ? "--trace-rpc-messages" : null,
recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize()
);
}
} else {
Expand Down Expand Up @@ -367,7 +383,8 @@ private CSharpRewriteRpc startProcess(Stream<@Nullable String> cmd) {
"--project", csproj.toAbsolutePath().normalize().toString(),
"--framework", "net10.0",
log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(),
traceRpcMessages ? "--trace-rpc-messages" : null
traceRpcMessages ? "--trace-rpc-messages" : null,
recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize()
);
}

Expand All @@ -393,7 +410,8 @@ private CSharpRewriteRpc startProcess(Stream<@Nullable String> cmd) {
return Stream.of(
toolExecutable.toAbsolutePath().normalize().toString(),
log == null ? null : "--log-file=" + log.toAbsolutePath().normalize(),
traceRpcMessages ? "--trace-rpc-messages" : null
traceRpcMessages ? "--trace-rpc-messages" : null,
recipeInstallDir == null ? null : "--recipe-install-dir=" + recipeInstallDir.toAbsolutePath().normalize()
);
}

Expand Down