diff --git a/docs/design/nuget-caching/nuget-cache.md b/docs/design/nuget-caching/nuget-cache.md index dbd997b..6d8aca3 100644 --- a/docs/design/nuget-caching/nuget-cache.md +++ b/docs/design/nuget-caching/nuget-cache.md @@ -47,10 +47,16 @@ library cooperative in concurrent or UI-driven environments. #### NuGet Settings Integration Rather than accepting source URIs directly, `NuGetCache` reads from the default -NuGet settings on the local machine via `Settings.LoadDefaultSettings(null)`. This -ensures the library respects the same `nuget.config` hierarchy (machine-wide, user, -and project-level) as the `dotnet` CLI and Visual Studio, including authenticated -feeds, proxy settings, and package source mapping. +NuGet settings via `Settings.LoadDefaultSettings(root)`, where `root` is a caller-supplied +directory (typically the directory containing the caller's project or `packages.config` +file) defaulting to the current working directory when omitted. Rooting the lookup this +way - rather than passing a literal `null` root - ensures the library respects the same +`nuget.config` hierarchy (machine-wide, user, and project-level, discovered by walking up +from `root`) as the `dotnet` CLI and Visual Studio, including authenticated feeds, proxy +settings, and package source mapping. A literal `null` root skips the walk-up entirely and +silently loses any repo-local package sources - this was the root cause of GitHub +issue #37, where `nuget-installer` could not see sources defined only in a +repo-local `nuget.config`. #### Early-Exit on Cache Hit @@ -127,7 +133,7 @@ misconfiguration, without requiring additional logging infrastructure. #### Testability via Injected Settings The public `EnsureCachedAsync` method is a thin wrapper that calls the internal overload -with `Settings.LoadDefaultSettings(null)`. The internal overload accepts an `ISettings` +with `Settings.LoadDefaultSettings(root)`. The internal overload accepts an `ISettings` parameter that replaces the call to `LoadDefaultSettings`. This design gives tests full control over the NuGet source and global packages folder without touching the developer's real machine configuration: @@ -149,13 +155,14 @@ without depending on shared, process-wide static state. ### Method Descriptions -#### `EnsureCachedAsync(string packageId, string version, CancellationToken)` (public) +#### `EnsureCachedAsync(string packageId, string version, string? root, CancellationToken)` (public) Thin wrapper that delegates immediately to the internal `EnsureCachedAsync` overload, -passing `Settings.LoadDefaultSettings(null)` as the `settings` argument. This ensures -the public API continues to behave identically to the pre-testability implementation -while the full logic lives in one place. See the internal overload description below -for the complete processing steps. +passing `Settings.LoadDefaultSettings(root ?? Directory.GetCurrentDirectory())` as the +`settings` argument. Callers should pass the directory containing their project or +`packages.config` file as `root` so that a repo-local `nuget.config` is discovered the +same way `dotnet restore` discovers it; omitting `root` falls back to the current working +directory. See the internal overload description below for the complete processing steps. Returns the absolute path to the cached package folder. diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 9f4882d..de1cc07 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -57,8 +57,9 @@ Console.WriteLine($"Package cached at: {packagePath}"); ``` The method reads NuGet configuration (sources and global packages folder) from the default NuGet -settings on the local machine, mirroring the behavior of the `dotnet` CLI. Package source mapping -is fully supported. +settings, rooted at a caller-supplied (or current working) directory, mirroring the behavior of +the `dotnet` CLI - including discovery of a project/repo-local `nuget.config`. Package source +mapping is fully supported. ## API Reference @@ -75,6 +76,7 @@ the local global packages cache. public static async Task EnsureCachedAsync( string packageId, string version, + string? root = null, CancellationToken cancellationToken = default) ``` @@ -86,6 +88,11 @@ Ensures a specific NuGet package version is available in the local global packag - `version` (string): The exact version string (e.g. `13.0.3`). Must not be null. +- `root` (string?): The directory from which to begin discovering `nuget.config` files (walking + up through ancestor directories, then falling back to machine/user-wide settings), matching the + `dotnet` CLI's behavior. Typically the directory containing the caller's project or + `packages.config` file. When null, defaults to the current working directory. + - `cancellationToken` (CancellationToken): Optional cancellation token for the async operation. **Returns:** @@ -129,7 +136,7 @@ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); string packagePath = await NuGetCache.EnsureCachedAsync( "Newtonsoft.Json", "13.0.3", - cts.Token); + cancellationToken: cts.Token); Console.WriteLine($"Package available at: {packagePath}"); ``` diff --git a/src/DemaConsulting.NuGet.Caching/NuGetCache.cs b/src/DemaConsulting.NuGet.Caching/NuGetCache.cs index e1dcfd4..fad46b8 100644 --- a/src/DemaConsulting.NuGet.Caching/NuGetCache.cs +++ b/src/DemaConsulting.NuGet.Caching/NuGetCache.cs @@ -32,8 +32,9 @@ namespace DemaConsulting.NuGet.Caching; /// /// /// This class reads NuGet configuration (sources and global packages folder) from -/// the default NuGet settings on the local machine, mirroring the behavior of -/// the dotnet CLI and Visual Studio package restore. +/// the default NuGet settings, rooted at a caller-supplied (or current working) directory, +/// mirroring the behavior of the dotnet CLI and Visual Studio package restore - +/// including discovery of a project/repo-local nuget.config. /// This class is stateless — all state is local to each EnsureCachedAsync call — and is /// safe for concurrent use. /// @@ -43,13 +44,25 @@ public static class NuGetCache /// Ensures a specific NuGet package version is available in the local global packages cache. /// /// - /// Reads NuGet configuration from the default machine settings via - /// Settings.LoadDefaultSettings, mirroring the behavior of the dotnet - /// CLI and Visual Studio package restore. All caching logic is delegated to the internal - /// overload that accepts an explicit instance. + /// Reads NuGet configuration from the default settings via Settings.LoadDefaultSettings, + /// rooted at (or the current working directory when + /// is ). Rooting the settings lookup this way + /// mirrors the behavior of the dotnet CLI and Visual Studio package restore, which + /// both discover a project/repo-local nuget.config by walking up from an ambient + /// working directory - passing a literal root (as earlier versions of + /// this method did) skips that walk entirely and silently loses any repo-local package + /// sources. All caching logic is delegated to the internal overload that accepts an explicit + /// instance. /// /// The NuGet package identifier (e.g. Newtonsoft.Json). /// The exact version string (e.g. 13.0.3). + /// + /// The directory from which to begin discovering nuget.config files (walking up + /// through ancestor directories, then falling back to machine/user-wide settings), matching + /// the dotnet CLI's behavior. Typically the directory containing the caller's project + /// or packages.config file. When , defaults to + /// . + /// /// Optional cancellation token for the async operation. /// /// The absolute path to the cached package folder inside the global packages folder. @@ -66,11 +79,14 @@ public static class NuGetCache public static async Task EnsureCachedAsync( string packageId, string version, + string? root = null, CancellationToken cancellationToken = default) { - // Delegate to the overload that accepts an explicit ISettings instance, using the - // default machine / user NuGet configuration as the settings source - return await EnsureCachedAsync(packageId, version, Settings.LoadDefaultSettings(null), cancellationToken); + // Delegate to the overload that accepts an explicit ISettings instance, rooting the + // default settings lookup at the provided directory (or the current working directory) + // so that a project/repo-local nuget.config is discovered the same way `dotnet restore` does + return await EnsureCachedAsync( + packageId, version, Settings.LoadDefaultSettings(root ?? Directory.GetCurrentDirectory()), cancellationToken); } /// diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs index f30d92b..13baea0 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheServerTests.cs @@ -849,6 +849,86 @@ public async Task NuGetCache_EnsureCachedAsync_DefaultRegistrar_RegistersRealCre } } + /// + /// Tests that the public NuGetCache.EnsureCachedAsync(packageId, version, root, ...) + /// overload discovers a project/repo-local nuget.config located at its root + /// argument, rather than only reading machine/user-wide settings. + /// + /// + /// This is a regression test for the bug reported in GitHub issue #37: the public overload + /// previously called Settings.LoadDefaultSettings(null), which never scans the + /// working directory (or any ancestor) for a repo-local nuget.config - identical + /// package sources placed in such a file were silently invisible. Here the WireMock source + /// is defined *only* in a real nuget.config file written to an isolated temp + /// directory that is not an ancestor of the process's actual working directory, and is + /// passed in via root: had the settings load ignored root (as before the fix), + /// no source would resolve the package and the call would throw + /// instead of succeeding. + /// + [Fact] + [Trait("Category", "LocalIntegration")] + public async Task NuGetCache_EnsureCachedAsync_RootPointsToDirectoryWithRepoLocalNugetConfig_UsesThatConfigSources() + { + // Arrange - isolated global packages folder and a separate "repo root" directory that + // holds only a project-local nuget.config (simulating a repo checkout) + var globalPackagesFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + var repoRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(globalPackagesFolder); + Directory.CreateDirectory(repoRoot); + + const string packageId = "TestPackage.RepoLocalConfig"; + const string version = "1.0.0"; + + await using var server = new NuGetTestServer(); + var nupkgBytes = NuGetPackageBuilder.CreateMinimalPackage(packageId, version); + server.RegisterV3Package(packageId, version, nupkgBytes); + + // Write a real nuget.config directly into the repo root, mirroring how a repository + // scopes its own package sources independently of machine/user-wide settings + await File.WriteAllTextAsync( + Path.Combine(repoRoot, "nuget.config"), + $""" + + + + + + + + + + + """, + TestContext.Current.CancellationToken); + + // Act - call the public overload, passing the repo root explicitly so the + // repo-local nuget.config is discovered exactly as `dotnet restore` would + var packagePath = await NuGetCache.EnsureCachedAsync( + packageId, version, root: repoRoot, cancellationToken: TestContext.Current.CancellationToken); + + // Assert - the package was found and cached via the repo-local source, proving + // the root parameter was honored when loading default settings + Assert.NotNull(packagePath); + Assert.True( + File.Exists(Path.Combine(packagePath, ".nupkg.metadata")), + $"Expected .nupkg.metadata in: {packagePath}"); + } + finally + { + if (Directory.Exists(globalPackagesFolder)) + { + Directory.Delete(globalPackagesFolder, recursive: true); + } + + if (Directory.Exists(repoRoot)) + { + Directory.Delete(repoRoot, recursive: true); + } + } + } + /// /// A test double implementing that /// simply counts invocations of , letting tests assert that diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs index d915db5..085f9e6 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCacheTests.cs @@ -41,7 +41,7 @@ public async Task NuGetCache_EnsureCachedAsync_ValidPackageId_ReturnsPackageFold const string version = "1.5.0"; // Act - ensure the package is present in the local NuGet global packages cache - var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert - the returned path must point to a real directory on disk Assert.NotNull(packageFolder); @@ -77,7 +77,7 @@ public async Task NuGetCache_EnsureCachedAsync_PackageAbsentFromAllSources_Throw // Act & Assert - calling with a non-existent package must throw InvalidOperationException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); } /// @@ -96,7 +96,7 @@ public async Task NuGetCache_EnsureCachedAsync_NullPackageId_ThrowsArgumentNullE // Act & Assert - calling with null packageId must throw ArgumentNullException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(null!, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(null!, version, cancellationToken: CancellationToken.None)); } /// @@ -115,7 +115,7 @@ public async Task NuGetCache_EnsureCachedAsync_NullVersion_ThrowsArgumentNullExc // Act & Assert - calling with null version must throw ArgumentNullException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, null!, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, null!, cancellationToken: CancellationToken.None)); } /// @@ -131,7 +131,7 @@ public async Task NuGetCache_EnsureCachedAsync_InvalidVersion_ThrowsArgumentExce // Act & Assert: calling with an invalid version must throw ArgumentException _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); } /// @@ -155,7 +155,7 @@ public async Task NuGetCache_EnsureCachedAsync_PackageAbsentFromAllSources_Excep // Act - calling with a non-existent package must throw InvalidOperationException var exception = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); // Assert - the exception message must identify both the package ID and the version // so that callers have enough context to diagnose the problem @@ -180,8 +180,8 @@ public async Task NuGetCache_EnsureCachedAsync_CalledTwiceWithSamePackage_Return const string version = "1.5.0"; // Act - call EnsureCachedAsync twice with the same package identity - var firstPath = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); - var secondPath = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var firstPath = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); + var secondPath = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert - both calls must return identical paths, proving the method is idempotent // and does not change the cache location on subsequent calls diff --git a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs index a2e7d56..8b866fd 100644 --- a/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs +++ b/test/DemaConsulting.NuGet.Caching.Tests/NuGetCachingTests.cs @@ -42,7 +42,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenKnownPackageAndVersionProvi const string version = "1.5.0"; // Act: invoke the library's package caching capability - var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None); + var packageFolder = await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None); // Assert: the library returned a valid path to the cached package on disk Assert.NotNull(packageFolder); @@ -80,7 +80,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenPackageNotFound_ThrowsInval // Act & Assert: the library should throw when the package cannot be found var ex = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, version, cancellationToken: CancellationToken.None)); // Assert: the exception message must identify the package ID and version Assert.Contains(packageId, ex.Message); @@ -103,7 +103,7 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenPackageIdIsNull_ThrowsArgum // Act & Assert: the library should throw ArgumentNullException for a null package ID _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(null!, version, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(null!, version, cancellationToken: CancellationToken.None)); } /// @@ -122,6 +122,6 @@ public async Task NuGetCaching_EnsureCachedAsync_WhenVersionIsNull_ThrowsArgumen // Act & Assert: the library should throw ArgumentNullException for a null version _ = await Assert.ThrowsAsync( - async () => await NuGetCache.EnsureCachedAsync(packageId, null!, CancellationToken.None)); + async () => await NuGetCache.EnsureCachedAsync(packageId, null!, cancellationToken: CancellationToken.None)); } }